@camstack/addon-provider-homeassistant 1.2.28 → 1.2.30

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -3,7 +3,7 @@ Object.defineProperties(exports, {
3
3
  [Symbol.toStringTag]: { value: "Module" }
4
4
  });
5
5
  //#endregion
6
- const require_dist = require("../dist-D_Zn5oFS.js");
6
+ const require_dist = require("../dist-XWveotlj.js");
7
7
  let node_crypto = require("node:crypto");
8
8
  //#region src/ha-export/topics.ts
9
9
  /**
@@ -67,11 +67,861 @@ function humanise(entity) {
67
67
  function toComponentKey(platform, entity) {
68
68
  return `${platform}-${entity.replace(/_/g, "-").replace(/[^a-zA-Z0-9_-]/g, "_")}`;
69
69
  }
70
+ /**
71
+ * The entity id of a DERIVED capability entity — the one expression.
72
+ *
73
+ * The derived half of the catalog names its entity after the capability
74
+ * it comes from, and the projector has to publish to that exact name.
75
+ * They used to be two expressions in two files, and they disagreed:
76
+ * the catalog built `camstack/<key>/motion` while the projector
77
+ * published `motion_detected`, `triggered` and a set of doorbell topics
78
+ * of its own. **Only `battery` and `online` lined up**, so a non-camera
79
+ * motion, contact, smoke, leak or doorbell device got entities that
80
+ * could never receive a value — the same failure the 2026-08-05 audit
81
+ * measured on the old exporter (177 of 293 devices), reproduced on a
82
+ * narrower set.
83
+ *
84
+ * Both sides now come from here, and
85
+ * `__tests__/state-projector.spec.ts` walks every `CAP_ENTITY_MAP` entry
86
+ * asserting the catalog's `state_topic` is the topic the projector
87
+ * publishes to. That test is the guard: it fails the moment either side
88
+ * drifts.
89
+ */
90
+ function derivedEntityId(capName) {
91
+ return toSlug(capName);
92
+ }
93
+ /**
94
+ * The entity id of a SECONDARY entity of the same capability.
95
+ *
96
+ * The nine allowed platforms cannot express a cover, a thermostat or a
97
+ * media player as one entity, so a capability may produce several: a
98
+ * `cover` is its state AND its position, a `climate-control` is its mode
99
+ * AND the temperature it measures. They are still ONE capability and one
100
+ * slice, so their ids are built from the same expression as the primary
101
+ * — `<cap>_<suffix>` — rather than invented per row. Two expressions in
102
+ * two files is exactly what left a non-camera contact sensor with an
103
+ * entity that could never receive a value.
104
+ */
105
+ function derivedExtraEntityId(capName, suffix) {
106
+ return `${toSlug(capName)}_${toSlug(suffix)}`;
107
+ }
70
108
  /** Lower-case, underscore-joined, diacritic-free. Ids only, never labels. */
71
109
  function toSlug(value) {
72
110
  return value.normalize("NFKD").replace(/[\u0300-\u036f]/g, "").replace(/([a-z0-9])([A-Z])/g, "$1_$2").replace(/[^a-zA-Z0-9]+/g, "_").replace(/^_+|_+$/g, "").toLowerCase();
73
111
  }
74
112
  //#endregion
113
+ //#region src/ha-export/camera-entities.ts
114
+ /**
115
+ * The live `camera` entities — one per STREAM of the camera.
116
+ *
117
+ * ── What was wrong, measured on the operator's Home Assistant ────────────
118
+ *
119
+ * This module used to mint six components per camera (three profiles ×
120
+ * muted/unmuted), each carrying a broker RTSP url on a `state_topic`,
121
+ * behind two url gates: a refusal for a credential in the authority and a
122
+ * refusal for a loopback host. On 2026-08-14 the live registry was read:
123
+ * `platform: camstack` held **165 entities and exactly two `camera` ones**
124
+ * — one per exported camera, `unique_id` `camstack_<stableId>_camera`.
125
+ * None of the twelve announced stream components existed.
126
+ *
127
+ * They never could. `custom_components/camstack/camera.py` builds its
128
+ * cameras from the export MEMBERSHIP, not from `cmps`: a still comes from
129
+ * `snapshot.getSnapshot` and live video from `webrtcSession.handleOffer`,
130
+ * which is native WebRTC and has no use for an RTSP address. The
131
+ * component's platform probe answers `camera` — truthfully, it builds the
132
+ * platform — and the hub read that as "it will build MY camera
133
+ * components". Twelve components, two entities, no warning on either
134
+ * side.
135
+ *
136
+ * ── What this module is now ──────────────────────────────────────────────
137
+ *
138
+ * One component per entry of `webrtcSession.listStreams`, carrying the
139
+ * `target` verbatim so the component hands it back to `handleOffer`. No
140
+ * url crosses, which is why the two gates are gone rather than kept "just
141
+ * in case": there is no address here to leak a password or to point at
142
+ * 127.0.0.1, and a gate guarding a value that no longer exists is the
143
+ * leftover that reads as verification.
144
+ *
145
+ * ── The adaptive stream is announced by NOBODY, on purpose ───────────────
146
+ *
147
+ * The component's membership entity IS the adaptive stream: it negotiates
148
+ * no target, it is the device's primary camera (`_attr_name = None`), and
149
+ * it has carried `camstack_<stableId>_camera` since the integration
150
+ * shipped. Announcing a component for it would claim the same
151
+ * `unique_id` — Home Assistant keys its registry on
152
+ * `(platform, integration, unique_id)` — and one of the two would lose the
153
+ * race and be dropped with a "does not generate unique IDs" error. So the
154
+ * operator's existing `camera.videocamera_ingresso` is left exactly where
155
+ * it is and gains SIBLINGS: one per profile, one per substream, which are
156
+ * the streams membership cannot express.
157
+ *
158
+ * ── Fan-out ──────────────────────────────────────────────────────────────
159
+ *
160
+ * Every remaining stream is announced. What arrives ENABLED is the
161
+ * assigned profiles — the pictures an operator picks between on a
162
+ * dashboard. The raw `cam-stream` sources (the camera's own main/sub
163
+ * substreams) are registered-and-off: they are the same pictures the
164
+ * profiles already serve, one layer below the slot the rest of camstack
165
+ * dials, and an operator who wants one switches it on per camera.
166
+ */
167
+ /** Profile slots, in the order an operator reads them. */
168
+ var CAMERA_PROFILE_ORDER = [
169
+ "high",
170
+ "mid",
171
+ "low"
172
+ ];
173
+ /** `high` → 0. An unknown profile sorts after the known ones, stably. */
174
+ function profileRank(profile) {
175
+ const index = CAMERA_PROFILE_ORDER.indexOf(profile);
176
+ return index === -1 ? CAMERA_PROFILE_ORDER.length : index;
177
+ }
178
+ /**
179
+ * Reading order: adaptive, then the profile slots high → mid → low, then
180
+ * whatever raw sources the camera reports, in the hub's own order.
181
+ *
182
+ * Rank only. The one stream that does NOT become a component is the
183
+ * adaptive one, and it is filtered by {@link isAnnounced} rather than
184
+ * here, because it is not a drop: it reaches Home Assistant on the
185
+ * membership entity. Every other slot the hub reports becomes an entity —
186
+ * "include all the streams of a camera" is the request this module exists
187
+ * to answer, and a slot silently dropped is a loss the operator cannot see
188
+ * from Home Assistant.
189
+ */
190
+ function streamRank(target) {
191
+ switch (target.kind) {
192
+ case "adaptive": return 0;
193
+ case "profile": return 1 + profileRank(target.profile);
194
+ case "cam-stream": return 1 + CAMERA_PROFILE_ORDER.length;
195
+ }
196
+ }
197
+ /**
198
+ * The adaptive stream is the component's own entity and gets no component.
199
+ *
200
+ * The one filter in this file, and the reason it is not the "drop a slot"
201
+ * kind: the stream still reaches Home Assistant, on the entity that has
202
+ * always carried it. See the identity note above.
203
+ */
204
+ function isAnnounced(target) {
205
+ return target.kind !== "adaptive";
206
+ }
207
+ /**
208
+ * The entity id of one stream.
209
+ *
210
+ * Derived from the TARGET rather than from `choice.id`, so the ids stay
211
+ * `stream_high` / `stream_mid` / `stream_low` — what this file already
212
+ * announced — instead of following the picker's `profile:high` spelling.
213
+ * An entity id is identity; the picker's id is a React key.
214
+ */
215
+ function entityIdFor(target) {
216
+ switch (target.kind) {
217
+ case "adaptive":
218
+ /* c8 ignore next -- filtered by isAnnounced before it can be reached */
219
+ return "stream_adaptive";
220
+ case "profile": return `stream_${toSlug(target.profile)}`;
221
+ case "cam-stream": return `stream_${toSlug(target.camStreamId)}`;
222
+ }
223
+ }
224
+ /**
225
+ * Enabled by default: the assigned profiles.
226
+ *
227
+ * A raw `cam-stream` is the source a profile slot already points at. It
228
+ * is announced so an operator who wants the camera's own substream can
229
+ * have it, and it is off so that a camera does not arrive with six
230
+ * pictures of the same doorway switched on.
231
+ */
232
+ function enabledByDefaultFor(target) {
233
+ return target.kind !== "cam-stream";
234
+ }
235
+ /**
236
+ * The label an operator reads on a card.
237
+ *
238
+ * The hub's own label plus the vertical resolution, because that is the
239
+ * one thing that distinguishes three pictures of the same doorway —
240
+ * "Stream high (2160p)" answers "which of these do I put on the wall
241
+ * tablet" and "Stream high" does not. The resolution is only in the label
242
+ * (never in the id), so a re-encoded profile renames an entity and never
243
+ * orphans one.
244
+ */
245
+ function labelFor(choice) {
246
+ const base = `Stream ${choice.label.toLowerCase()}`;
247
+ return choice.height === void 0 ? base : `${base} (${choice.height}p)`;
248
+ }
249
+ /**
250
+ * Stream list → the `camera` entities the catalog builds.
251
+ *
252
+ * Pure, and value-free: a `camera` entity has no state on the push plane
253
+ * at all. It exists because the hub lists the stream, and everything the
254
+ * component needs to show it travels in `stream_target`.
255
+ */
256
+ function cameraStreamEntities(choices) {
257
+ return [...choices].filter((choice) => isAnnounced(choice.target)).sort((a, b) => streamRank(a.target) - streamRank(b.target)).map((choice) => ({
258
+ entity: entityIdFor(choice.target),
259
+ platform: "camera",
260
+ label: labelFor(choice),
261
+ enabledByDefault: enabledByDefaultFor(choice.target),
262
+ target: choice.target
263
+ }));
264
+ }
265
+ //#endregion
266
+ //#region src/ha-export/component-support.ts
267
+ /** Where the component publishes what it builds. A wire format. */
268
+ var COMPONENT_VERSION_PATH = "/api/camstack/version";
269
+ /**
270
+ * The platforms every released component builds.
271
+ *
272
+ * Assumed when the component does not answer the probe at all, which is
273
+ * exactly what 0.3.x and older do: the endpoint arrived with the native
274
+ * platforms. `alarm_control_panel` is deliberately ABSENT — the export has
275
+ * been emitting it since T1 and the 0.3.x component never built it, so
276
+ * assuming it here would keep an alarm panel invisible on the very
277
+ * installations this list exists to protect.
278
+ */
279
+ var LEGACY_PLATFORMS = [
280
+ "binary_sensor",
281
+ "sensor",
282
+ "image",
283
+ "switch",
284
+ "button",
285
+ "select",
286
+ "camera",
287
+ "number"
288
+ ];
289
+ var LEGACY_SUPPORT = new Set(LEGACY_PLATFORMS);
290
+ /**
291
+ * What the catalog assumes when NOBODY asked.
292
+ *
293
+ * Deliberately not {@link LEGACY_SUPPORT}: the platforms the degraded
294
+ * tables themselves name, which is exactly the behaviour the export had
295
+ * before any of this existed. A caller that does not negotiate therefore
296
+ * gets what it always got — no native platforms, and no degradation
297
+ * either. Both changes are decisions the NEGOTIATION makes, on an answer
298
+ * from the Home Assistant that has to build them, and neither is a
299
+ * default a test fixture or a future caller can trip over by accident.
300
+ */
301
+ var UNNEGOTIATED_SUPPORT = new Set([...LEGACY_PLATFORMS, "alarm_control_panel"]);
302
+ var KNOWN_PLATFORMS = new Set([
303
+ "binary_sensor",
304
+ "sensor",
305
+ "image",
306
+ "switch",
307
+ "button",
308
+ "select",
309
+ "camera",
310
+ "alarm_control_panel",
311
+ "number",
312
+ "cover",
313
+ "climate",
314
+ "lock",
315
+ "fan",
316
+ "vacuum",
317
+ "valve",
318
+ "humidifier",
319
+ "water_heater",
320
+ "media_player"
321
+ ]);
322
+ function isKnownPlatform(value) {
323
+ return KNOWN_PLATFORMS.has(value);
324
+ }
325
+ /**
326
+ * Parse the component's answer.
327
+ *
328
+ * A platform this hub does not know is DROPPED rather than refused: the
329
+ * component ships on its own train and may well build something newer
330
+ * than this hub can emit, and refusing the whole report over one unknown
331
+ * name would downgrade every entity on that installation.
332
+ */
333
+ function parseComponentReport(body) {
334
+ if (body === null || typeof body !== "object") return null;
335
+ const raw = Reflect.get(body, "platforms");
336
+ if (!Array.isArray(raw)) return null;
337
+ const platforms = /* @__PURE__ */ new Set();
338
+ for (const entry of raw) if (typeof entry === "string" && isKnownPlatform(entry)) platforms.add(entry);
339
+ if (platforms.size === 0) return null;
340
+ const version = Reflect.get(body, "version");
341
+ return {
342
+ version: typeof version === "string" ? version : null,
343
+ platforms
344
+ };
345
+ }
346
+ /** Stable, comparable text for a support set. Used for change detection. */
347
+ function supportSignature(support) {
348
+ return [...support].sort().join(",");
349
+ }
350
+ /**
351
+ * The platforms every one of these components builds.
352
+ *
353
+ * A device exported to two Home Assistant instances is announced ONCE, so
354
+ * a platform only one of them builds cannot be used: the other would
355
+ * receive a component it drops on the floor. Two instances on different
356
+ * component versions is the case this exists for, and the cost is that
357
+ * the newer one waits for the older to be updated — visible in the log,
358
+ * and never an entity that silently does not arrive.
359
+ */
360
+ function intersectSupport(supports) {
361
+ const [first, ...rest] = supports;
362
+ if (first === void 0) return LEGACY_SUPPORT;
363
+ const out = /* @__PURE__ */ new Set();
364
+ for (const platform of first) if (rest.every((other) => other.has(platform))) out.add(platform);
365
+ return out;
366
+ }
367
+ /**
368
+ * Per-broker negotiated support, with the downgrade rule.
369
+ *
370
+ * Pure and injectable-free: the probe is somebody else's job, this only
371
+ * decides what a sequence of answers means.
372
+ */
373
+ var ComponentSupportTracker = class {
374
+ current = /* @__PURE__ */ new Map();
375
+ /** brokerId → the signature of the downgrade that has been seen once. */
376
+ pendingDowngrade = /* @__PURE__ */ new Map();
377
+ /** The support in force for a broker. Legacy until something says otherwise. */
378
+ supportFor(brokerId) {
379
+ return this.current.get(brokerId) ?? LEGACY_SUPPORT;
380
+ }
381
+ /** The support in force for a set of brokers, intersected. */
382
+ supportForAll(brokerIds) {
383
+ if (brokerIds.length === 0) return LEGACY_SUPPORT;
384
+ return intersectSupport(brokerIds.map((id) => this.supportFor(id)));
385
+ }
386
+ forget(brokerId) {
387
+ this.current.delete(brokerId);
388
+ this.pendingDowngrade.delete(brokerId);
389
+ }
390
+ observe(brokerId, probe) {
391
+ const before = this.supportFor(brokerId);
392
+ if (probe.kind === "unknown") return {
393
+ support: before,
394
+ changed: false,
395
+ withheldDowngrade: false
396
+ };
397
+ const next = probe.kind === "absent" ? LEGACY_SUPPORT : probe.report.platforms;
398
+ const signature = supportSignature(next);
399
+ if (signature === supportSignature(before)) {
400
+ this.pendingDowngrade.delete(brokerId);
401
+ this.current.set(brokerId, next);
402
+ return {
403
+ support: before,
404
+ changed: false,
405
+ withheldDowngrade: false
406
+ };
407
+ }
408
+ if ([...before].some((platform) => !next.has(platform)) && this.pendingDowngrade.get(brokerId) !== signature) {
409
+ this.pendingDowngrade.set(brokerId, signature);
410
+ return {
411
+ support: before,
412
+ changed: false,
413
+ withheldDowngrade: true
414
+ };
415
+ }
416
+ this.pendingDowngrade.delete(brokerId);
417
+ this.current.set(brokerId, next);
418
+ return {
419
+ support: next,
420
+ changed: true,
421
+ withheldDowngrade: false
422
+ };
423
+ }
424
+ };
425
+ //#endregion
426
+ //#region src/ha-export/native-platforms.ts
427
+ /**
428
+ * The NATIVE half of the Home Assistant export.
429
+ *
430
+ * A `cover` in Home Assistant is one entity with an open/close/stop
431
+ * surface, a position slider and a tilt slider. Exported through the nine
432
+ * platforms the 0.3.x component builds it is a `sensor` plus two more
433
+ * `sensor`s plus three `button`s — automatable, but not a cover: no
434
+ * `cover.open_cover`, no position in the more-info dialog, no
435
+ * `device_class: garage`, and every dashboard card that expects a cover
436
+ * refuses it. Same for a lock (`jammed` is not a boolean), a thermostat,
437
+ * a vacuum and a media player.
438
+ *
439
+ * From component 0.4.0 those platforms exist, and this table says which
440
+ * capability becomes which one. It is **structure only**: the topics a
441
+ * native component names are the topics the DEGRADED entities already
442
+ * used, so `state-projector.ts` is untouched and the value plane cannot
443
+ * disagree with the entity plane. Nothing here publishes a value.
444
+ *
445
+ * ── Three rules, each with a cost behind it ──────────────────────────────
446
+ *
447
+ * 1. **A control appears only when its descriptor is writable.** The
448
+ * authority is `CAP_ENTITY_MAP` — the same flag that decides whether the
449
+ * degraded entity gets a command topic, and therefore the same flag that
450
+ * tracks `CAP_COMMAND_ROUTES`. A native cover on a hub whose route table
451
+ * cannot move a cover is a READ-ONLY cover: it shows state, position and
452
+ * tilt and offers no buttons. A control that calls nothing is the defect
453
+ * this repo has shipped twice (D62), and a prettier platform is not a
454
+ * reason to ship it a third time.
455
+ *
456
+ * 2. **A binding whose entity does not exist is skipped.** The degraded
457
+ * table is being widened in parallel (verb buttons, writable numbers).
458
+ * Every binding below is resolved against `CAP_ENTITY_MAP` at build
459
+ * time, so a control lights up the moment its descriptor appears and
460
+ * costs nothing until then. This is what lets the two halves land in
461
+ * either order.
462
+ *
463
+ * 3. **What the native entity reads, it OWNS — and only that.** A bound
464
+ * entity is not announced separately: a native cover plus a `sensor`
465
+ * reporting the same position is two readings of one value, and a native
466
+ * cover plus a `number` writing the same position is two knobs (D62).
467
+ * Everything NOT bound stays exactly as it was, and that is the property
468
+ * that makes widening safe in both directions: a vacuum's error label is
469
+ * still a diagnostic sensor because HA's vacuum cannot show one, and a
470
+ * thermostat's vertical-swing toggle is still a `switch` because HA's
471
+ * climate expresses swing as a mode vocabulary the capability does not
472
+ * have. A control this table has no binding for is never silently lost —
473
+ * it is simply not absorbed.
474
+ */
475
+ /**
476
+ * capability → the native Home Assistant platform it becomes.
477
+ *
478
+ * Every capability here also has a row in `CAP_ENTITY_MAP`, which stays
479
+ * the fallback for a component that does not build the platform. The two
480
+ * are never both announced.
481
+ */
482
+ var NATIVE_CAP_PLATFORMS = {
483
+ /**
484
+ * `CoverStatusSchema`: `state` is the HA lifecycle verbatim
485
+ * (`open`/`opening`/`closing`/`closed`/`stopped`), `position` and
486
+ * `tiltPosition` are 0..100 or null. The three verbs are bound BOTH ways:
487
+ * to the primary command topic (one topic, `OPEN`/`CLOSE`/`STOP`) and to
488
+ * the per-verb buttons, so whichever the degraded table grows, the
489
+ * native cover can drive it.
490
+ */
491
+ cover: {
492
+ platform: "cover",
493
+ primary: { from: null },
494
+ controls: {
495
+ position: {
496
+ from: "position",
497
+ min: 0,
498
+ max: 100,
499
+ step: 1
500
+ },
501
+ tilt: {
502
+ from: "tilt",
503
+ min: 0,
504
+ max: 100,
505
+ step: 1
506
+ },
507
+ open: {
508
+ from: "open",
509
+ commandOnly: true
510
+ },
511
+ close: {
512
+ from: "close",
513
+ commandOnly: true
514
+ },
515
+ stop: {
516
+ from: "stop",
517
+ commandOnly: true
518
+ }
519
+ }
520
+ },
521
+ /** `ValveStatusSchema` — the cover lifecycle without tilt. */
522
+ valve: {
523
+ platform: "valve",
524
+ primary: { from: null },
525
+ controls: {
526
+ position: {
527
+ from: "position",
528
+ min: 0,
529
+ max: 100,
530
+ step: 1
531
+ },
532
+ open: {
533
+ from: "open",
534
+ commandOnly: true
535
+ },
536
+ close: {
537
+ from: "close",
538
+ commandOnly: true
539
+ },
540
+ stop: {
541
+ from: "stop",
542
+ commandOnly: true
543
+ }
544
+ }
545
+ },
546
+ /**
547
+ * The reason this platform exists. `LockStateSchema` is
548
+ * `locked | unlocked | locking | unlocking | jammed`, and every one of
549
+ * those is a state Home Assistant's `lock` has — including the one a
550
+ * `switch` silently reports as "unlocked".
551
+ *
552
+ * It READS the enum extra and WRITES the switch. The degraded primary
553
+ * publishes a boolean derived from the same field, so binding the state
554
+ * to it would throw away exactly the distinction the platform is for.
555
+ */
556
+ "lock-control": {
557
+ platform: "lock",
558
+ primary: {
559
+ from: "state",
560
+ commandFrom: null
561
+ },
562
+ controls: { open: {
563
+ from: "open",
564
+ commandOnly: true
565
+ } }
566
+ },
567
+ /**
568
+ * `AlarmStateSchema` IS Home Assistant's alarm vocabulary, and
569
+ * `availableModes` is the subset this panel accepts — a panel that
570
+ * cannot arm `vacation` must not offer the button. `requiresCode` is the
571
+ * panel's own answer about a PIN.
572
+ *
573
+ * This is also the only row here whose degraded form is a REGRESSION
574
+ * rather than the status quo: `CAP_ENTITY_MAP` already names
575
+ * `alarm_control_panel`, which the 0.3.x component does not build, so an
576
+ * alarm panel exported to it produces NO entity at all. The fallback
577
+ * below gives those installations the state as a sensor.
578
+ */
579
+ "alarm-panel": {
580
+ platform: "alarm_control_panel",
581
+ primary: {
582
+ from: null,
583
+ optionsField: "availableModes"
584
+ },
585
+ flagFields: { code_arm_required: "requiresCode" }
586
+ },
587
+ /**
588
+ * `ClimateControlStatusSchema`. Every temperature is Celsius by the
589
+ * cap's own doc comment, and the mode/fan-mode/preset vocabularies are
590
+ * `available*` arrays on the DEVICE — which is exactly why they cannot be
591
+ * a static `select` in the degraded table and can be a native climate
592
+ * here: the component receives the list with the entity.
593
+ */
594
+ "climate-control": {
595
+ platform: "climate",
596
+ primary: {
597
+ from: null,
598
+ optionsField: "availableModes"
599
+ },
600
+ constants: { temperature_unit: "°C" },
601
+ controls: {
602
+ current_temperature: { from: "current-temp" },
603
+ current_humidity: { from: "current-humidity" },
604
+ target_humidity: {
605
+ from: "target-humidity",
606
+ min: 0,
607
+ max: 100,
608
+ step: 1
609
+ },
610
+ target: {
611
+ from: "target",
612
+ min: -20,
613
+ max: 90,
614
+ step: .5
615
+ },
616
+ target_low: {
617
+ from: "target-low",
618
+ min: -20,
619
+ max: 90,
620
+ step: .5
621
+ },
622
+ target_high: {
623
+ from: "target-high",
624
+ min: -20,
625
+ max: 90,
626
+ step: .5
627
+ },
628
+ fan_mode: {
629
+ from: "fan-mode",
630
+ optionsField: "availableFanModes"
631
+ },
632
+ preset: {
633
+ from: "preset",
634
+ optionsField: "availablePresets"
635
+ }
636
+ }
637
+ },
638
+ /**
639
+ * A fan has no `on` field: `percentage` IS its state and 0 is off, which
640
+ * is what Home Assistant's fan does too (`turn_off` writes 0).
641
+ * `percentageStep` is the device's own granularity — a 4-speed fan
642
+ * reports 25, and a step of 1 would offer 37% to hardware that rounds it.
643
+ */
644
+ "fan-control": {
645
+ platform: "fan",
646
+ primary: {
647
+ from: null,
648
+ min: 0,
649
+ max: 100,
650
+ step: 1,
651
+ stepField: "percentageStep"
652
+ },
653
+ controls: {
654
+ preset: {
655
+ from: "preset",
656
+ optionsField: "availablePresets"
657
+ },
658
+ oscillation: { from: "oscillating" },
659
+ direction: { from: "direction" }
660
+ }
661
+ },
662
+ /** `HumidifierStatusSchema`. `minHumidity`/`maxHumidity` are per device. */
663
+ humidifier: {
664
+ platform: "humidifier",
665
+ deviceClass: "humidifier",
666
+ primary: { from: null },
667
+ controls: {
668
+ current_humidity: { from: "current-humidity" },
669
+ target_humidity: {
670
+ from: "target-humidity",
671
+ minField: "minHumidity",
672
+ maxField: "maxHumidity",
673
+ min: 0,
674
+ max: 100,
675
+ step: 1
676
+ },
677
+ mode: {
678
+ from: "mode",
679
+ optionsField: "availableModes"
680
+ },
681
+ action: { from: "action" }
682
+ }
683
+ },
684
+ /**
685
+ * Home Assistant's water heater IS its operation mode — the entity state
686
+ * is `eco`/`performance`/`off`, not a temperature. The capability's
687
+ * primary entity is the MEASURED temperature, so the primary binding
688
+ * names the mode extra and the measurement becomes a control.
689
+ */
690
+ "water-heater": {
691
+ platform: "water_heater",
692
+ primary: {
693
+ from: "mode",
694
+ optionsField: "availableModes"
695
+ },
696
+ constants: { temperature_unit: "°C" },
697
+ controls: {
698
+ current_temperature: { from: null },
699
+ target: {
700
+ from: "target",
701
+ minField: "minTemp",
702
+ maxField: "maxTemp",
703
+ min: -20,
704
+ max: 90,
705
+ step: .5
706
+ },
707
+ away: { from: "away" }
708
+ }
709
+ },
710
+ /**
711
+ * `VacuumStateSchema` is `idle|cleaning|paused|returning|docked|drying|error`;
712
+ * Home Assistant has every one but `drying`, which the component folds
713
+ * into `cleaning` rather than dropping the update.
714
+ *
715
+ * Battery, progress and the error label are deliberately NOT absorbed.
716
+ * Home Assistant deprecated the vacuum battery attribute in favour of a
717
+ * separate `sensor`, and it has nowhere to show a progress percentage or
718
+ * a vendor error string at all — as sensors they stay automatable.
719
+ */
720
+ "vacuum-control": {
721
+ platform: "vacuum",
722
+ primary: { from: null },
723
+ controls: {
724
+ fan_speed: {
725
+ from: "fan-speed",
726
+ optionsField: "availableFanSpeeds"
727
+ },
728
+ start: {
729
+ from: "start",
730
+ commandOnly: true
731
+ },
732
+ pause: {
733
+ from: "pause",
734
+ commandOnly: true
735
+ },
736
+ stop: {
737
+ from: "stop",
738
+ commandOnly: true
739
+ },
740
+ return_to_base: {
741
+ from: "return-to-base",
742
+ commandOnly: true
743
+ },
744
+ locate: {
745
+ from: "locate",
746
+ commandOnly: true
747
+ }
748
+ }
749
+ },
750
+ /**
751
+ * `MediaPlayerStatusSchema`. `volumeLevel` is 0..100 in the cap and
752
+ * 0..1 in Home Assistant — the component converts, in one place, because
753
+ * a factor of 100 applied on the wrong side is a player that jumps to
754
+ * full volume.
755
+ *
756
+ * `currentMedia` is an object and stays unexported: title and artist
757
+ * would need a projector that walks into a sub-object, and an entity
758
+ * that reports `unrenderable` on every delivery is worse than one that
759
+ * does not exist.
760
+ */
761
+ "media-player": {
762
+ platform: "media_player",
763
+ primary: { from: null },
764
+ controls: {
765
+ volume: {
766
+ from: "volume",
767
+ min: 0,
768
+ max: 100,
769
+ step: 1
770
+ },
771
+ mute: { from: "muted" },
772
+ source: {
773
+ from: "source",
774
+ optionsField: "availableSources"
775
+ },
776
+ shuffle: { from: "shuffle" },
777
+ repeat: { from: "repeat" },
778
+ play: {
779
+ from: "play",
780
+ commandOnly: true
781
+ },
782
+ pause: {
783
+ from: "pause",
784
+ commandOnly: true
785
+ },
786
+ stop: {
787
+ from: "stop",
788
+ commandOnly: true
789
+ },
790
+ next: {
791
+ from: "next",
792
+ commandOnly: true
793
+ },
794
+ previous: {
795
+ from: "previous",
796
+ commandOnly: true
797
+ }
798
+ }
799
+ }
800
+ };
801
+ /**
802
+ * What a platform becomes when the component on the other end cannot
803
+ * build it.
804
+ *
805
+ * The native platforms never reach this: `buildDerivedPlan` asks about
806
+ * support BEFORE choosing one, and falls back to the capability's own
807
+ * degraded row. This is for the other direction — a platform the DEGRADED
808
+ * table already names that an older component still does not build.
809
+ *
810
+ * There is exactly one, and it is a live defect rather than a
811
+ * hypothetical: the export has been announcing `alarm_control_panel`
812
+ * since the native export shipped, and the 0.3.x component builds eight
813
+ * platforms not including it. Every alarm panel exported to one of those
814
+ * installations produced a warning line and no entity at all. A `sensor`
815
+ * carrying the panel state is not an alarm card, but it is a value an
816
+ * operator can automate on, which is strictly more than nothing.
817
+ */
818
+ var DEGRADED_PLATFORM = { alarm_control_panel: "sensor" };
819
+ /** Whether a capability has a native platform at all. */
820
+ function nativePlatformFor(capName) {
821
+ return NATIVE_CAP_PLATFORMS[capName]?.platform ?? null;
822
+ }
823
+ function readStringArray$1(slice, field) {
824
+ const raw = slice?.[field];
825
+ if (!Array.isArray(raw)) return void 0;
826
+ const values = raw.filter((entry) => typeof entry === "string");
827
+ return values.length > 0 ? values : void 0;
828
+ }
829
+ function readNumber$1(slice, field) {
830
+ const raw = slice?.[field];
831
+ return typeof raw === "number" && Number.isFinite(raw) ? raw : void 0;
832
+ }
833
+ function resolveEntity(input, suffix) {
834
+ const { capName, mapping } = input;
835
+ const descriptor = suffix === null ? mapping : mapping.extras?.[suffix];
836
+ const isCommandEntity = suffix !== null && descriptor === void 0 && mapping.commands?.[suffix] !== void 0;
837
+ if (descriptor === void 0 && !isCommandEntity) return null;
838
+ return {
839
+ entity: suffix === null ? derivedEntityId(capName) : derivedExtraEntityId(capName, suffix),
840
+ writable: isCommandEntity || descriptor?.writable === true,
841
+ descriptor
842
+ };
843
+ }
844
+ function resolveBinding(input, binding) {
845
+ const { deviceKey } = input;
846
+ const source = resolveEntity(input, binding.from);
847
+ if (source === null) return null;
848
+ /**
849
+ * The written entity, when it is not the read one. Absent rather than
850
+ * refused: a control whose command entity does not exist is still a
851
+ * READING worth announcing.
852
+ */
853
+ const target = binding.commandFrom === void 0 ? source : resolveEntity(input, binding.commandFrom);
854
+ const entity = source.entity;
855
+ const descriptor = source.descriptor;
856
+ const writable = target?.writable === true;
857
+ const options = binding.optionsField === void 0 ? void 0 : readStringArray$1(input.slice, binding.optionsField);
858
+ /**
859
+ * The DEVICE's own bounds win over the static ones. A heat-pump water
860
+ * heater that reports `maxTemp: 60` must not offer 90, and a static
861
+ * range is only ever the backstop for hardware that reports none.
862
+ */
863
+ const min = (binding.minField === void 0 ? void 0 : readNumber$1(input.slice, binding.minField)) ?? descriptor?.range?.min ?? binding.min;
864
+ const max = (binding.maxField === void 0 ? void 0 : readNumber$1(input.slice, binding.maxField)) ?? descriptor?.range?.max ?? binding.max;
865
+ const step = (binding.stepField === void 0 ? void 0 : readNumber$1(input.slice, binding.stepField)) ?? descriptor?.range?.step ?? binding.step;
866
+ const control = {
867
+ ...binding.commandOnly === true ? {} : { state_topic: stateTopic(deviceKey, entity) },
868
+ ...writable && target !== null ? { command_topic: commandTopic(deviceKey, target.entity) } : {},
869
+ ...options !== void 0 ? { options } : {},
870
+ ...min !== void 0 ? { min } : {},
871
+ ...max !== void 0 ? { max } : {},
872
+ ...step !== void 0 ? { step } : {}
873
+ };
874
+ return {
875
+ entities: target === null || target.entity === entity ? [entity] : [entity, target.entity],
876
+ control
877
+ };
878
+ }
879
+ /**
880
+ * Build one native component, or `null` when the capability has none.
881
+ *
882
+ * The component's identity — its `unique_id` and therefore the component
883
+ * key — is the capability's PRIMARY entity id, the same one the degraded
884
+ * `sensor` used. Home Assistant keys its registry on
885
+ * `(platform, integration, unique_id)`, so this is a NEW entity even
886
+ * though the id is unchanged, and the old one is removed by the
887
+ * component's own diff rather than left orphaned. That is the whole
888
+ * migration, and `docs/design/ecosystem-export-plan.md` records why an
889
+ * in-place one is not possible.
890
+ */
891
+ function buildNativeComponent(input) {
892
+ const native = NATIVE_CAP_PLATFORMS[input.capName];
893
+ if (native === void 0) return null;
894
+ const primary = resolveBinding(input, native.primary);
895
+ if (primary === null) return null;
896
+ const absorbed = new Set([derivedEntityId(input.capName), ...primary.entities]);
897
+ const controls = {};
898
+ for (const [name, binding] of Object.entries(native.controls ?? {})) {
899
+ const resolved = resolveBinding(input, binding);
900
+ if (resolved === null) continue;
901
+ controls[name] = resolved.control;
902
+ for (const entity of resolved.entities) absorbed.add(entity);
903
+ }
904
+ const flags = {};
905
+ for (const [key, field] of Object.entries(native.flagFields ?? {})) {
906
+ const raw = input.slice?.[field];
907
+ if (typeof raw === "boolean") flags[key] = raw;
908
+ }
909
+ return {
910
+ component: {
911
+ platform: native.platform,
912
+ unique_id: `${input.stableId}_${derivedEntityId(input.capName)}`,
913
+ name: input.label,
914
+ ...primary.control,
915
+ ...native.deviceClass !== void 0 ? { device_class: native.deviceClass } : {},
916
+ ...native.icon !== void 0 ? { icon: native.icon } : {},
917
+ ...Object.keys(controls).length > 0 ? { controls } : {},
918
+ ...native.constants,
919
+ ...flags
920
+ },
921
+ absorbed
922
+ };
923
+ }
924
+ //#endregion
75
925
  //#region src/ha-export/entity-catalog.ts
76
926
  /**
77
927
  * The entity catalog — pure. Device + zones + macros → the `cmps` set.
@@ -97,6 +947,11 @@ function toSlug(value) {
97
947
  * nothing produces is the failure this repo keeps paying for.
98
948
  */
99
949
  /**
950
+ * The enums a `select` offers come from the CAP, never from a list here: a
951
+ * second copy of `HvacModeSchema` would be one rename away from offering an
952
+ * operator a mode their thermostat has never heard of.
953
+ */
954
+ /**
100
955
  * The macro classes an operator automates on.
101
956
  *
102
957
  * Deliberately the macros and NOT the 21 fine classes: those include
@@ -158,6 +1013,29 @@ var ZONE_MACROS = [
158
1013
  * what the export is for. The three `*_last_image` entities — camera,
159
1014
  * per macro, per zone × macro — therefore ship enabled. They cost one
160
1015
  * entity each and carry a signed URL, not bytes.
1016
+ *
1017
+ * **So are the three things the operator asked for on 2026-08-14**, and
1018
+ * each has a reason that is a property of the value rather than a taste:
1019
+ *
1020
+ * - **`<macro>_last_detection`, and the camera's own.** "When was a
1021
+ * person last seen" is a timestamp, and half of what an operator
1022
+ * builds on a camera is a duration since one — a `for:` in a
1023
+ * template, a "nobody has been at the door since" notification. A
1024
+ * boolean that is `off` right now cannot answer it.
1025
+ * - **`<macro>_last_label`.** This is where the RECOGNISED FACE and the
1026
+ * read PLATE arrive. It was the single highest-value item in the gap
1027
+ * against the reference integration and it shipped switched off, so
1028
+ * the name camstack worked out was in Home Assistant and invisible.
1029
+ * - **`battery` and `charger` on a battery camera.** A battery
1030
+ * percentage is the headline of a battery device, not a diagnostic
1031
+ * detail about one; `sleeping` stays off because it describes the
1032
+ * power state machine rather than the charge.
1033
+ *
1034
+ * What stays off is what the valve was built for: the per-macro and
1035
+ * per-zone `*_objects` COUNTERS (a count of what is on screen right now,
1036
+ * which the `_detected` boolean and the camera-wide `objects` total
1037
+ * already answer for automation), the per-zone timestamps, and the
1038
+ * doorbell's since-restart counter.
161
1039
  */
162
1040
  /**
163
1041
  * The snooze surface, as a `select`.
@@ -192,25 +1070,96 @@ var PTZ_BUTTONS = [
192
1070
  "ptz_zoom_in",
193
1071
  "ptz_zoom_out"
194
1072
  ];
1073
+ /**
1074
+ * Platforms whose entity carries no value on the push plane.
1075
+ *
1076
+ * A `button` is a command. A `camera` is a stream the component fetches
1077
+ * for itself (`snapshot.getSnapshot` + `webrtcSession.handleOffer`) from
1078
+ * the target it is given. Announcing a `state_topic` for either would
1079
+ * declare a value nothing ever publishes — which is precisely how twelve
1080
+ * camera components came to sit in an announce that produced no entity.
1081
+ */
1082
+ var STATELESS_PLATFORMS = new Set(["button", "camera"]);
1083
+ /**
1084
+ * Platforms that can carry a command topic.
1085
+ *
1086
+ * `writable` on a descriptor means "the route table writes exactly this
1087
+ * value" — a fact about the CAPABILITY. Whether Home Assistant can offer
1088
+ * a control for it is a fact about the PLATFORM, and the two are not the
1089
+ * same: `climate-control.fanMode` is written by `setFanMode` and has no
1090
+ * closed vocabulary, so its degraded form is a `sensor` and its native
1091
+ * form is the climate entity's fan-mode control. Announcing a command
1092
+ * topic on the degraded `sensor` would describe a control Home Assistant
1093
+ * cannot render — a leftover that reads as verification.
1094
+ */
1095
+ var COMMANDABLE_PLATFORMS = new Set([
1096
+ "switch",
1097
+ "button",
1098
+ "select",
1099
+ "number",
1100
+ "alarm_control_panel",
1101
+ "cover",
1102
+ "climate",
1103
+ "lock",
1104
+ "fan",
1105
+ "vacuum",
1106
+ "valve",
1107
+ "humidifier",
1108
+ "water_heater",
1109
+ "media_player"
1110
+ ]);
1111
+ /** 0..100, the shape every camstack percentage declares in its own schema. */
1112
+ var PERCENT_RANGE = {
1113
+ min: 0,
1114
+ max: 100,
1115
+ step: 1
1116
+ };
1117
+ /**
1118
+ * A temperature setpoint, in Celsius.
1119
+ *
1120
+ * Deliberately WIDER than any one device: a heat-pump water heater runs to
1121
+ * 80 °C, a freezer setpoint is negative, and the per-device truth
1122
+ * (`climate-control`'s `getOptions`, `water-heater`'s `minTemp`/`maxTemp`)
1123
+ * is on the provider and the slice, not in this static table. Half-degree
1124
+ * steps because every thermostat this repo has met accepts them and a step of
1125
+ * 1 would make 20.5 unreachable.
1126
+ */
1127
+ var TEMPERATURE_RANGE = {
1128
+ min: -20,
1129
+ max: 90,
1130
+ step: .5
1131
+ };
1132
+ /** `ColorInputSchema` declares mireds as `int().min(50).max(1000)`. */
1133
+ var MIRED_RANGE = {
1134
+ min: 50,
1135
+ max: 1e3,
1136
+ step: 1
1137
+ };
195
1138
  function buildComponent(device, spec) {
196
1139
  const deviceKey = deviceKeyFor(device.stableId);
197
1140
  return {
198
1141
  platform: spec.platform,
199
1142
  unique_id: `${device.stableId}_${spec.uniqueSuffix ?? spec.entity}`,
200
1143
  name: spec.label,
201
- ...spec.platform === "button" ? {} : { state_topic: stateTopic(deviceKey, spec.entity) },
202
- ...spec.writable === true ? { command_topic: commandTopic(deviceKey, spec.entity) } : {},
1144
+ ...STATELESS_PLATFORMS.has(spec.platform) ? {} : { state_topic: stateTopic(deviceKey, spec.entity) },
1145
+ ...spec.writable === true && COMMANDABLE_PLATFORMS.has(spec.platform) ? { command_topic: commandTopic(deviceKey, spec.entity) } : {},
203
1146
  ...spec.deviceClass !== void 0 ? { device_class: spec.deviceClass } : {},
204
1147
  ...spec.unit !== void 0 ? { unit_of_measurement: spec.unit } : {},
205
1148
  ...spec.icon !== void 0 ? { icon: spec.icon } : {},
206
1149
  ...spec.options !== void 0 ? { options: spec.options } : {},
1150
+ ...spec.range !== void 0 ? {
1151
+ min: spec.range.min,
1152
+ max: spec.range.max,
1153
+ step: spec.range.step
1154
+ } : {},
207
1155
  ...spec.entityCategory !== void 0 ? { entity_category: spec.entityCategory } : {},
208
1156
  ...spec.enabledByDefault ? {} : { enabled_by_default: false },
209
1157
  ...spec.platform === "binary_sensor" || spec.platform === "switch" ? {
210
1158
  payload_on: "true",
211
1159
  payload_off: "false"
212
1160
  } : {},
213
- ...spec.platform === "button" ? { payload_press: "PRESS" } : {}
1161
+ ...spec.platform === "button" ? { payload_press: "PRESS" } : {},
1162
+ ...spec.streamTarget !== void 0 ? { stream_target: spec.streamTarget } : {}
214
1163
  };
215
1164
  }
216
1165
  function deviceBlock(device) {
@@ -363,7 +1312,7 @@ function cameraSpecs(device) {
363
1312
  label: "Last detection",
364
1313
  deviceClass: "timestamp",
365
1314
  icon: "mdi:clock",
366
- enabledByDefault: false
1315
+ enabledByDefault: true
367
1316
  }
368
1317
  ];
369
1318
  if (device.slices.includes("battery")) specs.push({
@@ -373,14 +1322,14 @@ function cameraSpecs(device) {
373
1322
  deviceClass: "battery",
374
1323
  unit: "%",
375
1324
  entityCategory: "diagnostic",
376
- enabledByDefault: false
1325
+ enabledByDefault: true
377
1326
  }, {
378
1327
  entity: "charger",
379
1328
  platform: "binary_sensor",
380
1329
  label: "Charging",
381
1330
  deviceClass: "battery_charging",
382
1331
  entityCategory: "diagnostic",
383
- enabledByDefault: false
1332
+ enabledByDefault: true
384
1333
  }, {
385
1334
  entity: "sleeping",
386
1335
  platform: "binary_sensor",
@@ -512,7 +1461,7 @@ function cameraSpecs(device) {
512
1461
  platform: "sensor",
513
1462
  label: `${label} last detection`,
514
1463
  deviceClass: "timestamp",
515
- enabledByDefault: false
1464
+ enabledByDefault: true
516
1465
  }, {
517
1466
  entity: `${macro}_objects`,
518
1467
  platform: "sensor",
@@ -525,9 +1474,36 @@ function cameraSpecs(device) {
525
1474
  platform: "sensor",
526
1475
  label: `${label} last label`,
527
1476
  icon: "mdi:tag",
528
- enabledByDefault: false
1477
+ enabledByDefault: true
529
1478
  });
530
1479
  }
1480
+ /**
1481
+ * The live picture — every stream the hub lists EXCEPT the adaptive one,
1482
+ * which the component's own entity already is. See `camera-entities.ts`
1483
+ * for why the entity carries a WebRTC target and not a url, and why
1484
+ * announcing the adaptive stream would collide on an identity that has
1485
+ * shipped.
1486
+ */
1487
+ for (const stream of cameraStreamEntities(device.streams ?? [])) specs.push({
1488
+ entity: stream.entity,
1489
+ platform: stream.platform,
1490
+ label: stream.label,
1491
+ enabledByDefault: stream.enabledByDefault,
1492
+ streamTarget: stream.target
1493
+ });
1494
+ /**
1495
+ * The two capability-derived entities a camera carries.
1496
+ *
1497
+ * `buildDerivedPlan` is the NON-camera path, so a camera-scoped
1498
+ * capability reaches Home Assistant only if the camera catalog builds
1499
+ * it — and it is built from the same descriptor and the same entity id
1500
+ * as the derived half, so `projectCapSlice` publishes to the topic
1501
+ * this component declares without a second expression anywhere.
1502
+ */
1503
+ for (const cap of CAMERA_DERIVED_CAPS) {
1504
+ if (!device.boundCaps.includes(cap)) continue;
1505
+ specs.push(...capEntitySpecs(cap));
1506
+ }
531
1507
  for (const zone of device.zones) specs.push(...zoneSpecs(zone));
532
1508
  return specs;
533
1509
  }
@@ -545,36 +1521,128 @@ function assemble(device, specs) {
545
1521
  function buildCameraPlan(device) {
546
1522
  return assemble(device, cameraSpecs(device));
547
1523
  }
1524
+ /**
1525
+ * capability → the entities it produces.
1526
+ *
1527
+ * ── What decides read-only ──────────────────────────────────────────────
1528
+ *
1529
+ * The plan's rule, and D62's rule generalised: **a writable descriptor
1530
+ * must name a route `CAP_COMMAND_ROUTES` can resolve, or ship read-only.**
1531
+ * The route table now covers every Tier A family, so the rows below are
1532
+ * writable wherever the capability declares a method that writes exactly
1533
+ * the value the entity carries.
1534
+ *
1535
+ * ── `writable` is about the CAPABILITY, not about the platform ───────────
1536
+ *
1537
+ * Six values are written by a real method and have no Home Assistant
1538
+ * control in the DEGRADED shape: `climate-control.fanMode`, `.preset`,
1539
+ * `fan-control.preset`, `water-heater.operationMode`, `humidifier.mode`,
1540
+ * `media-player.source` and `vacuum-control.fanSpeed` are free-form
1541
+ * strings whose accepted values are `available*` arrays on the DEVICE. A
1542
+ * static `select` cannot hold them — it would offer values the device
1543
+ * rejects while hiding the ones it takes — so their degraded platform is
1544
+ * a `sensor`.
1545
+ *
1546
+ * They are still marked `writable`, and that is the point: the flag means
1547
+ * "`CAP_COMMAND_ROUTES` writes exactly this value", which is what
1548
+ * `native-platforms.ts` asks before giving a native climate its fan-mode
1549
+ * control or a native vacuum its fan-speed control. `buildComponent`
1550
+ * announces a `command_topic` only on a platform that can carry one
1551
+ * (`COMMANDABLE_PLATFORMS`), so the degraded `sensor` stays a reading and
1552
+ * the native entity gains the control. They were read-only until
1553
+ * 2026-08-14 and the native controls silently did not appear, because the
1554
+ * one flag was answering two different questions.
1555
+ *
1556
+ * **`setTargetRange` is the one setter that needs two values at once**,
1557
+ * and it is routed by READING the other half first — see the note on
1558
+ * `target-high` below. Everything else in this table names one method
1559
+ * with one value.
1560
+ *
1561
+ * Only **a value that is not flat** still ships read-only. See below.
1562
+ *
1563
+ * ── Why a nested object is never a field ────────────────────────────────
1564
+ *
1565
+ * `renderCapValue` carries strings, numbers and booleans. `color.rgb`,
1566
+ * `vacuum-control.dustBin`, `event-emitter.lastEvent` and
1567
+ * `consumables.items` are objects and arrays: mapping one would publish
1568
+ * an entity that reports `unrenderable` on every single delivery. Where a
1569
+ * capability's headline value is only expressible as an object, the flat
1570
+ * neighbours are exported and the object is named in the doc comment
1571
+ * rather than mapped.
1572
+ */
548
1573
  var CAP_ENTITY_MAP = {
549
1574
  switch: {
550
1575
  platform: "switch",
551
1576
  field: "on",
552
1577
  writable: true
553
1578
  },
1579
+ /**
1580
+ * The lock is BOTH: a switch, because that is what an operator
1581
+ * automates on and `lockControl.lock` / `.unlock` is a route that
1582
+ * works — and a sensor, because `LockStateSchema` is
1583
+ * `locked | unlocked | locking | unlocking | jammed` and a boolean
1584
+ * silently reports a JAMMED door as unlocked. The boolean is derived
1585
+ * from the same field, so the two can never disagree.
1586
+ */
554
1587
  "lock-control": {
555
1588
  platform: "switch",
556
- field: "locked",
1589
+ field: "state",
557
1590
  deviceClass: "lock",
558
- writable: true
559
- },
560
- siren: {
561
- platform: "switch",
562
- field: "active",
563
- writable: true
1591
+ writable: true,
1592
+ derive: {
1593
+ kind: "equals",
1594
+ value: "locked"
1595
+ },
1596
+ extras: { state: {
1597
+ platform: "sensor",
1598
+ field: "state",
1599
+ label: "Lock state",
1600
+ icon: "mdi:lock-question"
1601
+ } },
1602
+ /**
1603
+ * `lockControl.open` — the LATCH, which is not `unlock`.
1604
+ *
1605
+ * Home Assistant spells it `LockEntityFeature.OPEN` and its native lock
1606
+ * offers it as a third button; the cap has declared the method since it
1607
+ * was written and nothing routed it, so `NATIVE_CAP_PLATFORMS`'
1608
+ * `open` control resolved to no entity and was skipped every time.
1609
+ * Registered and off in the degraded table for the same reason `stop`
1610
+ * is: a lock without a latch answers the press with the provider's own
1611
+ * refusal, and that is a button most operators never want to see.
1612
+ */
1613
+ commands: { open: {
1614
+ label: "Open latch",
1615
+ icon: "mdi:door-open",
1616
+ enabledByDefault: false
1617
+ } }
564
1618
  },
565
1619
  button: {
566
1620
  platform: "button",
567
1621
  field: "pressed",
568
1622
  writable: true
569
1623
  },
1624
+ /**
1625
+ * The range is not decoration. `brightness.setBrightness` declares
1626
+ * `0..100` and the entity carried no bounds, so Home Assistant applied its
1627
+ * own default of min 1 — an operator could not send `0` and could not turn
1628
+ * the light off from the number. It has been that way since the entity
1629
+ * existed.
1630
+ */
570
1631
  brightness: {
571
1632
  platform: "number",
572
- field: "brightness",
1633
+ field: "percentage",
1634
+ unit: "%",
1635
+ writable: true,
1636
+ range: PERCENT_RANGE
1637
+ },
1638
+ "alarm-panel": {
1639
+ platform: "alarm_control_panel",
1640
+ field: "state",
573
1641
  writable: true
574
1642
  },
575
1643
  binary: {
576
1644
  platform: "binary_sensor",
577
- field: "state"
1645
+ field: "on"
578
1646
  },
579
1647
  motion: {
580
1648
  platform: "binary_sensor",
@@ -583,22 +1651,39 @@ var CAP_ENTITY_MAP = {
583
1651
  },
584
1652
  contact: {
585
1653
  platform: "binary_sensor",
586
- field: "open",
1654
+ field: "entryOpen",
587
1655
  deviceClass: "door"
588
1656
  },
1657
+ /**
1658
+ * `state` is a STRING — `home`, `not_home`, or the name of any zone the
1659
+ * operator defined. The binary answers "is anybody in", which is what
1660
+ * an automation triggers on; the extra carries the zone NAME, which is
1661
+ * the whole reason a presence device is not a contact sensor. A native
1662
+ * `device_tracker` (which carries both at once) is Tier B.
1663
+ */
589
1664
  presence: {
590
1665
  platform: "binary_sensor",
591
- field: "present",
592
- deviceClass: "presence"
1666
+ field: "state",
1667
+ deviceClass: "presence",
1668
+ derive: {
1669
+ kind: "not-equals",
1670
+ value: "not_home"
1671
+ },
1672
+ extras: { zone: {
1673
+ platform: "sensor",
1674
+ field: "state",
1675
+ label: "Presence zone",
1676
+ icon: "mdi:map-marker-account"
1677
+ } }
593
1678
  },
594
1679
  connectivity: {
595
1680
  platform: "binary_sensor",
596
- field: "online",
1681
+ field: "connected",
597
1682
  deviceClass: "connectivity"
598
1683
  },
599
1684
  flood: {
600
1685
  platform: "binary_sensor",
601
- field: "detected",
1686
+ field: "flooded",
602
1687
  deviceClass: "moisture"
603
1688
  },
604
1689
  smoke: {
@@ -618,7 +1703,7 @@ var CAP_ENTITY_MAP = {
618
1703
  },
619
1704
  tamper: {
620
1705
  platform: "binary_sensor",
621
- field: "detected",
1706
+ field: "tampered",
622
1707
  deviceClass: "tamper"
623
1708
  },
624
1709
  vibration: {
@@ -633,25 +1718,25 @@ var CAP_ENTITY_MAP = {
633
1718
  },
634
1719
  "temperature-sensor": {
635
1720
  platform: "sensor",
636
- field: "temperature",
1721
+ field: "celsius",
637
1722
  deviceClass: "temperature",
638
1723
  unit: "°C"
639
1724
  },
640
1725
  "humidity-sensor": {
641
1726
  platform: "sensor",
642
- field: "humidity",
1727
+ field: "percent",
643
1728
  deviceClass: "humidity",
644
1729
  unit: "%"
645
1730
  },
646
1731
  "pressure-sensor": {
647
1732
  platform: "sensor",
648
- field: "pressure",
1733
+ field: "hpa",
649
1734
  deviceClass: "pressure",
650
1735
  unit: "hPa"
651
1736
  },
652
1737
  "ambient-light-sensor": {
653
1738
  platform: "sensor",
654
- field: "illuminance",
1739
+ field: "lux",
655
1740
  deviceClass: "illuminance",
656
1741
  unit: "lx"
657
1742
  },
@@ -680,12 +1765,926 @@ var CAP_ENTITY_MAP = {
680
1765
  field: "aqi",
681
1766
  deviceClass: "aqi"
682
1767
  },
683
- "alarm-panel": {
684
- platform: "alarm_control_panel",
1768
+ /**
1769
+ * A cover is its state, its two positions and its three verbs.
1770
+ *
1771
+ * `state` stays a `sensor`: `opening`/`closing` are transitions, not
1772
+ * commands, and there is no cap method that writes the lifecycle directly.
1773
+ * The positions are `number`s because `setPosition` / `setTiltPosition`
1774
+ * write exactly them, and the verbs are buttons because `open`, `close` and
1775
+ * `stop` take no value at all.
1776
+ */
1777
+ cover: {
1778
+ platform: "sensor",
685
1779
  field: "state",
686
- writable: true
1780
+ icon: "mdi:window-shutter",
1781
+ extras: {
1782
+ position: {
1783
+ platform: "number",
1784
+ field: "position",
1785
+ label: "Position",
1786
+ unit: "%",
1787
+ icon: "mdi:arrow-up-down",
1788
+ writable: true,
1789
+ range: PERCENT_RANGE
1790
+ },
1791
+ tilt: {
1792
+ platform: "number",
1793
+ field: "tiltPosition",
1794
+ label: "Tilt position",
1795
+ unit: "%",
1796
+ icon: "mdi:angle-acute",
1797
+ writable: true,
1798
+ range: PERCENT_RANGE,
1799
+ enabledByDefault: false
1800
+ }
1801
+ },
1802
+ commands: {
1803
+ open: {
1804
+ label: "Open",
1805
+ icon: "mdi:arrow-up-box"
1806
+ },
1807
+ close: {
1808
+ label: "Close",
1809
+ icon: "mdi:arrow-down-box"
1810
+ },
1811
+ stop: {
1812
+ label: "Stop",
1813
+ icon: "mdi:stop",
1814
+ enabledByDefault: false
1815
+ }
1816
+ }
1817
+ },
1818
+ /**
1819
+ * Every temperature here is CELSIUS by the cap's own doc comment
1820
+ * ("Single setpoint in Celsius", "Current measured temperature in
1821
+ * Celsius"), so the unit is declared rather than guessed.
1822
+ */
1823
+ "climate-control": {
1824
+ platform: "select",
1825
+ field: "mode",
1826
+ icon: "mdi:thermostat",
1827
+ writable: true,
1828
+ /**
1829
+ * The one closed list on this capability, and it is closed because
1830
+ * `HvacModeSchema` is an enum. `availableModes` narrows it PER DEVICE and
1831
+ * a static descriptor cannot read a slice, so an operator can select a
1832
+ * mode their unit rejects — the provider refuses it and the refusal
1833
+ * surfaces as a 422 with the device's own message, which is the honest
1834
+ * failure. `fanMode` and `preset` get no such list at all: they are
1835
+ * free-form strings, so they stay sensors.
1836
+ */
1837
+ options: require_dist.HvacModeSchema.options,
1838
+ extras: {
1839
+ "current-temp": {
1840
+ platform: "sensor",
1841
+ field: "currentTemp",
1842
+ label: "Current temperature",
1843
+ deviceClass: "temperature",
1844
+ unit: "°C"
1845
+ },
1846
+ target: {
1847
+ platform: "number",
1848
+ field: "target",
1849
+ label: "Target temperature",
1850
+ deviceClass: "temperature",
1851
+ unit: "°C",
1852
+ writable: true,
1853
+ range: TEMPERATURE_RANGE
1854
+ },
1855
+ "current-humidity": {
1856
+ platform: "sensor",
1857
+ field: "currentHumidity",
1858
+ label: "Current humidity",
1859
+ deviceClass: "humidity",
1860
+ unit: "%",
1861
+ enabledByDefault: false
1862
+ },
1863
+ "target-humidity": {
1864
+ platform: "number",
1865
+ field: "targetHumidity",
1866
+ label: "Target humidity",
1867
+ deviceClass: "humidity",
1868
+ unit: "%",
1869
+ writable: true,
1870
+ range: PERCENT_RANGE,
1871
+ enabledByDefault: false
1872
+ },
1873
+ /**
1874
+ * Free-form: the accepted values are `availableFanModes` on the slice,
1875
+ * so the degraded platform is a `sensor` and only the native climate
1876
+ * renders the control. `setFanMode` is the route.
1877
+ */
1878
+ "fan-mode": {
1879
+ platform: "sensor",
1880
+ field: "fanMode",
1881
+ label: "Fan mode",
1882
+ icon: "mdi:fan",
1883
+ writable: true,
1884
+ enabledByDefault: false
1885
+ },
1886
+ /** Free-form: the accepted values are `availablePresets` on the slice. */
1887
+ preset: {
1888
+ platform: "sensor",
1889
+ field: "preset",
1890
+ label: "Preset",
1891
+ writable: true,
1892
+ enabledByDefault: false
1893
+ },
1894
+ /**
1895
+ * The dual setpoint — and the one route in this export that READS
1896
+ * before it writes.
1897
+ *
1898
+ * `setTargetRange` takes `targetLow` AND `targetHigh` together, while
1899
+ * Home Assistant's climate sends the two halves on two topics, so a
1900
+ * command carrying one of them has to learn the other before it can
1901
+ * make the call. `applyCapCommand` therefore reads
1902
+ * `climateControl.getStatus`, substitutes the half it was given, and
1903
+ * calls once. A device reporting `null` for the other half is REFUSED
1904
+ * rather than defaulted: inventing the missing bound is how a
1905
+ * thermostat comes to be told to hold 20 °C to 20 °C.
1906
+ */
1907
+ "target-high": {
1908
+ platform: "sensor",
1909
+ field: "targetHigh",
1910
+ label: "Target high",
1911
+ deviceClass: "temperature",
1912
+ unit: "°C",
1913
+ writable: true,
1914
+ enabledByDefault: false
1915
+ },
1916
+ "target-low": {
1917
+ platform: "sensor",
1918
+ field: "targetLow",
1919
+ label: "Target low",
1920
+ deviceClass: "temperature",
1921
+ unit: "°C",
1922
+ writable: true,
1923
+ enabledByDefault: false
1924
+ },
1925
+ /**
1926
+ * Two INDEPENDENT axes, each with its own setter and its own
1927
+ * `DeviceFeature`. A device with neither reports `null` on both, and a
1928
+ * null renders as `unknown` rather than as a switch stuck off.
1929
+ */
1930
+ "swing-vertical": {
1931
+ platform: "switch",
1932
+ field: "swingVertical",
1933
+ label: "Vertical swing",
1934
+ icon: "mdi:arrow-up-down",
1935
+ writable: true,
1936
+ enabledByDefault: false
1937
+ },
1938
+ "swing-horizontal": {
1939
+ platform: "switch",
1940
+ field: "swingHorizontal",
1941
+ label: "Horizontal swing",
1942
+ icon: "mdi:arrow-left-right",
1943
+ writable: true,
1944
+ enabledByDefault: false
1945
+ }
1946
+ }
1947
+ },
1948
+ /** A fan has no `on` field — `percentage` IS its state, and 0 is off. */
1949
+ "fan-control": {
1950
+ platform: "number",
1951
+ field: "percentage",
1952
+ unit: "%",
1953
+ icon: "mdi:fan",
1954
+ writable: true,
1955
+ /**
1956
+ * `percentageStep` on the slice is the device's own granularity (25 for a
1957
+ * four-speed fan). A static step of 1 is the WIDER choice: a value the
1958
+ * device rounds is recoverable, a step that hides three of its four speeds
1959
+ * is not.
1960
+ */
1961
+ range: PERCENT_RANGE,
1962
+ extras: {
1963
+ /**
1964
+ * Free-form: the accepted values are `availablePresets` on the slice.
1965
+ * `fanControl.setPreset` is the route; the native fan renders it.
1966
+ */
1967
+ preset: {
1968
+ platform: "sensor",
1969
+ field: "preset",
1970
+ label: "Preset",
1971
+ writable: true,
1972
+ enabledByDefault: false
1973
+ },
1974
+ oscillating: {
1975
+ platform: "switch",
1976
+ field: "oscillating",
1977
+ label: "Oscillating",
1978
+ icon: "mdi:arrow-oscillating",
1979
+ writable: true,
1980
+ enabledByDefault: false
1981
+ },
1982
+ direction: {
1983
+ platform: "select",
1984
+ field: "direction",
1985
+ label: "Direction",
1986
+ writable: true,
1987
+ options: require_dist.FanDirectionSchema.options,
1988
+ enabledByDefault: false
1989
+ }
1990
+ }
1991
+ },
1992
+ humidifier: {
1993
+ platform: "switch",
1994
+ field: "on",
1995
+ icon: "mdi:air-humidifier",
1996
+ writable: true,
1997
+ extras: {
1998
+ "current-humidity": {
1999
+ platform: "sensor",
2000
+ field: "currentHumidity",
2001
+ label: "Current humidity",
2002
+ deviceClass: "humidity",
2003
+ unit: "%"
2004
+ },
2005
+ /**
2006
+ * `minHumidity`/`maxHumidity` on the slice are the device's own bounds;
2007
+ * the static range is the full percentage for the reason on
2008
+ * {@link HaNumberRange}.
2009
+ */
2010
+ "target-humidity": {
2011
+ platform: "number",
2012
+ field: "targetHumidity",
2013
+ label: "Target humidity",
2014
+ deviceClass: "humidity",
2015
+ unit: "%",
2016
+ writable: true,
2017
+ range: PERCENT_RANGE
2018
+ },
2019
+ /**
2020
+ * Free-form: the accepted values are `availableModes` on the slice.
2021
+ * `humidifier.setMode` is the route; the native humidifier renders it.
2022
+ */
2023
+ mode: {
2024
+ platform: "sensor",
2025
+ field: "mode",
2026
+ label: "Mode",
2027
+ writable: true,
2028
+ enabledByDefault: false
2029
+ },
2030
+ action: {
2031
+ platform: "sensor",
2032
+ field: "action",
2033
+ label: "Action",
2034
+ enabledByDefault: false
2035
+ }
2036
+ }
2037
+ },
2038
+ "water-heater": {
2039
+ platform: "sensor",
2040
+ field: "currentTemp",
2041
+ deviceClass: "temperature",
2042
+ unit: "°C",
2043
+ extras: {
2044
+ target: {
2045
+ platform: "number",
2046
+ field: "targetTemp",
2047
+ label: "Target temperature",
2048
+ deviceClass: "temperature",
2049
+ unit: "°C",
2050
+ writable: true,
2051
+ range: TEMPERATURE_RANGE
2052
+ },
2053
+ /**
2054
+ * Free-form: the accepted values are `availableModes` on the slice, and
2055
+ * this is the entity Home Assistant's water heater uses as its STATE —
2056
+ * `setOperationMode` is what a native water_heater writes when the
2057
+ * operator picks `eco`. Read-only here left that platform with no
2058
+ * command at all.
2059
+ */
2060
+ mode: {
2061
+ platform: "sensor",
2062
+ field: "operationMode",
2063
+ label: "Operation mode",
2064
+ writable: true,
2065
+ enabledByDefault: false
2066
+ },
2067
+ away: {
2068
+ platform: "switch",
2069
+ field: "away",
2070
+ label: "Away mode",
2071
+ writable: true,
2072
+ enabledByDefault: false
2073
+ }
2074
+ }
2075
+ },
2076
+ valve: {
2077
+ platform: "sensor",
2078
+ field: "state",
2079
+ icon: "mdi:valve",
2080
+ extras: { position: {
2081
+ platform: "number",
2082
+ field: "position",
2083
+ label: "Position",
2084
+ unit: "%",
2085
+ icon: "mdi:arrow-up-down",
2086
+ writable: true,
2087
+ range: PERCENT_RANGE
2088
+ } },
2089
+ commands: {
2090
+ open: {
2091
+ label: "Open",
2092
+ icon: "mdi:valve-open"
2093
+ },
2094
+ close: {
2095
+ label: "Close",
2096
+ icon: "mdi:valve-closed"
2097
+ },
2098
+ stop: {
2099
+ label: "Stop",
2100
+ icon: "mdi:stop",
2101
+ enabledByDefault: false
2102
+ }
2103
+ }
2104
+ },
2105
+ "vacuum-control": {
2106
+ platform: "sensor",
2107
+ field: "state",
2108
+ icon: "mdi:robot-vacuum",
2109
+ extras: {
2110
+ battery: {
2111
+ platform: "sensor",
2112
+ field: "batteryLevel",
2113
+ label: "Battery",
2114
+ deviceClass: "battery",
2115
+ unit: "%"
2116
+ },
2117
+ /**
2118
+ * Free-form: the accepted values are `availableFanSpeeds` on the slice.
2119
+ * `vacuumControl.setFanSpeed` is the route; the native vacuum renders it.
2120
+ */
2121
+ "fan-speed": {
2122
+ platform: "sensor",
2123
+ field: "fanSpeed",
2124
+ label: "Fan speed",
2125
+ writable: true,
2126
+ enabledByDefault: false
2127
+ },
2128
+ progress: {
2129
+ platform: "sensor",
2130
+ field: "progressPercent",
2131
+ label: "Progress",
2132
+ unit: "%",
2133
+ enabledByDefault: false
2134
+ },
2135
+ error: {
2136
+ platform: "sensor",
2137
+ field: "errorLabel",
2138
+ label: "Error",
2139
+ entityCategory: "diagnostic",
2140
+ enabledByDefault: false
2141
+ }
2142
+ },
2143
+ commands: {
2144
+ start: {
2145
+ label: "Start",
2146
+ icon: "mdi:play"
2147
+ },
2148
+ pause: {
2149
+ label: "Pause",
2150
+ icon: "mdi:pause"
2151
+ },
2152
+ stop: {
2153
+ label: "Stop",
2154
+ icon: "mdi:stop",
2155
+ enabledByDefault: false
2156
+ },
2157
+ "return-to-base": {
2158
+ label: "Return to base",
2159
+ icon: "mdi:home-import-outline"
2160
+ },
2161
+ locate: {
2162
+ label: "Locate",
2163
+ icon: "mdi:map-marker-radius",
2164
+ enabledByDefault: false
2165
+ }
2166
+ }
2167
+ },
2168
+ "lawn-mower-control": {
2169
+ platform: "sensor",
2170
+ field: "activity",
2171
+ icon: "mdi:robot-mower",
2172
+ extras: {
2173
+ battery: {
2174
+ platform: "sensor",
2175
+ field: "batteryLevel",
2176
+ label: "Battery",
2177
+ deviceClass: "battery",
2178
+ unit: "%"
2179
+ },
2180
+ progress: {
2181
+ platform: "sensor",
2182
+ field: "progressPercent",
2183
+ label: "Progress",
2184
+ unit: "%",
2185
+ enabledByDefault: false
2186
+ },
2187
+ error: {
2188
+ platform: "sensor",
2189
+ field: "currentCodeLabel",
2190
+ label: "Status code",
2191
+ entityCategory: "diagnostic",
2192
+ enabledByDefault: false
2193
+ }
2194
+ },
2195
+ commands: {
2196
+ start: {
2197
+ label: "Start mowing",
2198
+ icon: "mdi:play"
2199
+ },
2200
+ pause: {
2201
+ label: "Pause",
2202
+ icon: "mdi:pause"
2203
+ },
2204
+ dock: {
2205
+ label: "Dock",
2206
+ icon: "mdi:home-import-outline"
2207
+ }
2208
+ }
2209
+ },
2210
+ /** `currentMedia` is an object; the flat fields around it are exported. */
2211
+ "media-player": {
2212
+ platform: "sensor",
2213
+ field: "state",
2214
+ icon: "mdi:play-circle",
2215
+ extras: {
2216
+ volume: {
2217
+ platform: "number",
2218
+ field: "volumeLevel",
2219
+ label: "Volume",
2220
+ icon: "mdi:volume-high",
2221
+ writable: true,
2222
+ range: PERCENT_RANGE
2223
+ },
2224
+ muted: {
2225
+ platform: "switch",
2226
+ field: "isMuted",
2227
+ label: "Muted",
2228
+ icon: "mdi:volume-off",
2229
+ writable: true
2230
+ },
2231
+ /**
2232
+ * Free-form: the accepted values are `availableSources` on the slice.
2233
+ * `mediaPlayer.selectSource` is the route; the native media_player
2234
+ * renders it as its source list.
2235
+ */
2236
+ source: {
2237
+ platform: "sensor",
2238
+ field: "source",
2239
+ label: "Source",
2240
+ writable: true,
2241
+ enabledByDefault: false
2242
+ },
2243
+ repeat: {
2244
+ platform: "select",
2245
+ field: "repeat",
2246
+ label: "Repeat",
2247
+ writable: true,
2248
+ options: require_dist.MediaPlayerRepeatSchema.options,
2249
+ enabledByDefault: false
2250
+ },
2251
+ shuffle: {
2252
+ platform: "switch",
2253
+ field: "shuffle",
2254
+ label: "Shuffle",
2255
+ writable: true,
2256
+ enabledByDefault: false
2257
+ }
2258
+ },
2259
+ commands: {
2260
+ play: {
2261
+ label: "Play",
2262
+ icon: "mdi:play"
2263
+ },
2264
+ pause: {
2265
+ label: "Pause",
2266
+ icon: "mdi:pause"
2267
+ },
2268
+ stop: {
2269
+ label: "Stop",
2270
+ icon: "mdi:stop",
2271
+ enabledByDefault: false
2272
+ },
2273
+ next: {
2274
+ label: "Next",
2275
+ icon: "mdi:skip-next"
2276
+ },
2277
+ previous: {
2278
+ label: "Previous",
2279
+ icon: "mdi:skip-previous"
2280
+ }
2281
+ }
2282
+ },
2283
+ /**
2284
+ * `rgb` and `hsv` are nested objects, so hue and saturation cannot be
2285
+ * two flat `number` entities without a projector that walks into a
2286
+ * sub-object — and a `light` platform that carries the whole colour is
2287
+ * Tier B. What IS flat is the colour MODE and the colour temperature,
2288
+ * and both are automatable.
2289
+ */
2290
+ color: {
2291
+ platform: "sensor",
2292
+ field: "mode",
2293
+ icon: "mdi:palette",
2294
+ extras: {
2295
+ /**
2296
+ * The one writable surface `setColor` has that is a single number: its
2297
+ * input is a discriminated union and `{ mode: 'mired', mireds }` is the
2298
+ * only arm with exactly one scalar in it. `mode` itself stays read-only
2299
+ * — writing it alone would be a call with no colour in it.
2300
+ */
2301
+ mireds: {
2302
+ platform: "number",
2303
+ field: "mireds",
2304
+ label: "Colour temperature",
2305
+ unit: "mired",
2306
+ writable: true,
2307
+ range: MIRED_RANGE,
2308
+ enabledByDefault: false
2309
+ } }
2310
+ },
2311
+ update: {
2312
+ platform: "binary_sensor",
2313
+ field: "updatable",
2314
+ deviceClass: "update",
2315
+ extras: {
2316
+ available: {
2317
+ platform: "sensor",
2318
+ field: "availableVersion",
2319
+ label: "Available version",
2320
+ entityCategory: "diagnostic"
2321
+ },
2322
+ current: {
2323
+ platform: "sensor",
2324
+ field: "currentVersion",
2325
+ label: "Installed version",
2326
+ entityCategory: "diagnostic"
2327
+ },
2328
+ installing: {
2329
+ platform: "binary_sensor",
2330
+ field: "inProgress",
2331
+ label: "Installing",
2332
+ deviceClass: "running",
2333
+ enabledByDefault: false
2334
+ }
2335
+ }
2336
+ },
2337
+ image: {
2338
+ platform: "image",
2339
+ field: "url",
2340
+ extras: { updated: {
2341
+ platform: "sensor",
2342
+ field: "lastUpdated",
2343
+ label: "Last updated",
2344
+ deviceClass: "timestamp",
2345
+ enabledByDefault: false
2346
+ } }
2347
+ },
2348
+ "script-runner": {
2349
+ platform: "binary_sensor",
2350
+ field: "isRunning",
2351
+ deviceClass: "running",
2352
+ icon: "mdi:script-text-play",
2353
+ extras: {
2354
+ "last-run": {
2355
+ platform: "sensor",
2356
+ field: "lastRunAt",
2357
+ label: "Last run",
2358
+ deviceClass: "timestamp"
2359
+ },
2360
+ "last-error": {
2361
+ platform: "sensor",
2362
+ field: "lastError",
2363
+ label: "Last error",
2364
+ entityCategory: "diagnostic",
2365
+ enabledByDefault: false
2366
+ }
2367
+ },
2368
+ commands: {
2369
+ run: {
2370
+ label: "Run",
2371
+ icon: "mdi:play"
2372
+ },
2373
+ stop: {
2374
+ label: "Stop",
2375
+ icon: "mdi:stop"
2376
+ }
2377
+ }
2378
+ },
2379
+ /**
2380
+ * `enabled` is a switch because `enable`/`disable` write exactly it;
2381
+ * `trigger` is a button because firing the action block is a verb, and it
2382
+ * works on a DISABLED automation — which is why it is not folded into the
2383
+ * switch.
2384
+ */
2385
+ "automation-control": {
2386
+ platform: "switch",
2387
+ field: "enabled",
2388
+ icon: "mdi:robot",
2389
+ writable: true,
2390
+ commands: { trigger: {
2391
+ label: "Trigger",
2392
+ icon: "mdi:flash"
2393
+ } },
2394
+ extras: {
2395
+ running: {
2396
+ platform: "binary_sensor",
2397
+ field: "isRunning",
2398
+ label: "Running",
2399
+ deviceClass: "running",
2400
+ enabledByDefault: false
2401
+ },
2402
+ "last-triggered": {
2403
+ platform: "sensor",
2404
+ field: "lastTriggeredAt",
2405
+ label: "Last triggered",
2406
+ deviceClass: "timestamp"
2407
+ },
2408
+ "last-error": {
2409
+ platform: "sensor",
2410
+ field: "lastError",
2411
+ label: "Last error",
2412
+ entityCategory: "diagnostic",
2413
+ enabledByDefault: false
2414
+ }
2415
+ }
2416
+ },
2417
+ /**
2418
+ * `lastEvent` is an object and `eventTypes` an array, so the plan's
2419
+ * "last-event + last-event-at" pair has nothing flat to read. The COUNT
2420
+ * is flat, monotonic and the thing an automation can trigger on ("it
2421
+ * went up") — a native `event` platform, which carries the payload, is
2422
+ * Tier B.
2423
+ */
2424
+ "event-emitter": {
2425
+ platform: "sensor",
2426
+ field: "eventCountSinceStart",
2427
+ icon: "mdi:counter"
2428
+ },
2429
+ "pet-feeder": {
2430
+ platform: "sensor",
2431
+ field: "foodLevel",
2432
+ unit: "%",
2433
+ icon: "mdi:food-drumstick",
2434
+ extras: {
2435
+ "low-food": {
2436
+ platform: "binary_sensor",
2437
+ field: "lowFood",
2438
+ label: "Low food",
2439
+ deviceClass: "problem"
2440
+ },
2441
+ feeding: {
2442
+ platform: "binary_sensor",
2443
+ field: "feeding",
2444
+ label: "Feeding",
2445
+ deviceClass: "running",
2446
+ enabledByDefault: false
2447
+ },
2448
+ desiccant: {
2449
+ platform: "sensor",
2450
+ field: "desiccantLeftDays",
2451
+ label: "Desiccant remaining",
2452
+ unit: "d",
2453
+ entityCategory: "diagnostic",
2454
+ enabledByDefault: false
2455
+ },
2456
+ status: {
2457
+ platform: "sensor",
2458
+ field: "status",
2459
+ label: "Status",
2460
+ entityCategory: "diagnostic",
2461
+ enabledByDefault: false
2462
+ }
2463
+ }
2464
+ },
2465
+ /**
2466
+ * The cap carries its OWN unit next to each measurement
2467
+ * (`temperatureUnit`, `pressureUnit`, `windSpeedUnit`), so no static
2468
+ * `unit_of_measurement` can be honest for a provider reporting °F.
2469
+ * Declaring one anyway is how a dashboard comes to say 71 °C. Humidity
2470
+ * is the exception: a percentage is a percentage.
2471
+ */
2472
+ weather: {
2473
+ platform: "sensor",
2474
+ field: "condition",
2475
+ icon: "mdi:weather-partly-cloudy",
2476
+ extras: {
2477
+ temperature: {
2478
+ platform: "sensor",
2479
+ field: "temperature",
2480
+ label: "Temperature",
2481
+ icon: "mdi:thermometer"
2482
+ },
2483
+ humidity: {
2484
+ platform: "sensor",
2485
+ field: "humidity",
2486
+ label: "Humidity",
2487
+ deviceClass: "humidity",
2488
+ unit: "%"
2489
+ },
2490
+ pressure: {
2491
+ platform: "sensor",
2492
+ field: "pressure",
2493
+ label: "Pressure",
2494
+ icon: "mdi:gauge",
2495
+ enabledByDefault: false
2496
+ },
2497
+ wind: {
2498
+ platform: "sensor",
2499
+ field: "windSpeed",
2500
+ label: "Wind speed",
2501
+ icon: "mdi:weather-windy",
2502
+ enabledByDefault: false
2503
+ },
2504
+ bearing: {
2505
+ platform: "sensor",
2506
+ field: "windBearing",
2507
+ label: "Wind bearing",
2508
+ unit: "°",
2509
+ enabledByDefault: false
2510
+ }
2511
+ }
2512
+ },
2513
+ /**
2514
+ * The generic user-settable input. `value` is `number | string` and
2515
+ * `kind` says which of the four shapes it is — a `number`, a `select`
2516
+ * or a text box, all three of which would need `control.setValue` in
2517
+ * the route table before they could be anything but a reading.
2518
+ */
2519
+ control: {
2520
+ platform: "sensor",
2521
+ field: "value",
2522
+ extras: { kind: {
2523
+ platform: "sensor",
2524
+ field: "kind",
2525
+ label: "Input kind",
2526
+ entityCategory: "diagnostic",
2527
+ enabledByDefault: false
2528
+ } }
2529
+ },
2530
+ "motion-trigger": {
2531
+ platform: "binary_sensor",
2532
+ field: "enabled",
2533
+ icon: "mdi:motion-sensor"
2534
+ },
2535
+ /**
2536
+ * Three capabilities a camera carries that an operator automates on. They
2537
+ * are in this table because the descriptor and the push path are the
2538
+ * derived half's; they reach a camera through {@link CAMERA_DERIVED_CAPS}
2539
+ * because `buildDerivedPlan` never runs for one.
2540
+ */
2541
+ /**
2542
+ * Talk-back, exported on the operator's decision and READ-ONLY.
2543
+ *
2544
+ * It was excluded until 2026-08-14 for a measured reason: the cap declared
2545
+ * `status` and no `runtimeState`, and the export's only two sources of a
2546
+ * value — the `device.state-changed` slice and the
2547
+ * `deviceState.getAllSnapshots` snapshot — are both built from runtime
2548
+ * state. The entity would have been published and never received a value.
2549
+ * `intercom.cap.ts` now declares the slice and the Reolink and Hikvision
2550
+ * providers write it at the four points that open and close a session, so
2551
+ * the entity has a feed before it has a row here.
2552
+ *
2553
+ * Read-only, and that is not a missing route: talk-back is an AUDIO
2554
+ * STREAM. `startTalkSession` opens a channel that `pushTalkAudio` feeds
2555
+ * frame by frame, and Home Assistant's switch carries `true`. A switch
2556
+ * that opened a session nobody could speak into is the control that lies.
2557
+ */
2558
+ intercom: {
2559
+ platform: "binary_sensor",
2560
+ field: "talking",
2561
+ icon: "mdi:account-voice",
2562
+ extras: { "last-session": {
2563
+ platform: "sensor",
2564
+ field: "lastSessionAt",
2565
+ label: "Last talk session",
2566
+ deviceClass: "timestamp",
2567
+ enabledByDefault: false
2568
+ } }
2569
+ },
2570
+ "day-night": {
2571
+ platform: "sensor",
2572
+ field: "mode",
2573
+ icon: "mdi:theme-light-dark",
2574
+ extras: {
2575
+ sensitivity: {
2576
+ platform: "sensor",
2577
+ field: "sensitivity",
2578
+ label: "IR-cut sensitivity",
2579
+ entityCategory: "diagnostic",
2580
+ enabledByDefault: false
2581
+ },
2582
+ "switch-delay": {
2583
+ platform: "sensor",
2584
+ field: "switchDelaySec",
2585
+ label: "IR-cut switch delay",
2586
+ unit: "s",
2587
+ entityCategory: "diagnostic",
2588
+ enabledByDefault: false
2589
+ }
2590
+ }
2591
+ },
2592
+ "ptz-autotrack": {
2593
+ platform: "binary_sensor",
2594
+ field: "enabled",
2595
+ icon: "mdi:crosshairs-gps"
687
2596
  }
688
2597
  };
2598
+ /**
2599
+ * Capabilities exported on a CAMERA rather than through
2600
+ * `buildDerivedPlan`, which only ever runs for a non-camera device.
2601
+ */
2602
+ var CAMERA_DERIVED_CAPS = [
2603
+ "day-night",
2604
+ "ptz-autotrack",
2605
+ "intercom"
2606
+ ];
2607
+ /**
2608
+ * The command buttons a capability produces.
2609
+ *
2610
+ * Separate from {@link capEntities} on purpose: that list is what the
2611
+ * PROJECTOR walks, and a button has nothing to project. Keeping them in one
2612
+ * list would put an entity with no `field` in front of code whose whole job is
2613
+ * to read one.
2614
+ */
2615
+ function capCommandEntities(capName) {
2616
+ const mapping = CAP_ENTITY_MAP[capName];
2617
+ if (mapping === void 0) return [];
2618
+ return Object.entries(mapping.commands ?? {}).map(([suffix, command]) => ({
2619
+ entity: derivedExtraEntityId(capName, suffix),
2620
+ suffix,
2621
+ label: command.label,
2622
+ command
2623
+ }));
2624
+ }
2625
+ /**
2626
+ * The one expression that turns a descriptor into entities.
2627
+ *
2628
+ * Both halves of the catalog and the projector call it, so a capability
2629
+ * cannot build a component under one id and publish under another — the
2630
+ * failure `derivedEntityId` was extracted to end, now that a capability
2631
+ * can produce more than one entity.
2632
+ */
2633
+ function capEntities(capName) {
2634
+ const mapping = CAP_ENTITY_MAP[capName];
2635
+ if (mapping === void 0) return [];
2636
+ const refs = [{
2637
+ entity: derivedEntityId(capName),
2638
+ label: humanise(capName),
2639
+ descriptor: mapping
2640
+ }];
2641
+ for (const [suffix, extra] of Object.entries(mapping.extras ?? {})) refs.push({
2642
+ entity: derivedExtraEntityId(capName, suffix),
2643
+ label: extra.label,
2644
+ descriptor: extra
2645
+ });
2646
+ return refs;
2647
+ }
2648
+ /**
2649
+ * Apply a descriptor's derivation.
2650
+ *
2651
+ * Returns `null` — which renders as `unrenderable` and is LOGGED — when
2652
+ * the slice carried something a comparison cannot be made against. A
2653
+ * derivation that quietly answered `false` for a non-string would report
2654
+ * a jammed lock as unlocked, which is the failure the derivation exists
2655
+ * to avoid.
2656
+ */
2657
+ function deriveCapValue(derivation, raw) {
2658
+ if (derivation === void 0) return raw;
2659
+ if (typeof raw !== "string") return null;
2660
+ return derivation.kind === "equals" ? raw === derivation.value : raw !== derivation.value;
2661
+ }
2662
+ /** A capability's entities as the catalog builders consume them. */
2663
+ function capEntitySpecs(capName) {
2664
+ const specs = capEntities(capName).map((ref) => ({
2665
+ entity: ref.entity,
2666
+ platform: ref.descriptor.platform,
2667
+ label: ref.label,
2668
+ enabledByDefault: ref.descriptor.enabledByDefault ?? true,
2669
+ ...ref.descriptor.deviceClass !== void 0 ? { deviceClass: ref.descriptor.deviceClass } : {},
2670
+ ...ref.descriptor.unit !== void 0 ? { unit: ref.descriptor.unit } : {},
2671
+ ...ref.descriptor.icon !== void 0 ? { icon: ref.descriptor.icon } : {},
2672
+ ...ref.descriptor.options !== void 0 ? { options: ref.descriptor.options } : {},
2673
+ ...ref.descriptor.range !== void 0 ? { range: ref.descriptor.range } : {},
2674
+ ...ref.descriptor.entityCategory !== void 0 ? { entityCategory: ref.descriptor.entityCategory } : {},
2675
+ ...ref.descriptor.writable === true ? { writable: true } : {}
2676
+ }));
2677
+ for (const ref of capCommandEntities(capName)) specs.push({
2678
+ entity: ref.entity,
2679
+ platform: "button",
2680
+ label: ref.label,
2681
+ enabledByDefault: ref.command.enabledByDefault ?? true,
2682
+ writable: true,
2683
+ ...ref.command.icon !== void 0 ? { icon: ref.command.icon } : {},
2684
+ ...ref.command.entityCategory !== void 0 ? { entityCategory: ref.command.entityCategory } : {}
2685
+ });
2686
+ return specs;
2687
+ }
689
2688
  var CAPS_NOT_EXPORTED = [
690
2689
  {
691
2690
  cap: "snapshot",
@@ -726,8 +2725,116 @@ var CAPS_NOT_EXPORTED = [
726
2725
  {
727
2726
  cap: "device-discovery",
728
2727
  reason: "Adoption-time only — it describes candidates, never the state of an adopted device."
2728
+ },
2729
+ {
2730
+ cap: "camera-credentials",
2731
+ reason: "It carries the camera's host, port and password. An entity would write that password into Home Assistant's registry and into every debug log it keeps, on a host camstack does not own. Never an entity, at any tier."
2732
+ },
2733
+ {
2734
+ cap: "stream-catalog",
2735
+ reason: "Transport — and it returns the camera's native URL with the password inline, so exporting it leaks the credential exactly as camera-credentials would. The streams reach HA as `camera` entities built from the broker restream instead (camera-entities.ts)."
2736
+ },
2737
+ {
2738
+ cap: "audio-analysis",
2739
+ reason: "Pipeline configuration. The audio DETECTIONS it drives already reach HA as the camera catalog's `audio_last_sound` / `audio_volume` entities, from the analyzer's own event."
2740
+ },
2741
+ {
2742
+ cap: "camera-pipeline-config",
2743
+ reason: "Transport/pipeline configuration — it selects the road, it is not a state."
2744
+ },
2745
+ {
2746
+ cap: "camera-streams",
2747
+ reason: "Transport. Its profile slots become the live `camera` entities (T4), which is the exported form; the slot configuration itself is not something an automation reads."
2748
+ },
2749
+ {
2750
+ cap: "detection-pipeline",
2751
+ reason: "Pipeline configuration. Its output is the per-macro camera entities, which are exported and which would disagree with a second set derived from the config."
2752
+ },
2753
+ {
2754
+ cap: "image-settings",
2755
+ reason: "Firmware image tuning — encoder brightness/contrast/saturation. A rendering setting with no state an automation reads."
2756
+ },
2757
+ {
2758
+ cap: "motion-detection",
2759
+ reason: "The LOCAL ML motion pipeline's configuration. Motion STATE is the `motion` capability, which is mapped; two sources on one question is the failure D62 records."
2760
+ },
2761
+ {
2762
+ cap: "motion-zones",
2763
+ reason: "Zone geometry, not state. Per-zone counts already reach HA through the camera catalog's zone fan-out."
2764
+ },
2765
+ {
2766
+ cap: "osd",
2767
+ reason: "On-screen-display text overlay — a rendering setting with no automatable state."
2768
+ },
2769
+ {
2770
+ cap: "pipeline-analytics",
2771
+ reason: "Pipeline telemetry. Cluster health is already the CamStack Server synthetic device; per-camera pipeline counters would fan out across the fleet with nothing consuming them."
2772
+ },
2773
+ {
2774
+ cap: "stream-params",
2775
+ reason: "Encoder parameters (bitrate/resolution/fps) — transport rather than state."
2776
+ },
2777
+ {
2778
+ cap: "videoclips",
2779
+ reason: "Media retrieval by handle, not a state. Clips reach the operator through notifications and the viewer."
2780
+ },
2781
+ {
2782
+ cap: "webrtc-session",
2783
+ reason: "A session opener — the definition of transport."
2784
+ },
2785
+ {
2786
+ cap: "events",
2787
+ reason: "A paginated event LOG, not a state. Its natural target is HA's `event` platform, which the component does not build (Tier B)."
2788
+ },
2789
+ {
2790
+ cap: "consumables",
2791
+ reason: "The value is `items`, an ARRAY of per-device consumables named by the provider (a filter, a brush, a cartridge). This table is static per capability, so it cannot mint one sensor per item; doing it needs a reconcile-time entity mint keyed on the device, which is a design, not a row."
2792
+ },
2793
+ {
2794
+ cap: "scene-monitor",
2795
+ reason: "Per-ROI reference-region state, `monitors` being an array of operator-named regions. Same shape as consumables: the entity set is per device and changes when the operator adds a region, which a static descriptor cannot express."
2796
+ },
2797
+ {
2798
+ cap: "audio-metrics",
2799
+ reason: "A real measurement, and already exported from its authority: the camera catalog projects `audio_volume` and `audio_level_rms` from the analyzer's own `pipeline.audio-inference-result`. A second source on the same question is the two-knobs failure D62 records."
2800
+ },
2801
+ {
2802
+ cap: "notifier",
2803
+ reason: "A delivery service, not an entity — HA models these as `notify.<service>`, which is not one of the nine platforms the component builds."
2804
+ },
2805
+ {
2806
+ cap: "accessories",
2807
+ reason: "Grouping metadata. Every listed child is a device in its own right and is exported as one; a second entity would restate the link and disagree with it the moment a child moves."
2808
+ },
2809
+ {
2810
+ cap: "zones",
2811
+ reason: "Zone geometry — configuration, not state. Same reason as `zone-analytics`, whose per-zone counts are what actually reaches HA."
2812
+ },
2813
+ {
2814
+ cap: "zone-rules",
2815
+ reason: "Per-stage zone rules — configuration the pipeline consumes, with no value an automation reads."
729
2816
  }
730
2817
  ];
2818
+ var UNNEGOTIATED_PLAN_OPTIONS = { platforms: UNNEGOTIATED_SUPPORT };
2819
+ /**
2820
+ * An entity whose platform the component cannot build, degraded to one it
2821
+ * can.
2822
+ *
2823
+ * The command topic goes with the platform: the degraded form is a
2824
+ * READING, and announcing a command topic on it would leave a route open
2825
+ * that nothing can reach. See `DEGRADED_PLATFORM` for the one entry this
2826
+ * exists for.
2827
+ */
2828
+ function degradeUnsupported(spec, options) {
2829
+ if (options.platforms.has(spec.platform)) return spec;
2830
+ const fallback = DEGRADED_PLATFORM[spec.platform];
2831
+ if (fallback === void 0) return spec;
2832
+ const { writable: _writable, ...rest } = spec;
2833
+ return {
2834
+ ...rest,
2835
+ platform: fallback
2836
+ };
2837
+ }
731
2838
  /** A capability in neither list — the thing the guard exists to surface. */
732
2839
  function unclassifiedCaps(caps) {
733
2840
  const known = new Set([...Object.keys(CAP_ENTITY_MAP), ...CAPS_NOT_EXPORTED.map((e) => e.cap)]);
@@ -746,8 +2853,19 @@ function unclassifiedCaps(caps) {
746
2853
  * (`device-status`, `feature-probe`, `device-ops`) carry no entity at
747
2854
  * all. The camera valve is for 73-per-device; this is not that problem,
748
2855
  * and exporting the operator's temperature switched off was the bug.
2856
+ *
2857
+ * **The Tier A widening qualifies that, per ENTITY rather than per
2858
+ * device.** A cover, a thermostat or a media player is not one value; it
2859
+ * is a state plus the numbers around it, and the numbers are the reason
2860
+ * to own the device. So the PRIMARY entity of every capability still
2861
+ * arrives enabled — it is what the device is — and a secondary one
2862
+ * arrives enabled only when an operator automates on it. A thermostat's
2863
+ * measured temperature: yes. Its swing preset and its dual-setpoint
2864
+ * bounds: registered and off. That keeps a widened `climate-control`
2865
+ * device at three enabled entities rather than eight, without hiding the
2866
+ * data from anyone who wants it.
749
2867
  */
750
- function buildDerivedPlan(device) {
2868
+ function buildDerivedPlan(device, options = UNNEGOTIATED_PLAN_OPTIONS) {
751
2869
  const specs = [{
752
2870
  entity: "online",
753
2871
  platform: "binary_sensor",
@@ -756,24 +2874,50 @@ function buildDerivedPlan(device) {
756
2874
  entityCategory: "diagnostic",
757
2875
  enabledByDefault: true
758
2876
  }];
2877
+ const native = {};
759
2878
  for (const cap of device.boundCaps) {
2879
+ /**
2880
+ * A camera-scoped capability is built by the CAMERA half. Nothing
2881
+ * binds `day-night` to a base device today, and if something did,
2882
+ * two halves of this file would build the same entity id twice.
2883
+ */
2884
+ if (CAMERA_DERIVED_CAPS.includes(cap)) continue;
760
2885
  const mapping = CAP_ENTITY_MAP[cap];
761
- if (mapping === void 0) continue;
762
- specs.push({
763
- entity: toSlug(cap),
764
- platform: mapping.platform,
2886
+ const platform = nativePlatformFor(cap);
2887
+ const plan = mapping === void 0 || platform === null || !options.platforms.has(platform) ? null : buildNativeComponent({
2888
+ deviceKey: deviceKeyFor(device.stableId),
2889
+ stableId: device.stableId,
2890
+ capName: cap,
765
2891
  label: humanise(cap),
766
- enabledByDefault: true,
767
- ...mapping.deviceClass !== void 0 ? { deviceClass: mapping.deviceClass } : {},
768
- ...mapping.unit !== void 0 ? { unit: mapping.unit } : {},
769
- ...mapping.writable === true ? { writable: true } : {}
2892
+ slice: device.capSlices?.[cap],
2893
+ mapping
770
2894
  });
2895
+ if (plan === null) {
2896
+ specs.push(...capEntitySpecs(cap).map((spec) => degradeUnsupported(spec, options)));
2897
+ continue;
2898
+ }
2899
+ native[toComponentKey(plan.component.platform, derivedEntityId(cap))] = plan.component;
2900
+ /**
2901
+ * Rule 3 of `native-platforms.ts`: what the native entity reads, it
2902
+ * owns. What it does NOT read is still announced — a vacuum's error
2903
+ * label has nowhere to go on Home Assistant's vacuum, and dropping it
2904
+ * to make the device look tidy would be a silent loss of a value an
2905
+ * operator already automates on.
2906
+ */
2907
+ specs.push(...capEntitySpecs(cap).filter((spec) => !plan.absorbed.has(spec.entity)));
771
2908
  }
772
- return assemble(device, specs);
2909
+ const assembled = assemble(device, specs);
2910
+ return {
2911
+ ...assembled,
2912
+ cmps: {
2913
+ ...assembled.cmps,
2914
+ ...native
2915
+ }
2916
+ };
773
2917
  }
774
2918
  /** Camera or base kind — the only place that decision is made. */
775
- function buildDevicePlan(device) {
776
- return device.type === "camera" ? buildCameraPlan(device) : buildDerivedPlan(device);
2919
+ function buildDevicePlan(device, options = UNNEGOTIATED_PLAN_OPTIONS) {
2920
+ return device.type === "camera" ? buildCameraPlan(device) : buildDerivedPlan(device, options);
777
2921
  }
778
2922
  //#endregion
779
2923
  //#region src/ha-export/command-routes.ts
@@ -781,15 +2925,28 @@ function buildDevicePlan(device) {
781
2925
  * Home Assistant → camstack.
782
2926
  *
783
2927
  * The component POSTs `{topic, value}` onto this addon's `addon-routes`
784
- * surface; this module turns that pair into a typed command. Resolution
785
- * is pure so it can be tested without a device, and the addon does the
786
- * dispatching.
2928
+ * surface; this module turns that pair into a typed command AND owns the
2929
+ * call each capability command makes. Resolution is pure; the dispatch
2930
+ * takes an injected `DeviceProxy` slice, so both halves are testable
2931
+ * without a hub.
2932
+ *
2933
+ * **The route and the call live together on purpose.** They used not to:
2934
+ * `resolveCommand` collapsed every non-button writable cap into
2935
+ * `kind: 'cap-switch'` and the addon called `device.switch.setState`
2936
+ * unconditionally, so `lock-control`, `siren`, `alarm-panel` and
2937
+ * `brightness` all advertised a control in Home Assistant that could not
2938
+ * work — a lock got `noProvider` and a 422, and `brightness` is a number
2939
+ * that never parsed as a boolean and never routed at all. A table that
2940
+ * names a method in one file and a switch that calls it in another is
2941
+ * exactly the drift that produced four lying controls.
787
2942
  *
788
2943
  * The rule that shapes every branch: **an unroutable or malformed
789
2944
  * command is refused, never approximated.** A `switch` payload that is
790
2945
  * neither `true` nor `false` is not "off"; a PTZ press on a camera that
791
- * declares no `ptz` cap is not a no-op worth pretending succeeded. Both
792
- * return `null`, and the caller logs the drop.
2946
+ * declares no `ptz` cap is not a no-op worth pretending succeeded; a cap
2947
+ * with no route does NOT fall through to `switch.setState`. Every
2948
+ * refusal carries a named reason so the log line says which of those it
2949
+ * was.
793
2950
  */
794
2951
  /** The camera switch ids, keyed by the entity slug the catalog emits. */
795
2952
  var CAMERA_SWITCH_BY_SLUG = Object.fromEntries([
@@ -809,76 +2966,981 @@ function parseBool(value) {
809
2966
  if (lower === "false" || lower === "off" || lower === "0") return false;
810
2967
  return null;
811
2968
  }
2969
+ /**
2970
+ * A lock's payload.
2971
+ *
2972
+ * The catalog renders `lock-control` as a `switch`, so Home Assistant
2973
+ * sends `true`/`false` — but the component is not the only caller and a
2974
+ * lock's own vocabulary is `lock`/`unlock`. Both are accepted because
2975
+ * both are unambiguous; anything else is refused rather than read as
2976
+ * "unlock", which is the wrong way to be wrong about a door.
2977
+ */
2978
+ function parseLocked(value) {
2979
+ const asBool = parseBool(value);
2980
+ if (asBool !== null) return asBool;
2981
+ const lower = value.trim().toLowerCase();
2982
+ if (lower === "lock" || lower === "locked") return true;
2983
+ if (lower === "unlock" || lower === "unlocked") return false;
2984
+ return null;
2985
+ }
2986
+ /**
2987
+ * A free-form vocabulary value, as the DEVICE spells it.
2988
+ *
2989
+ * `fanMode`, `preset`, `operationMode`, `mode`, `fanSpeed` and `source` are
2990
+ * `z.string().min(1)` in their caps and their accepted values live in an
2991
+ * `available*` array on the slice. Nothing here validates against that list:
2992
+ * a pure resolution has no slice, and a second copy of the vocabulary would
2993
+ * be one firmware away from refusing a mode the device accepts. The device
2994
+ * is the authority on its own vocabulary, and its refusal surfaces with its
2995
+ * own words. Only EMPTY is refused here, because a blank is never a mode.
2996
+ */
2997
+ function parseVocabulary(value) {
2998
+ const trimmed = value.trim();
2999
+ return trimmed.length === 0 ? null : trimmed;
3000
+ }
3001
+ /** 0..100 inclusive, as `brightness.setBrightness` declares it. */
3002
+ function parsePercentage(value) {
3003
+ const parsed = Number(value.trim());
3004
+ if (!Number.isFinite(parsed)) return null;
3005
+ if (parsed < 0 || parsed > 100) return null;
3006
+ return parsed;
3007
+ }
3008
+ /**
3009
+ * An alarm panel's payload.
3010
+ *
3011
+ * Home Assistant's alarm-panel command vocabulary is `ARM_HOME`,
3012
+ * `ARM_AWAY`, `ARM_NIGHT`, `ARM_VACATION`, `ARM_CUSTOM_BYPASS`, `DISARM`
3013
+ * and `TRIGGER`; the cap's own vocabulary is the arm MODE (`home`,
3014
+ * `away`, …) and its state vocabulary is `armed_home`. All three are
3015
+ * accepted — they are the same instruction spelled by three layers of the
3016
+ * same stack — and the mode itself is validated by `AlarmArmModeSchema`
3017
+ * rather than by a second list here that could drift from the cap.
3018
+ */
3019
+ function parseAlarmAction(value) {
3020
+ const lower = value.trim().toLowerCase();
3021
+ if (lower === "disarm" || lower === "disarmed") return { kind: "disarm" };
3022
+ if (lower === "trigger" || lower === "triggered") return { kind: "trigger" };
3023
+ const withoutPrefix = lower.startsWith("armed_") ? lower.slice(6) : lower.startsWith("arm_") ? lower.slice(4) : lower;
3024
+ const mode = require_dist.AlarmArmModeSchema.safeParse(withoutPrefix);
3025
+ return mode.success ? {
3026
+ kind: "arm",
3027
+ mode: mode.data
3028
+ } : null;
3029
+ }
3030
+ /** A finite number in `[min, max]`, or `null` when the payload is neither. */
3031
+ function parseBounded(value, min, max) {
3032
+ const parsed = Number(value.trim());
3033
+ if (!Number.isFinite(parsed)) return null;
3034
+ if (parsed < min || parsed > max) return null;
3035
+ return parsed;
3036
+ }
3037
+ /**
3038
+ * A payload against an enum the CAP declares.
3039
+ *
3040
+ * Never against a second list here. `HvacModeSchema`, `FanDirectionSchema`
3041
+ * and `MediaPlayerRepeatSchema` are the same objects the catalog offers as
3042
+ * the `select`'s options, so the values HA can send and the values this
3043
+ * accepts cannot drift apart.
3044
+ */
3045
+ function parseEnum(schema, value) {
3046
+ const parsed = schema.safeParse(value.trim().toLowerCase());
3047
+ return parsed.success ? parsed.data : null;
3048
+ }
3049
+ /**
3050
+ * capability → the commands that WRITE it.
3051
+ *
3052
+ * The entity catalog decides WHICH entities are writable; this decides what
3053
+ * writing each one does. An entity declared `writable` with no entry here is
3054
+ * refused with `no-route` and the drop is logged — it must never fall through
3055
+ * to another cap's method, which is how four controls came to advertise a
3056
+ * function they could not perform.
3057
+ *
3058
+ * Both directions are asserted by the specs: a writable descriptor with no
3059
+ * route fails, and a route with no descriptor fails too. An unreachable route
3060
+ * is the same defect read backwards — it describes a control the catalog
3061
+ * never built, and a leftover that describes the right design reads as
3062
+ * verification.
3063
+ *
3064
+ * **Every builder names ONE method with ONE value, and none of them reads
3065
+ * the device.** Resolution is pure and has no slice, which is why
3066
+ * `climate-control.setTargetRange` — the only cap method that takes two
3067
+ * values at once — resolves to a `climate-target-edge` carrying the half it
3068
+ * was given. The read of the other half belongs to {@link applyCapCommand},
3069
+ * which has the device; composing it here would have needed a slice this
3070
+ * function must not have.
3071
+ */
3072
+ var CAP_COMMAND_ROUTES = {
3073
+ switch: { primary: (deviceId, value) => {
3074
+ const on = parseBool(value);
3075
+ return on === null ? null : {
3076
+ kind: "cap-switch",
3077
+ deviceId,
3078
+ capName: "switch",
3079
+ on
3080
+ };
3081
+ } },
3082
+ "lock-control": {
3083
+ primary: (deviceId, value) => {
3084
+ const locked = parseLocked(value);
3085
+ return locked === null ? null : {
3086
+ kind: "cap-lock",
3087
+ deviceId,
3088
+ locked
3089
+ };
3090
+ },
3091
+ commands: { open: (deviceId) => ({
3092
+ kind: "cap-lock-open",
3093
+ deviceId
3094
+ }) }
3095
+ },
3096
+ brightness: { primary: (deviceId, value) => {
3097
+ const percentage = parsePercentage(value);
3098
+ return percentage === null ? null : {
3099
+ kind: "cap-brightness",
3100
+ deviceId,
3101
+ percentage
3102
+ };
3103
+ } },
3104
+ "alarm-panel": { primary: (deviceId, value) => {
3105
+ const action = parseAlarmAction(value);
3106
+ return action === null ? null : {
3107
+ kind: "cap-alarm",
3108
+ deviceId,
3109
+ action
3110
+ };
3111
+ } },
3112
+ button: { primary: (deviceId) => ({
3113
+ kind: "cap-button",
3114
+ deviceId,
3115
+ capName: "button"
3116
+ }) },
3117
+ cover: {
3118
+ extras: {
3119
+ position: (deviceId, value) => {
3120
+ const position = parsePercentage(value);
3121
+ return position === null ? null : {
3122
+ kind: "cover-position",
3123
+ deviceId,
3124
+ position
3125
+ };
3126
+ },
3127
+ tilt: (deviceId, value) => {
3128
+ const tiltPosition = parsePercentage(value);
3129
+ return tiltPosition === null ? null : {
3130
+ kind: "cover-tilt",
3131
+ deviceId,
3132
+ tiltPosition
3133
+ };
3134
+ }
3135
+ },
3136
+ commands: {
3137
+ open: (deviceId) => ({
3138
+ kind: "cover-verb",
3139
+ deviceId,
3140
+ verb: "open"
3141
+ }),
3142
+ close: (deviceId) => ({
3143
+ kind: "cover-verb",
3144
+ deviceId,
3145
+ verb: "close"
3146
+ }),
3147
+ stop: (deviceId) => ({
3148
+ kind: "cover-verb",
3149
+ deviceId,
3150
+ verb: "stop"
3151
+ })
3152
+ }
3153
+ },
3154
+ "climate-control": {
3155
+ primary: (deviceId, value) => {
3156
+ const mode = parseEnum(require_dist.HvacModeSchema, value);
3157
+ return mode === null ? null : {
3158
+ kind: "climate-mode",
3159
+ deviceId,
3160
+ mode
3161
+ };
3162
+ },
3163
+ extras: {
3164
+ /**
3165
+ * Bounded by the entity's declared range and nothing narrower: the
3166
+ * per-device limits are the provider's (`getOptions`), and refusing
3167
+ * here on a guess would reject a setpoint the device accepts.
3168
+ */
3169
+ target: (deviceId, value) => {
3170
+ const target = parseBounded(value, TEMPERATURE_MIN, TEMPERATURE_MAX);
3171
+ return target === null ? null : {
3172
+ kind: "climate-target",
3173
+ deviceId,
3174
+ target
3175
+ };
3176
+ },
3177
+ "target-humidity": (deviceId, value) => {
3178
+ const targetHumidity = parsePercentage(value);
3179
+ return targetHumidity === null ? null : {
3180
+ kind: "climate-target-humidity",
3181
+ deviceId,
3182
+ targetHumidity
3183
+ };
3184
+ },
3185
+ "fan-mode": (deviceId, value) => {
3186
+ const fanMode = parseVocabulary(value);
3187
+ return fanMode === null ? null : {
3188
+ kind: "climate-fan-mode",
3189
+ deviceId,
3190
+ fanMode
3191
+ };
3192
+ },
3193
+ preset: (deviceId, value) => {
3194
+ const preset = parseVocabulary(value);
3195
+ return preset === null ? null : {
3196
+ kind: "climate-preset",
3197
+ deviceId,
3198
+ preset
3199
+ };
3200
+ },
3201
+ /**
3202
+ * The two halves of `setTargetRange`, each bounded by the same range
3203
+ * `target` is. They resolve to an EDGE rather than to a call: the other
3204
+ * half is a fact about the device, and resolution is pure.
3205
+ */
3206
+ "target-low": (deviceId, value) => {
3207
+ const low = parseBounded(value, TEMPERATURE_MIN, TEMPERATURE_MAX);
3208
+ return low === null ? null : {
3209
+ kind: "climate-target-edge",
3210
+ deviceId,
3211
+ edge: "low",
3212
+ value: low
3213
+ };
3214
+ },
3215
+ "target-high": (deviceId, value) => {
3216
+ const high = parseBounded(value, TEMPERATURE_MIN, TEMPERATURE_MAX);
3217
+ return high === null ? null : {
3218
+ kind: "climate-target-edge",
3219
+ deviceId,
3220
+ edge: "high",
3221
+ value: high
3222
+ };
3223
+ },
3224
+ "swing-vertical": (deviceId, value) => {
3225
+ const on = parseBool(value);
3226
+ return on === null ? null : {
3227
+ kind: "climate-swing",
3228
+ deviceId,
3229
+ axis: "vertical",
3230
+ on
3231
+ };
3232
+ },
3233
+ "swing-horizontal": (deviceId, value) => {
3234
+ const on = parseBool(value);
3235
+ return on === null ? null : {
3236
+ kind: "climate-swing",
3237
+ deviceId,
3238
+ axis: "horizontal",
3239
+ on
3240
+ };
3241
+ }
3242
+ }
3243
+ },
3244
+ "fan-control": {
3245
+ primary: (deviceId, value) => {
3246
+ const percentage = parsePercentage(value);
3247
+ return percentage === null ? null : {
3248
+ kind: "fan-percentage",
3249
+ deviceId,
3250
+ percentage
3251
+ };
3252
+ },
3253
+ extras: {
3254
+ preset: (deviceId, value) => {
3255
+ const preset = parseVocabulary(value);
3256
+ return preset === null ? null : {
3257
+ kind: "fan-preset",
3258
+ deviceId,
3259
+ preset
3260
+ };
3261
+ },
3262
+ oscillating: (deviceId, value) => {
3263
+ const oscillating = parseBool(value);
3264
+ return oscillating === null ? null : {
3265
+ kind: "fan-oscillating",
3266
+ deviceId,
3267
+ oscillating
3268
+ };
3269
+ },
3270
+ direction: (deviceId, value) => {
3271
+ const direction = parseEnum(require_dist.FanDirectionSchema, value);
3272
+ return direction === null ? null : {
3273
+ kind: "fan-direction",
3274
+ deviceId,
3275
+ direction
3276
+ };
3277
+ }
3278
+ }
3279
+ },
3280
+ humidifier: {
3281
+ primary: (deviceId, value) => {
3282
+ const on = parseBool(value);
3283
+ return on === null ? null : {
3284
+ kind: "humidifier-on",
3285
+ deviceId,
3286
+ on
3287
+ };
3288
+ },
3289
+ extras: {
3290
+ "target-humidity": (deviceId, value) => {
3291
+ const humidity = parsePercentage(value);
3292
+ return humidity === null ? null : {
3293
+ kind: "humidifier-humidity",
3294
+ deviceId,
3295
+ humidity
3296
+ };
3297
+ },
3298
+ mode: (deviceId, value) => {
3299
+ const mode = parseVocabulary(value);
3300
+ return mode === null ? null : {
3301
+ kind: "humidifier-mode",
3302
+ deviceId,
3303
+ mode
3304
+ };
3305
+ }
3306
+ }
3307
+ },
3308
+ "water-heater": { extras: {
3309
+ target: (deviceId, value) => {
3310
+ const temp = parseBounded(value, TEMPERATURE_MIN, TEMPERATURE_MAX);
3311
+ return temp === null ? null : {
3312
+ kind: "water-heater-temp",
3313
+ deviceId,
3314
+ temp
3315
+ };
3316
+ },
3317
+ away: (deviceId, value) => {
3318
+ const on = parseBool(value);
3319
+ return on === null ? null : {
3320
+ kind: "water-heater-away",
3321
+ deviceId,
3322
+ on
3323
+ };
3324
+ },
3325
+ mode: (deviceId, value) => {
3326
+ const mode = parseVocabulary(value);
3327
+ return mode === null ? null : {
3328
+ kind: "water-heater-mode",
3329
+ deviceId,
3330
+ mode
3331
+ };
3332
+ }
3333
+ } },
3334
+ valve: {
3335
+ extras: { position: (deviceId, value) => {
3336
+ const position = parsePercentage(value);
3337
+ return position === null ? null : {
3338
+ kind: "valve-position",
3339
+ deviceId,
3340
+ position
3341
+ };
3342
+ } },
3343
+ commands: {
3344
+ open: (deviceId) => ({
3345
+ kind: "valve-verb",
3346
+ deviceId,
3347
+ verb: "open"
3348
+ }),
3349
+ close: (deviceId) => ({
3350
+ kind: "valve-verb",
3351
+ deviceId,
3352
+ verb: "close"
3353
+ }),
3354
+ stop: (deviceId) => ({
3355
+ kind: "valve-verb",
3356
+ deviceId,
3357
+ verb: "stop"
3358
+ })
3359
+ }
3360
+ },
3361
+ "vacuum-control": {
3362
+ extras: { "fan-speed": (deviceId, value) => {
3363
+ const speed = parseVocabulary(value);
3364
+ return speed === null ? null : {
3365
+ kind: "vacuum-fan-speed",
3366
+ deviceId,
3367
+ speed
3368
+ };
3369
+ } },
3370
+ commands: {
3371
+ start: (deviceId) => ({
3372
+ kind: "vacuum-verb",
3373
+ deviceId,
3374
+ verb: "start"
3375
+ }),
3376
+ pause: (deviceId) => ({
3377
+ kind: "vacuum-verb",
3378
+ deviceId,
3379
+ verb: "pause"
3380
+ }),
3381
+ stop: (deviceId) => ({
3382
+ kind: "vacuum-verb",
3383
+ deviceId,
3384
+ verb: "stop"
3385
+ }),
3386
+ "return-to-base": (deviceId) => ({
3387
+ kind: "vacuum-verb",
3388
+ deviceId,
3389
+ verb: "return-to-base"
3390
+ }),
3391
+ locate: (deviceId) => ({
3392
+ kind: "vacuum-verb",
3393
+ deviceId,
3394
+ verb: "locate"
3395
+ })
3396
+ }
3397
+ },
3398
+ "lawn-mower-control": { commands: {
3399
+ start: (deviceId) => ({
3400
+ kind: "mower-verb",
3401
+ deviceId,
3402
+ verb: "start"
3403
+ }),
3404
+ pause: (deviceId) => ({
3405
+ kind: "mower-verb",
3406
+ deviceId,
3407
+ verb: "pause"
3408
+ }),
3409
+ dock: (deviceId) => ({
3410
+ kind: "mower-verb",
3411
+ deviceId,
3412
+ verb: "dock"
3413
+ })
3414
+ } },
3415
+ "media-player": {
3416
+ extras: {
3417
+ volume: (deviceId, value) => {
3418
+ const volumeLevel = parsePercentage(value);
3419
+ return volumeLevel === null ? null : {
3420
+ kind: "media-volume",
3421
+ deviceId,
3422
+ volumeLevel
3423
+ };
3424
+ },
3425
+ muted: (deviceId, value) => {
3426
+ const muted = parseBool(value);
3427
+ return muted === null ? null : {
3428
+ kind: "media-mute",
3429
+ deviceId,
3430
+ muted
3431
+ };
3432
+ },
3433
+ shuffle: (deviceId, value) => {
3434
+ const shuffle = parseBool(value);
3435
+ return shuffle === null ? null : {
3436
+ kind: "media-shuffle",
3437
+ deviceId,
3438
+ shuffle
3439
+ };
3440
+ },
3441
+ repeat: (deviceId, value) => {
3442
+ const repeat = parseEnum(require_dist.MediaPlayerRepeatSchema, value);
3443
+ return repeat === null ? null : {
3444
+ kind: "media-repeat",
3445
+ deviceId,
3446
+ repeat
3447
+ };
3448
+ },
3449
+ source: (deviceId, value) => {
3450
+ const source = parseVocabulary(value);
3451
+ return source === null ? null : {
3452
+ kind: "media-source",
3453
+ deviceId,
3454
+ source
3455
+ };
3456
+ }
3457
+ },
3458
+ commands: {
3459
+ play: (deviceId) => ({
3460
+ kind: "media-verb",
3461
+ deviceId,
3462
+ verb: "play"
3463
+ }),
3464
+ pause: (deviceId) => ({
3465
+ kind: "media-verb",
3466
+ deviceId,
3467
+ verb: "pause"
3468
+ }),
3469
+ stop: (deviceId) => ({
3470
+ kind: "media-verb",
3471
+ deviceId,
3472
+ verb: "stop"
3473
+ }),
3474
+ next: (deviceId) => ({
3475
+ kind: "media-verb",
3476
+ deviceId,
3477
+ verb: "next"
3478
+ }),
3479
+ previous: (deviceId) => ({
3480
+ kind: "media-verb",
3481
+ deviceId,
3482
+ verb: "previous"
3483
+ })
3484
+ }
3485
+ },
3486
+ color: { extras: { mireds: (deviceId, value) => {
3487
+ const mireds = parseBounded(value, MIRED_MIN, MIRED_MAX);
3488
+ if (mireds === null || !Number.isInteger(mireds)) return null;
3489
+ return {
3490
+ kind: "color-mireds",
3491
+ deviceId,
3492
+ mireds
3493
+ };
3494
+ } } },
3495
+ "script-runner": { commands: {
3496
+ run: (deviceId) => ({
3497
+ kind: "script-verb",
3498
+ deviceId,
3499
+ verb: "run"
3500
+ }),
3501
+ stop: (deviceId) => ({
3502
+ kind: "script-verb",
3503
+ deviceId,
3504
+ verb: "stop"
3505
+ })
3506
+ } },
3507
+ "automation-control": {
3508
+ primary: (deviceId, value) => {
3509
+ const enabled = parseBool(value);
3510
+ return enabled === null ? null : {
3511
+ kind: "automation-enabled",
3512
+ deviceId,
3513
+ enabled
3514
+ };
3515
+ },
3516
+ commands: { trigger: (deviceId) => ({
3517
+ kind: "automation-trigger",
3518
+ deviceId
3519
+ }) }
3520
+ }
3521
+ };
3522
+ /**
3523
+ * The bounds the parsers enforce, kept HERE rather than imported from the
3524
+ * catalog's `TEMPERATURE_RANGE` / `MIRED_RANGE`: the catalog's ranges are
3525
+ * what Home Assistant is TOLD, and these are what the hub ACCEPTS. They
3526
+ * agree today, and the catalog spec asserts they still do — but the
3527
+ * assertion has to be able to fail, which it cannot if both read one
3528
+ * constant.
3529
+ */
3530
+ var TEMPERATURE_MIN = -20;
3531
+ var TEMPERATURE_MAX = 90;
3532
+ var MIRED_MIN = 50;
3533
+ var MIRED_MAX = 1e3;
812
3534
  function resolveCommand(target, entity, value) {
813
3535
  if (target.type === "camera") {
814
3536
  const switchId = CAMERA_SWITCH_BY_SLUG[entity];
815
3537
  if (switchId !== void 0) {
816
3538
  const enabled = parseBool(value);
817
- if (enabled === null) return null;
3539
+ if (enabled === null) return {
3540
+ ok: false,
3541
+ reason: "bad-value"
3542
+ };
818
3543
  return {
819
- kind: "camera-switch",
820
- deviceId: target.deviceId,
821
- switchId,
822
- enabled
3544
+ ok: true,
3545
+ command: {
3546
+ kind: "camera-switch",
3547
+ deviceId: target.deviceId,
3548
+ switchId,
3549
+ enabled
3550
+ }
823
3551
  };
824
3552
  }
825
3553
  if (entity === "reboot") return {
826
- kind: "reboot",
827
- deviceId: target.deviceId
3554
+ ok: true,
3555
+ command: {
3556
+ kind: "reboot",
3557
+ deviceId: target.deviceId
3558
+ }
828
3559
  };
829
3560
  const direction = PTZ_DIRECTION_BY_ENTITY[entity];
830
3561
  if (direction !== void 0) {
831
- if (!target.boundCaps.includes("ptz")) return null;
3562
+ if (!target.boundCaps.includes("ptz")) return {
3563
+ ok: false,
3564
+ reason: "cap-not-bound",
3565
+ capName: "ptz"
3566
+ };
832
3567
  return {
833
- kind: "ptz-move",
834
- deviceId: target.deviceId,
835
- direction
3568
+ ok: true,
3569
+ command: {
3570
+ kind: "ptz-move",
3571
+ deviceId: target.deviceId,
3572
+ direction
3573
+ }
836
3574
  };
837
3575
  }
838
3576
  if (entity === "ptz_preset") {
839
- if (!target.boundCaps.includes("ptz")) return null;
840
- if (value.trim().length === 0) return null;
3577
+ if (!target.boundCaps.includes("ptz")) return {
3578
+ ok: false,
3579
+ reason: "cap-not-bound",
3580
+ capName: "ptz"
3581
+ };
3582
+ if (value.trim().length === 0) return {
3583
+ ok: false,
3584
+ reason: "bad-value",
3585
+ capName: "ptz"
3586
+ };
841
3587
  return {
842
- kind: "ptz-preset",
843
- deviceId: target.deviceId,
844
- preset: value
3588
+ ok: true,
3589
+ command: {
3590
+ kind: "ptz-preset",
3591
+ deviceId: target.deviceId,
3592
+ preset: value
3593
+ }
845
3594
  };
846
3595
  }
847
3596
  if (entity === "snooze") {
848
- if (!SNOOZE_OPTIONS.includes(value)) return null;
3597
+ if (!SNOOZE_OPTIONS.includes(value)) return {
3598
+ ok: false,
3599
+ reason: "bad-value"
3600
+ };
849
3601
  const minutes = SNOOZE_MINUTES[value];
850
3602
  return {
851
- kind: "snooze",
852
- deviceId: target.deviceId,
853
- minutes: minutes ?? null
3603
+ ok: true,
3604
+ command: {
3605
+ kind: "snooze",
3606
+ deviceId: target.deviceId,
3607
+ minutes: minutes ?? null
3608
+ }
854
3609
  };
855
3610
  }
856
- return null;
3611
+ return {
3612
+ ok: false,
3613
+ reason: "unknown-entity"
3614
+ };
857
3615
  }
858
3616
  /**
859
- * The derived half. The entity slug is the capability name, so the
860
- * mapping table decides both the platform and whether it is writable —
861
- * there is no separate list to fall out of sync with the catalog.
3617
+ * The derived half.
3618
+ *
3619
+ * One capability can produce several entities a cover is a state, two
3620
+ * positions and three verbs — so the match is against every id the CATALOG
3621
+ * builds for the cap, through the same two expressions it used
3622
+ * (`derivedEntityId` / `derivedExtraEntityId`). Anything derived from the
3623
+ * catalog rather than re-spelled here cannot address an entity that does
3624
+ * not exist, which is the failure the shared expressions were extracted to
3625
+ * end.
862
3626
  */
863
- for (const [capName, mapping] of Object.entries(CAP_ENTITY_MAP)) {
864
- if (toSlug(capName) !== entity) continue;
865
- if (mapping.writable !== true) return null;
866
- if (!target.boundCaps.includes(capName)) return null;
867
- if (mapping.platform === "button") return {
868
- kind: "cap-button",
869
- deviceId: target.deviceId,
870
- capName
871
- };
872
- const on = parseBool(value);
873
- if (on === null) return null;
3627
+ for (const capName of Object.keys(CAP_ENTITY_MAP)) {
3628
+ const route = CAP_COMMAND_ROUTES[capName];
3629
+ if (derivedEntityId(capName) === entity) {
3630
+ if (CAP_ENTITY_MAP[capName]?.writable !== true) return {
3631
+ ok: false,
3632
+ reason: "not-writable",
3633
+ capName
3634
+ };
3635
+ if (!target.boundCaps.includes(capName)) return {
3636
+ ok: false,
3637
+ reason: "cap-not-bound",
3638
+ capName
3639
+ };
3640
+ return build(route?.primary, target.deviceId, value, capName);
3641
+ }
3642
+ for (const [suffix, extra] of Object.entries(CAP_ENTITY_MAP[capName]?.extras ?? {})) {
3643
+ if (derivedExtraEntityId(capName, suffix) !== entity) continue;
3644
+ if (extra.writable !== true) return {
3645
+ ok: false,
3646
+ reason: "not-writable",
3647
+ capName
3648
+ };
3649
+ if (!target.boundCaps.includes(capName)) return {
3650
+ ok: false,
3651
+ reason: "cap-not-bound",
3652
+ capName
3653
+ };
3654
+ return build(route?.extras?.[suffix], target.deviceId, value, capName);
3655
+ }
3656
+ /**
3657
+ * Command buttons. A button carries no state, so there is no `writable`
3658
+ * to consult — its existence in the catalog IS the declaration, and the
3659
+ * route table is the other half. The payload (`PRESS`) is ignored on
3660
+ * purpose: HA sends whatever `payload_press` says and the verb is
3661
+ * already in the topic.
3662
+ */
3663
+ for (const ref of capCommandEntities(capName)) {
3664
+ if (ref.entity !== entity) continue;
3665
+ if (!target.boundCaps.includes(capName)) return {
3666
+ ok: false,
3667
+ reason: "cap-not-bound",
3668
+ capName
3669
+ };
3670
+ return build(route?.commands?.[ref.suffix], target.deviceId, value, capName);
3671
+ }
3672
+ }
3673
+ return {
3674
+ ok: false,
3675
+ reason: "unknown-entity"
3676
+ };
3677
+ }
3678
+ /** Run a builder, or name the refusal. The one place `no-route` is decided. */
3679
+ function build(builder, deviceId, value, capName) {
3680
+ if (builder === void 0) return {
3681
+ ok: false,
3682
+ reason: "no-route",
3683
+ capName
3684
+ };
3685
+ const command = builder(deviceId, value);
3686
+ if (command === null) return {
3687
+ ok: false,
3688
+ reason: "bad-value",
3689
+ capName
3690
+ };
3691
+ return {
3692
+ ok: true,
3693
+ command
3694
+ };
3695
+ }
3696
+ /**
3697
+ * The camera half, listed rather than the capability half.
3698
+ *
3699
+ * The capability half is the one that grows — every Tier A row adds a
3700
+ * variant — and a narrowing written the other way round would silently send
3701
+ * each new one down the camera switch below. There are five camera
3702
+ * projections and there have been for the life of this file.
3703
+ */
3704
+ function isCameraCommand(command) {
3705
+ switch (command.kind) {
3706
+ case "camera-switch":
3707
+ case "reboot":
3708
+ case "ptz-move":
3709
+ case "ptz-preset":
3710
+ case "snooze": return true;
3711
+ default: return false;
3712
+ }
3713
+ }
3714
+ /** Narrows an `ExportCommand` to the half {@link applyCapCommand} can make. */
3715
+ function isCapCommand(command) {
3716
+ return !isCameraCommand(command);
3717
+ }
3718
+ /**
3719
+ * Make the call the command names.
3720
+ *
3721
+ * Every branch names ONE method on ONE cap. Nothing here falls back to a
3722
+ * neighbouring cap: a device that declares `lock-control` but serves no
3723
+ * provider for it is refused with `no-provider`, and the caller logs the
3724
+ * drop rather than turning a lock into a switch.
3725
+ */
3726
+ async function applyCapCommand(device, command) {
3727
+ switch (command.kind) {
3728
+ case "cap-switch":
3729
+ if (device.switch === void 0) return {
3730
+ ok: false,
3731
+ reason: "no-provider",
3732
+ capName: command.capName
3733
+ };
3734
+ await device.switch.setState({ on: command.on });
3735
+ return { ok: true };
3736
+ case "cap-lock":
3737
+ if (device.lockControl === void 0) return {
3738
+ ok: false,
3739
+ reason: "no-provider",
3740
+ capName: "lock-control"
3741
+ };
3742
+ if (command.locked) await device.lockControl.lock({});
3743
+ else await device.lockControl.unlock({});
3744
+ return { ok: true };
3745
+ case "cap-lock-open":
3746
+ if (device.lockControl === void 0) return {
3747
+ ok: false,
3748
+ reason: "no-provider",
3749
+ capName: "lock-control"
3750
+ };
3751
+ /**
3752
+ * A lock without a latch REFUSES this, and the refusal is the answer.
3753
+ * `lock-control` declares `open` unconditionally while the latch is a
3754
+ * property of the hardware, so the provider is the only thing that
3755
+ * knows — reporting success over its refusal would leave Home
3756
+ * Assistant showing a door that opened and did not.
3757
+ */
3758
+ try {
3759
+ await device.lockControl.open({});
3760
+ } catch (err) {
3761
+ return {
3762
+ ok: false,
3763
+ reason: "refused",
3764
+ capName: "lock-control",
3765
+ error: err instanceof Error ? err.message : String(err)
3766
+ };
3767
+ }
3768
+ return { ok: true };
3769
+ case "cap-brightness":
3770
+ if (device.brightness === void 0) return {
3771
+ ok: false,
3772
+ reason: "no-provider",
3773
+ capName: "brightness"
3774
+ };
3775
+ await device.brightness.setBrightness({ percentage: command.percentage });
3776
+ return { ok: true };
3777
+ case "cap-alarm":
3778
+ if (device.alarmPanel === void 0) return {
3779
+ ok: false,
3780
+ reason: "no-provider",
3781
+ capName: "alarm-panel"
3782
+ };
3783
+ /**
3784
+ * **The arm can be REFUSED.** CamStack's own panel throws
3785
+ * `NcAlarmArmRefusedError` when a contact the mode covers is open,
3786
+ * and that refusal is the operator's answer — reporting success
3787
+ * over it would leave Home Assistant showing `armed_away` on a
3788
+ * house with an open door. It is caught HERE rather than left to
3789
+ * the caller's generic catch so the reason is named `refused` and
3790
+ * the panel's own message survives into the log.
3791
+ */
3792
+ try {
3793
+ switch (command.action.kind) {
3794
+ case "arm":
3795
+ await device.alarmPanel.arm({ mode: command.action.mode });
3796
+ break;
3797
+ case "disarm":
3798
+ await device.alarmPanel.disarm({});
3799
+ break;
3800
+ case "trigger":
3801
+ await device.alarmPanel.trigger({});
3802
+ break;
3803
+ }
3804
+ } catch (err) {
3805
+ return {
3806
+ ok: false,
3807
+ reason: "refused",
3808
+ capName: "alarm-panel",
3809
+ error: err instanceof Error ? err.message : String(err)
3810
+ };
3811
+ }
3812
+ return { ok: true };
3813
+ case "cap-button":
3814
+ if (device.button === void 0) return {
3815
+ ok: false,
3816
+ reason: "no-provider",
3817
+ capName: command.capName
3818
+ };
3819
+ await device.button.press({});
3820
+ return { ok: true };
3821
+ case "cover-position": return call(device.cover, "cover", (cover) => cover.setPosition({ position: command.position }));
3822
+ case "cover-tilt": return call(device.cover, "cover", (cover) => cover.setTiltPosition({ tiltPosition: command.tiltPosition }));
3823
+ case "cover-verb": return call(device.cover, "cover", (cover) => {
3824
+ switch (command.verb) {
3825
+ case "open": return cover.open({});
3826
+ case "close": return cover.close({});
3827
+ case "stop": return cover.stop({});
3828
+ }
3829
+ });
3830
+ case "climate-mode": return call(device.climateControl, "climate-control", (climate) => climate.setMode({ mode: command.mode }));
3831
+ case "climate-target": return call(device.climateControl, "climate-control", (climate) => climate.setTarget({ target: command.target }));
3832
+ case "climate-fan-mode": return call(device.climateControl, "climate-control", (climate) => climate.setFanMode({ fanMode: command.fanMode }));
3833
+ case "climate-preset": return call(device.climateControl, "climate-control", (climate) => climate.setPreset({ preset: command.preset }));
3834
+ /**
3835
+ * The one read-then-write in this file, and the reason is the cap's
3836
+ * signature rather than a preference: `setTargetRange` takes both edges
3837
+ * and Home Assistant sends them one at a time. The OTHER edge is read
3838
+ * from `getStatus` — the provider's own current answer — and a `null`
3839
+ * there is REFUSED, because a dual-setpoint device that reports no range
3840
+ * has no range to move one edge of, and substituting the single `target`
3841
+ * would silently collapse the band to a point.
3842
+ */
3843
+ case "climate-target-edge": return call(device.climateControl, "climate-control", async (climate) => {
3844
+ const status = await climate.getStatus({});
3845
+ if (status === null) throw new Error("the device reports no climate status, so setTargetRange has no range");
3846
+ const low = command.edge === "low" ? command.value : status.targetLow;
3847
+ const high = command.edge === "high" ? command.value : status.targetHigh;
3848
+ if (low === null || high === null) throw new Error(`the device reports no ${command.edge === "low" ? "high" : "low"} setpoint, so setTargetRange has nothing to preserve`);
3849
+ await climate.setTargetRange({
3850
+ targetLow: low,
3851
+ targetHigh: high
3852
+ });
3853
+ });
3854
+ case "climate-target-humidity": return call(device.climateControl, "climate-control", (climate) => climate.setTargetHumidity({ targetHumidity: command.targetHumidity }));
3855
+ case "climate-swing": return call(device.climateControl, "climate-control", (climate) => command.axis === "vertical" ? climate.setSwingVertical({ on: command.on }) : climate.setSwingHorizontal({ on: command.on }));
3856
+ case "fan-percentage": return call(device.fanControl, "fan-control", (fan) => fan.setPercentage({ percentage: command.percentage }));
3857
+ case "fan-preset": return call(device.fanControl, "fan-control", (fan) => fan.setPreset({ preset: command.preset }));
3858
+ case "fan-oscillating": return call(device.fanControl, "fan-control", (fan) => fan.setOscillating({ oscillating: command.oscillating }));
3859
+ case "fan-direction": return call(device.fanControl, "fan-control", (fan) => fan.setDirection({ direction: command.direction }));
3860
+ case "humidifier-on": return call(device.humidifier, "humidifier", (humidifier) => humidifier.setOn({ on: command.on }));
3861
+ case "humidifier-humidity": return call(device.humidifier, "humidifier", (humidifier) => humidifier.setTargetHumidity({ humidity: command.humidity }));
3862
+ case "humidifier-mode": return call(device.humidifier, "humidifier", (humidifier) => humidifier.setMode({ mode: command.mode }));
3863
+ case "water-heater-temp": return call(device.waterHeater, "water-heater", (heater) => heater.setTargetTemp({ temp: command.temp }));
3864
+ case "water-heater-away": return call(device.waterHeater, "water-heater", (heater) => heater.setAway({ on: command.on }));
3865
+ case "water-heater-mode": return call(device.waterHeater, "water-heater", (heater) => heater.setOperationMode({ mode: command.mode }));
3866
+ case "valve-position": return call(device.valve, "valve", (valve) => valve.setPosition({ position: command.position }));
3867
+ case "valve-verb": return call(device.valve, "valve", (valve) => {
3868
+ switch (command.verb) {
3869
+ case "open": return valve.open({});
3870
+ case "close": return valve.close({});
3871
+ case "stop": return valve.stop({});
3872
+ }
3873
+ });
3874
+ case "vacuum-verb": return call(device.vacuumControl, "vacuum-control", (vacuum) => {
3875
+ switch (command.verb) {
3876
+ case "start": return vacuum.start({});
3877
+ case "pause": return vacuum.pause({});
3878
+ case "stop": return vacuum.stop({});
3879
+ case "return-to-base": return vacuum.returnToBase({});
3880
+ case "locate": return vacuum.locate({});
3881
+ }
3882
+ });
3883
+ case "vacuum-fan-speed": return call(device.vacuumControl, "vacuum-control", (vacuum) => vacuum.setFanSpeed({ speed: command.speed }));
3884
+ case "mower-verb": return call(device.lawnMowerControl, "lawn-mower-control", (mower) => {
3885
+ switch (command.verb) {
3886
+ case "start": return mower.startMowing({});
3887
+ case "pause": return mower.pause({});
3888
+ case "dock": return mower.dock({});
3889
+ }
3890
+ });
3891
+ case "media-verb": return call(device.mediaPlayer, "media-player", (media) => {
3892
+ switch (command.verb) {
3893
+ case "play": return media.play({});
3894
+ case "pause": return media.pause({});
3895
+ case "stop": return media.stop({});
3896
+ case "next": return media.next({});
3897
+ case "previous": return media.previous({});
3898
+ }
3899
+ });
3900
+ case "media-volume": return call(device.mediaPlayer, "media-player", (media) => media.setVolume({ volumeLevel: command.volumeLevel }));
3901
+ case "media-mute": return call(device.mediaPlayer, "media-player", (media) => media.setMute({ muted: command.muted }));
3902
+ case "media-shuffle": return call(device.mediaPlayer, "media-player", (media) => media.setShuffle({ shuffle: command.shuffle }));
3903
+ case "media-repeat": return call(device.mediaPlayer, "media-player", (media) => media.setRepeat({ repeat: command.repeat }));
3904
+ case "media-source": return call(device.mediaPlayer, "media-player", (media) => media.selectSource({ source: command.source }));
3905
+ case "color-mireds": return call(device.color, "color", (color) => color.setColor({ color: {
3906
+ mode: "mired",
3907
+ mireds: command.mireds
3908
+ } }));
3909
+ case "script-verb": return call(device.scriptRunner, "script-runner", (script) => command.verb === "run" ? script.run({}) : script.stop({}));
3910
+ case "automation-enabled": return call(device.automationControl, "automation-control", (automation) => command.enabled ? automation.enable({}) : automation.disable({}));
3911
+ case "automation-trigger": return call(device.automationControl, "automation-control", (automation) => automation.trigger({}));
3912
+ }
3913
+ }
3914
+ /**
3915
+ * One capability call, with the two outcomes that are not success.
3916
+ *
3917
+ * `no-provider` is a device that DECLARES the cap and serves nothing —
3918
+ * distinct from a throw, which is the device REFUSING. The distinction is the
3919
+ * whole reason `alarm-panel` grew its own try/catch first: an arm the panel
3920
+ * turns down (`NcAlarmArmRefusedError`, a contact the mode covers is open) is
3921
+ * the operator's answer and must reach them with the panel's own words, not
3922
+ * as an optimistic success. Every Tier A method can refuse the same way — a
3923
+ * cover already moving, a vacuum with an empty tank, a climate mode the unit
3924
+ * does not have in `availableModes` — so the handling is here rather than
3925
+ * repeated per branch.
3926
+ */
3927
+ async function call(slice, capName, make) {
3928
+ if (slice === void 0) return {
3929
+ ok: false,
3930
+ reason: "no-provider",
3931
+ capName
3932
+ };
3933
+ try {
3934
+ await make(slice);
3935
+ } catch (err) {
874
3936
  return {
875
- kind: "cap-switch",
876
- deviceId: target.deviceId,
3937
+ ok: false,
3938
+ reason: "refused",
877
3939
  capName,
878
- on
3940
+ error: err instanceof Error ? err.message : String(err)
879
3941
  };
880
3942
  }
881
- return null;
3943
+ return { ok: true };
882
3944
  }
883
3945
  /**
884
3946
  * The ONE derivation of "a signed, expiring URL".
@@ -1363,6 +4425,110 @@ function bool(value) {
1363
4425
  return value ? "true" : "false";
1364
4426
  }
1365
4427
  /**
4428
+ * A value → the string Home Assistant's platform expects.
4429
+ *
4430
+ * Exhaustive over `HaPlatform` so a new platform cannot be added
4431
+ * without deciding what its values look like. Every value is a STRING:
4432
+ * the component lowercases binary values verbatim and a JSON number or
4433
+ * boolean raises inside its state callback, which it swallows — so the
4434
+ * entity silently never updates again.
4435
+ */
4436
+ function renderCapValue(mapping, raw) {
4437
+ switch (mapping.platform) {
4438
+ case "binary_sensor":
4439
+ case "switch": return typeof raw === "boolean" ? bool(raw) : null;
4440
+ case "number": return typeof raw === "number" && Number.isFinite(raw) ? String(raw) : null;
4441
+ case "sensor":
4442
+ if (mapping.deviceClass === "timestamp") {
4443
+ if (typeof raw === "number" && Number.isFinite(raw)) return iso(raw);
4444
+ return typeof raw === "string" ? raw : null;
4445
+ }
4446
+ if (typeof raw === "number") return Number.isFinite(raw) ? String(raw) : null;
4447
+ if (typeof raw === "string") return raw;
4448
+ return typeof raw === "boolean" ? bool(raw) : null;
4449
+ case "select":
4450
+ case "alarm_control_panel":
4451
+ case "image":
4452
+ case "camera": return typeof raw === "string" ? raw : null;
4453
+ case "button": return null;
4454
+ case "cover":
4455
+ case "climate":
4456
+ case "lock":
4457
+ case "fan":
4458
+ case "vacuum":
4459
+ case "valve":
4460
+ case "humidifier":
4461
+ case "water_heater":
4462
+ case "media_player":
4463
+ /**
4464
+ * A native platform carries no value of its own. It is built from
4465
+ * the topics of the DEGRADED descriptors — which are the ones this
4466
+ * function renders — so `CAP_ENTITY_MAP` never names one, and a
4467
+ * descriptor that did would be publishing to a topic no entity
4468
+ * reads. `null` here is `unrenderable`, which the addon LOGS: the
4469
+ * loud failure is the point.
4470
+ */
4471
+ return null;
4472
+ }
4473
+ }
4474
+ /**
4475
+ * One capability slice → the derived entity's value.
4476
+ *
4477
+ * **This is the push path every derived capability was missing.** The
4478
+ * addon used to handle four caps by hand (`device-status`, `battery`,
4479
+ * `doorbell`, `motion`) and the other 23 entries in `CAP_ENTITY_MAP` had
4480
+ * no push path at all — they moved only on the 300 s reconcile, and the
4481
+ * ones whose topics disagreed with the catalog never moved at all. This
4482
+ * is descriptor-driven, so a capability T3 adds to `CAP_ENTITY_MAP`
4483
+ * arrives with its push path already built.
4484
+ */
4485
+ function projectCapSlice(deviceKey, capName, slice) {
4486
+ const mapping = CAP_ENTITY_MAP[capName];
4487
+ if (mapping === void 0) return {
4488
+ kind: "no-entity",
4489
+ capName
4490
+ };
4491
+ const entity = derivedEntityId(capName);
4492
+ const values = [];
4493
+ /**
4494
+ * Every entity the capability produces, not just its primary. A `cover`
4495
+ * is a state AND a position, and projecting only the first would leave
4496
+ * the second in Home Assistant's registry with nothing arriving on it —
4497
+ * which is precisely the defect the descriptor table replaced.
4498
+ */
4499
+ for (const ref of capEntities(capName)) {
4500
+ if (ref.descriptor.platform === "button") continue;
4501
+ if (!Object.prototype.hasOwnProperty.call(slice, ref.descriptor.field)) return {
4502
+ kind: "missing-field",
4503
+ capName,
4504
+ field: ref.descriptor.field,
4505
+ sliceFields: Object.keys(slice)
4506
+ };
4507
+ const raw = slice[ref.descriptor.field];
4508
+ if (raw === null || raw === void 0) continue;
4509
+ const rendered = renderCapValue(ref.descriptor, deriveCapValue(ref.descriptor.derive, raw));
4510
+ if (rendered === null) return {
4511
+ kind: "unrenderable",
4512
+ capName,
4513
+ field: ref.descriptor.field,
4514
+ valueType: typeof raw
4515
+ };
4516
+ values.push({
4517
+ topic: stateTopic(deviceKey, ref.entity),
4518
+ value: rendered
4519
+ });
4520
+ }
4521
+ if (mapping.platform === "button" && mapping.extras === void 0) return {
4522
+ kind: "no-entity",
4523
+ capName
4524
+ };
4525
+ return {
4526
+ kind: "values",
4527
+ entity,
4528
+ values
4529
+ };
4530
+ }
4531
+ /**
1366
4532
  * Reachability.
1367
4533
  *
1368
4534
  * `device.sleeping` deliberately does NOT feed this: a battery camera in
@@ -1889,6 +5055,22 @@ function projectSynthetic(input, nowMs) {
1889
5055
  }
1890
5056
  return values;
1891
5057
  }
5058
+ //#endregion
5059
+ //#region src/ha-export/reconcile-fingerprint.ts
5060
+ function structureFingerprint(exported) {
5061
+ return JSON.stringify([...exported.keys()].sort().map((key) => {
5062
+ const entry = exported.get(key);
5063
+ return [
5064
+ key,
5065
+ entry?.brokerIds,
5066
+ entry?.plan
5067
+ ];
5068
+ }));
5069
+ }
5070
+ /** Only an event-debounced pass with an unchanged structure may skip. */
5071
+ function shouldSkipAnnounce(reason, fingerprint, lastFingerprint) {
5072
+ return reason === "structure-changed" && fingerprint === lastFingerprint;
5073
+ }
1892
5074
  /** The `BrokerInfo.kind` tag the Home Assistant provider stamps. */
1893
5075
  var HA_BROKER_KIND = "home-assistant";
1894
5076
  /**
@@ -1975,6 +5157,17 @@ var HaExportAddon = class extends require_dist.BaseAddon {
1975
5157
  /** A reason asked for while one was running. Re-run, never dropped. */
1976
5158
  reconcilePending = null;
1977
5159
  lastError;
5160
+ /**
5161
+ * Fingerprint of the last ANNOUNCED structure. An event-debounced
5162
+ * reconcile that rebuilds an identical structure skips the announce and
5163
+ * the full state push: a flapping camera (RTSP retry every 4 s emits a
5164
+ * device event per attempt) was driving a full 2.7 s reconcile every
5165
+ * ~13 s, forever. Only the 'structure-changed' reason short-circuits —
5166
+ * 'boot', 'periodic' and config-driven passes always announce, so the
5167
+ * D8/D11 contract (events are an optimisation, never a substitute)
5168
+ * still holds through the periodic pass.
5169
+ */
5170
+ lastStructureFingerprint;
1978
5171
  entityCount = 0;
1979
5172
  /** deviceId → the `lastPressedAt` already rung, so a re-read does not ring again. */
1980
5173
  lastDoorbellPressAt = /* @__PURE__ */ new Map();
@@ -1982,6 +5175,15 @@ var HaExportAddon = class extends require_dist.BaseAddon {
1982
5175
  doorbellReleaseTimers = /* @__PURE__ */ new Map();
1983
5176
  unclassified = [];
1984
5177
  /**
5178
+ * What each Home Assistant's custom component can BUILD.
5179
+ *
5180
+ * The hub and the component ship on different trains — the operator
5181
+ * updates the integration through HACS when they decide to — so a
5182
+ * platform this hub knows about is not one the component has. Refreshed
5183
+ * at the top of every reconcile, before any plan is built.
5184
+ */
5185
+ componentSupport = new ComponentSupportTracker();
5186
+ /**
1985
5187
  * The rules as of the last reconcile, so a command arriving on
1986
5188
  * `rule_<slug>` can be turned back into the rule id it came from. Refreshed
1987
5189
  * whenever the synthetic devices are, never written to.
@@ -2116,6 +5318,19 @@ var HaExportAddon = class extends require_dist.BaseAddon {
2116
5318
  this.reconcileSoon();
2117
5319
  });
2118
5320
  }
5321
+ /**
5322
+ * A slice changed → the entities that read it.
5323
+ *
5324
+ * Two halves, exactly as the catalog has two halves. `device-status`
5325
+ * and `battery` are projected by hand for BOTH kinds — their topics are
5326
+ * the same on a camera and on a base device, and `battery` deliberately
5327
+ * publishes nothing when the device reports a low-battery INDICATOR
5328
+ * rather than a level, which a descriptor cannot express. Everything
5329
+ * else on a camera is a projection of pipeline output, and everything
5330
+ * else on a base device is derived from `CAP_ENTITY_MAP` — the same
5331
+ * branch `buildDevicePlan` takes, so the value can only land on an
5332
+ * entity the plan actually built.
5333
+ */
2119
5334
  onSliceChanged(data) {
2120
5335
  const deviceId = readNumber(data, "deviceId");
2121
5336
  const capName = readString(data, "capName");
@@ -2123,6 +5338,19 @@ var HaExportAddon = class extends require_dist.BaseAddon {
2123
5338
  if (deviceId === null || capName === null || slice === null) return;
2124
5339
  const deviceKey = this.keyByDeviceId.get(deviceId);
2125
5340
  if (deviceKey === void 0) return;
5341
+ if (capName !== "device-status" && capName !== "battery" && !this.isCamera(deviceKey)) {
5342
+ this.pushDerivedCap(deviceId, deviceKey, capName, slice);
5343
+ return;
5344
+ }
5345
+ /**
5346
+ * The two capabilities a CAMERA carries from the derived half. Same
5347
+ * descriptor, same entity id, same projector — the camera catalog
5348
+ * simply builds the component instead of `buildDerivedPlan`.
5349
+ */
5350
+ if (CAMERA_DERIVED_CAPS.includes(capName)) {
5351
+ this.pushDerivedCap(deviceId, deviceKey, capName, slice);
5352
+ return;
5353
+ }
2126
5354
  if (capName === "device-status") {
2127
5355
  const online = slice["online"];
2128
5356
  if (typeof online === "boolean") this.push(deviceId, projectDeviceStatus(deviceKey, online));
@@ -2186,6 +5414,61 @@ var HaExportAddon = class extends require_dist.BaseAddon {
2186
5414
  }));
2187
5415
  }
2188
5416
  }
5417
+ /** Camera or base kind — the same question `buildDevicePlan` asks. */
5418
+ isCamera(deviceKey) {
5419
+ return this.exported.get(deviceKey)?.target.type === "camera";
5420
+ }
5421
+ /**
5422
+ * The generic derived push: any capability in `CAP_ENTITY_MAP`.
5423
+ *
5424
+ * Every branch that produces no value SAYS so, once per
5425
+ * (device, cap, field): an entity that exists and never receives a
5426
+ * value used to be indistinguishable from an entity nothing had
5427
+ * happened to, and that is how 23 of the 27 mapped capabilities went
5428
+ * unnoticed with no push path at all. Once, because a slice that
5429
+ * changes every second must not turn a catalog defect into a log
5430
+ * flood — the first line already carries everything the fix needs.
5431
+ */
5432
+ pushDerivedCap(deviceId, deviceKey, capName, slice) {
5433
+ const projection = projectCapSlice(deviceKey, capName, slice);
5434
+ switch (projection.kind) {
5435
+ case "values":
5436
+ if (projection.values.length > 0) this.push(deviceId, projection.values);
5437
+ return;
5438
+ case "no-entity": return;
5439
+ case "missing-field":
5440
+ this.warnOnce(deviceId, capName, projection.field, {
5441
+ message: "ha-export: a derived entity reads a field its slice does not carry",
5442
+ meta: {
5443
+ capName,
5444
+ field: projection.field,
5445
+ sliceFields: projection.sliceFields
5446
+ }
5447
+ });
5448
+ return;
5449
+ case "unrenderable":
5450
+ this.warnOnce(deviceId, capName, projection.field, {
5451
+ message: "ha-export: a derived entity cannot render the value its slice carries",
5452
+ meta: {
5453
+ capName,
5454
+ field: projection.field,
5455
+ valueType: projection.valueType
5456
+ }
5457
+ });
5458
+ return;
5459
+ }
5460
+ }
5461
+ /** deviceId:cap:field already reported. Bounded by devices × mapped caps. */
5462
+ warnedCapFields = /* @__PURE__ */ new Set();
5463
+ warnOnce(deviceId, capName, field, line) {
5464
+ const key = `${deviceId}:${capName}:${field}`;
5465
+ if (this.warnedCapFields.has(key)) return;
5466
+ this.warnedCapFields.add(key);
5467
+ this.ctx.logger.warn(line.message, {
5468
+ tags: { deviceId },
5469
+ meta: line.meta
5470
+ });
5471
+ }
2189
5472
  onOnlineChanged(data, online) {
2190
5473
  const deviceId = readNumber(data, "deviceId");
2191
5474
  if (deviceId === null) return;
@@ -2199,6 +5482,17 @@ var HaExportAddon = class extends require_dist.BaseAddon {
2199
5482
  if (deviceId === null || typeof detected !== "boolean") return;
2200
5483
  const deviceKey = this.keyByDeviceId.get(deviceId);
2201
5484
  if (deviceKey === void 0) return;
5485
+ /**
5486
+ * A base device's motion entity is `motion`, a camera's is
5487
+ * `motion_detected` + `triggered`. The cap event is the same one, so
5488
+ * the projection has to follow the device's own half of the catalog —
5489
+ * publishing the camera topics for a PIR sensor addressed three
5490
+ * entities the sensor's plan never built.
5491
+ */
5492
+ if (!this.isCamera(deviceKey)) {
5493
+ this.pushDerivedCap(deviceId, deviceKey, "motion", { detected });
5494
+ return;
5495
+ }
2202
5496
  const timestamp = readNumber(data, "timestamp") ?? Date.now();
2203
5497
  this.push(deviceId, projectMotion(deviceKey, {
2204
5498
  detected,
@@ -2390,6 +5684,7 @@ var HaExportAddon = class extends require_dist.BaseAddon {
2390
5684
  try {
2391
5685
  await this.refreshBrokers();
2392
5686
  await this.refreshMediaBaseUrl();
5687
+ await this.refreshComponentSupport();
2393
5688
  const enabled = this.enabledBrokerIds();
2394
5689
  const devices = await this.ctx.api.deviceManager.listAll.query({});
2395
5690
  const snapshots = await this.ctx.api.deviceState.getAllSnapshots.query({});
@@ -2397,6 +5692,7 @@ var HaExportAddon = class extends require_dist.BaseAddon {
2397
5692
  const exported = /* @__PURE__ */ new Map();
2398
5693
  const keyByDeviceId = /* @__PURE__ */ new Map();
2399
5694
  const allCaps = /* @__PURE__ */ new Set();
5695
+ const exportedDeviceLog = [];
2400
5696
  let entityCount = 0;
2401
5697
  for (const deviceIdStr of exposedDeviceIds(this.config.membership, enabled)) {
2402
5698
  const numericId = Number(deviceIdStr);
@@ -2420,8 +5716,15 @@ var HaExportAddon = class extends require_dist.BaseAddon {
2420
5716
  }
2421
5717
  const catalogDevice = await this.buildCatalogDevice(device, snapshots[String(numericId)]);
2422
5718
  for (const cap of catalogDevice.boundCaps) allCaps.add(cap);
2423
- const plan = buildDevicePlan(catalogDevice);
2424
5719
  const brokerIds = brokersExposing(this.config.membership, deviceIdStr).filter((id) => enabled.includes(id));
5720
+ /**
5721
+ * The plan is built ONCE and announced to every broker that
5722
+ * receives the device, so it can only use platforms ALL of them
5723
+ * build — see `intersectSupport`. One instance on an old component
5724
+ * therefore holds its device back and nobody's entities silently
5725
+ * fail to arrive.
5726
+ */
5727
+ const plan = buildDevicePlan(catalogDevice, { platforms: this.componentSupport.supportForAll(brokerIds) });
2425
5728
  exported.set(plan.deviceKey, {
2426
5729
  plan,
2427
5730
  deviceId: numericId,
@@ -2430,7 +5733,8 @@ var HaExportAddon = class extends require_dist.BaseAddon {
2430
5733
  type: device.type,
2431
5734
  boundCaps: catalogDevice.boundCaps
2432
5735
  },
2433
- brokerIds
5736
+ brokerIds,
5737
+ streams: catalogDevice.streams ?? []
2434
5738
  });
2435
5739
  keyByDeviceId.set(numericId, plan.deviceKey);
2436
5740
  entityCount += Object.keys(plan.cmps).length;
@@ -2439,16 +5743,16 @@ var HaExportAddon = class extends require_dist.BaseAddon {
2439
5743
  * device being DROPPED; without this one an operator can see why
2440
5744
  * a device is missing but cannot confirm which devices were
2441
5745
  * actually exported, to which Home Assistant, with which
2442
- * entities. Silence reads as "never happened" both ways.
5746
+ * entities. Silence reads as "never happened" both ways. Deferred
5747
+ * until the announce decision: a skipped identical pass must not
5748
+ * repeat the whole roster either.
2443
5749
  */
2444
- this.ctx.logger.info("ha-export: exporting device", {
2445
- tags: { deviceId: numericId },
2446
- meta: {
2447
- deviceType: device.type,
2448
- deviceKey: plan.deviceKey,
2449
- brokers: brokerIds,
2450
- entities: Object.keys(plan.cmps).length
2451
- }
5750
+ exportedDeviceLog.push({
5751
+ deviceId: numericId,
5752
+ deviceType: device.type,
5753
+ deviceKey: plan.deviceKey,
5754
+ brokers: brokerIds,
5755
+ entities: Object.keys(plan.cmps).length
2452
5756
  });
2453
5757
  }
2454
5758
  /**
@@ -2469,7 +5773,8 @@ var HaExportAddon = class extends require_dist.BaseAddon {
2469
5773
  type: "synthetic",
2470
5774
  boundCaps: []
2471
5775
  },
2472
- brokerIds: [...enabled]
5776
+ brokerIds: [...enabled],
5777
+ streams: []
2473
5778
  });
2474
5779
  entityCount += Object.keys(plan.cmps).length;
2475
5780
  }
@@ -2477,6 +5782,25 @@ var HaExportAddon = class extends require_dist.BaseAddon {
2477
5782
  this.keyByDeviceId = keyByDeviceId;
2478
5783
  this.entityCount = entityCount;
2479
5784
  this.unclassified = unclassifiedCaps([...allCaps]);
5785
+ const fingerprint = structureFingerprint(exported);
5786
+ if (shouldSkipAnnounce(reason, fingerprint, this.lastStructureFingerprint)) {
5787
+ this.ctx.logger.debug("ha-export: structure unchanged, skipping announce", { meta: {
5788
+ devices: exported.size,
5789
+ entities: entityCount
5790
+ } });
5791
+ this.lastError = void 0;
5792
+ return;
5793
+ }
5794
+ this.lastStructureFingerprint = fingerprint;
5795
+ for (const line of exportedDeviceLog) this.ctx.logger.info("ha-export: exporting device", {
5796
+ tags: { deviceId: line.deviceId },
5797
+ meta: {
5798
+ deviceType: line.deviceType,
5799
+ deviceKey: line.deviceKey,
5800
+ brokers: line.brokers,
5801
+ entities: line.entities
5802
+ }
5803
+ });
2480
5804
  await this.announce(exported);
2481
5805
  await this.pushFullState(exported, snapshots);
2482
5806
  if (synthetic !== null) await this.pushSynthetic(projectSynthetic(synthetic, Date.now()), [...enabled]);
@@ -2616,7 +5940,55 @@ var HaExportAddon = class extends require_dist.BaseAddon {
2616
5940
  }));
2617
5941
  }
2618
5942
  }
5943
+ /**
5944
+ * The derived half, from the snapshot the reconcile already read.
5945
+ *
5946
+ * The reconcile is the CONTRACT — events are an optimisation over it
5947
+ * (D8) — so every entity it announces must also get a value from it.
5948
+ * It used to push only `device-status` and `battery`, which is why a
5949
+ * derived entity whose event path was missing stayed blank for ever
5950
+ * rather than for one interval.
5951
+ */
5952
+ {
5953
+ /**
5954
+ * The derived half, and the two capabilities a CAMERA carries from
5955
+ * it (`CAMERA_DERIVED_CAPS`). A camera takes the hand-written
5956
+ * catalog, so every other cap slice on one addresses no component;
5957
+ * these two do, because the camera catalog builds them from the
5958
+ * same descriptor and the same entity id.
5959
+ */
5960
+ const isCamera = entry.target.type === "camera";
5961
+ for (const [capName, capSlice] of Object.entries(snapshot)) {
5962
+ if (capName === "device-status" || capName === "battery") continue;
5963
+ if (isCamera && !CAMERA_DERIVED_CAPS.includes(capName)) continue;
5964
+ const projection = projectCapSlice(key, capName, capSlice);
5965
+ if (projection.kind === "values") values.push(...projection.values);
5966
+ else if (projection.kind === "missing-field") this.warnOnce(entry.deviceId, capName, projection.field, {
5967
+ message: "ha-export: a derived entity reads a field its slice does not carry",
5968
+ meta: {
5969
+ capName,
5970
+ field: projection.field,
5971
+ sliceFields: projection.sliceFields
5972
+ }
5973
+ });
5974
+ else if (projection.kind === "unrenderable") this.warnOnce(entry.deviceId, capName, projection.field, {
5975
+ message: "ha-export: a derived entity cannot render the value its slice carries",
5976
+ meta: {
5977
+ capName,
5978
+ field: projection.field,
5979
+ valueType: projection.valueType
5980
+ }
5981
+ });
5982
+ }
5983
+ }
2619
5984
  if (entry.target.type === "camera") {
5985
+ /**
5986
+ * The live picture publishes NOTHING here, deliberately. A `camera`
5987
+ * entity's whole content is its `stream_target`, which travelled with
5988
+ * the announce; the component fetches the still and negotiates the
5989
+ * video itself. The url this block used to publish was read by
5990
+ * nobody — see `camera-entities.ts`.
5991
+ */
2620
5992
  const group = await this.ctx.api.pipelineOrchestrator.getCameraSwitches.query({ deviceId: entry.deviceId });
2621
5993
  values.push(...projectCameraSwitches(key, group.switches.map((sw) => ({
2622
5994
  id: sw.id,
@@ -2645,6 +6017,7 @@ var HaExportAddon = class extends require_dist.BaseAddon {
2645
6017
  const zones = device.type === "camera" ? await this.loadZones(device.id) : [];
2646
6018
  const switches = device.type === "camera" ? await this.loadSwitches(device.id) : [];
2647
6019
  const ptzPresets = device.type === "camera" && boundCaps.includes("ptz") ? await this.loadPresets(device.id) : [];
6020
+ const streams = device.type === "camera" && boundCaps.includes("webrtc-session") ? await this.loadStreamChoices(device.id) : [];
2648
6021
  const manufacturer = readString(device.metadata ?? {}, "manufacturer");
2649
6022
  const model = readString(device.metadata ?? {}, "model");
2650
6023
  return {
@@ -2659,9 +6032,48 @@ var HaExportAddon = class extends require_dist.BaseAddon {
2659
6032
  zones,
2660
6033
  switches,
2661
6034
  ptzPresets,
2662
- slices: Object.keys(snapshot)
6035
+ streams,
6036
+ slices: Object.keys(snapshot),
6037
+ /**
6038
+ * The slice VALUES, for the native platforms only: a native
6039
+ * `climate` announces the modes the device accepts, and
6040
+ * `availableModes` is on the slice and nowhere else. See
6041
+ * `CatalogDevice.capSlices`.
6042
+ */
6043
+ capSlices: snapshot
2663
6044
  };
2664
6045
  }
6046
+ /**
6047
+ * Every stream of the camera, as the hub's own picker lists them.
6048
+ *
6049
+ * `webrtcSession.listStreams` rather than
6050
+ * `cameraStreams.getProfileRtspEntries`: the component negotiates live
6051
+ * video through `webrtcSession.handleOffer` and needs the `target`, not
6052
+ * an address. Reading the RTSP entries instead cost this export three
6053
+ * things it no longer pays — a `hostname` argument (the broker binds
6054
+ * `127.0.0.1`, so a camera on a hub with no reachable base url got NO
6055
+ * camera entity at all), a refusal for a credentialed url, and a whole
6056
+ * value plane nothing consumed.
6057
+ *
6058
+ * `listStreams` also reports the camera's raw substreams, which the
6059
+ * profile view could not express.
6060
+ */
6061
+ async loadStreamChoices(deviceId) {
6062
+ try {
6063
+ return (await this.ctx.api.webrtcSession.listStreams.query({ deviceId })).map((choice) => ({
6064
+ id: choice.id,
6065
+ label: choice.label,
6066
+ target: choice.target,
6067
+ ...choice.resolution === null ? {} : { height: choice.resolution.height }
6068
+ }));
6069
+ } catch (err) {
6070
+ this.ctx.logger.warn("ha-export: could not read the camera streams, no camera entity", {
6071
+ tags: { deviceId },
6072
+ meta: { error: errMsg(err) }
6073
+ });
6074
+ return [];
6075
+ }
6076
+ }
2665
6077
  async loadBoundCaps(deviceId) {
2666
6078
  try {
2667
6079
  return (await this.ctx.api.deviceManager.getBindings.query({ deviceId })).entries.map((entry) => entry.capName);
@@ -2769,6 +6181,7 @@ var HaExportAddon = class extends require_dist.BaseAddon {
2769
6181
  if (wanted.has(brokerId)) continue;
2770
6182
  await link.client.dispose();
2771
6183
  this.links.delete(brokerId);
6184
+ this.componentSupport.forget(brokerId);
2772
6185
  this.ctx.logger.info("ha-export: stopped exporting to a broker", { meta: { brokerId } });
2773
6186
  }
2774
6187
  for (const broker of haBrokers) {
@@ -2796,6 +6209,75 @@ var HaExportAddon = class extends require_dist.BaseAddon {
2796
6209
  } });
2797
6210
  }
2798
6211
  }
6212
+ /**
6213
+ * Ask every live link what its component builds.
6214
+ *
6215
+ * Sequential and cheap — one GET per Home Assistant per reconcile, and
6216
+ * there is one Home Assistant on almost every installation. A link that
6217
+ * does not answer keeps whatever was negotiated last: the tracker treats
6218
+ * silence as no information, because the alternative is that one
6219
+ * timeout migrates every native entity back to a sensor.
6220
+ */
6221
+ async refreshComponentSupport() {
6222
+ for (const [brokerId, link] of this.links) {
6223
+ const probe = await this.probeComponent(link);
6224
+ const change = this.componentSupport.observe(brokerId, probe);
6225
+ if (change.withheldDowngrade) {
6226
+ /**
6227
+ * D49. The read that DESTROYS work needs a second read to agree,
6228
+ * and the operator gets to see that this is what happened rather
6229
+ * than watching their entities migrate on one bad answer.
6230
+ */
6231
+ this.ctx.logger.warn("ha-export: the Home Assistant component reports fewer platforms than before — holding the old set until a second read agrees", { meta: {
6232
+ brokerId,
6233
+ current: supportSignature(change.support),
6234
+ reported: probe.kind === "reported" ? supportSignature(probe.report.platforms) : "none"
6235
+ } });
6236
+ continue;
6237
+ }
6238
+ if (!change.changed) continue;
6239
+ this.ctx.logger.info("ha-export: negotiated the Home Assistant component platform set", { meta: {
6240
+ brokerId,
6241
+ componentVersion: probe.kind === "reported" ? probe.report.version ?? "unknown" : "pre-0.4.0",
6242
+ platforms: supportSignature(change.support)
6243
+ } });
6244
+ }
6245
+ }
6246
+ /**
6247
+ * One `GET /api/camstack/version`, turned into the three answers the
6248
+ * tracker distinguishes.
6249
+ *
6250
+ * A 404 is the OLD component saying it has no such endpoint, which is
6251
+ * information. Anything else — a timeout, a 500, a rotated token — is
6252
+ * not, and must not be read as one.
6253
+ */
6254
+ async probeComponent(link) {
6255
+ try {
6256
+ const response = await fetch(`${link.baseUrl.replace(/\/+$/, "")}${COMPONENT_VERSION_PATH}`, {
6257
+ headers: { authorization: `Bearer ${link.token}` },
6258
+ signal: AbortSignal.timeout(1e4)
6259
+ });
6260
+ if (response.status === 404 || response.status === 405) return { kind: "absent" };
6261
+ if (!response.ok) return {
6262
+ kind: "unknown",
6263
+ error: `Home Assistant answered ${response.status}`
6264
+ };
6265
+ const report = parseComponentReport(await response.json());
6266
+ if (report === null) return {
6267
+ kind: "unknown",
6268
+ error: "the component answered without a platform list"
6269
+ };
6270
+ return {
6271
+ kind: "reported",
6272
+ report
6273
+ };
6274
+ } catch (err) {
6275
+ return {
6276
+ kind: "unknown",
6277
+ error: errMsg(err)
6278
+ };
6279
+ }
6280
+ }
2799
6281
  async resolveBrokerConnection(brokerId, addonId) {
2800
6282
  try {
2801
6283
  const raw = await this.ctx.api.broker.getBrokerConfig.query({
@@ -2924,25 +6406,56 @@ var HaExportAddon = class extends require_dist.BaseAddon {
2924
6406
  }
2925
6407
  return;
2926
6408
  }
2927
- const command = resolveCommand(exported.target, parsed.entity, value);
2928
- if (command === null) {
2929
- this.ctx.logger.warn("ha-export: dropping an unroutable command", {
6409
+ const resolution = resolveCommand(exported.target, parsed.entity, value);
6410
+ if (!resolution.ok) {
6411
+ /**
6412
+ * The reason is NAMED. "Unroutable" covered five different faults —
6413
+ * a typo in a topic, a capability the device does not declare, a
6414
+ * payload the platform cannot carry, and a control the catalog
6415
+ * advertised with no route behind it — and an operator watching a
6416
+ * switch snap back in Home Assistant could not tell which.
6417
+ */
6418
+ this.ctx.logger.warn("ha-export: dropping a command it cannot route", {
2930
6419
  tags: { deviceId: exported.deviceId },
2931
6420
  meta: {
2932
6421
  topic,
2933
- entity: parsed.entity
6422
+ entity: parsed.entity,
6423
+ reason: resolution.reason,
6424
+ ...resolution.capName !== void 0 ? { capName: resolution.capName } : {}
2934
6425
  }
2935
6426
  });
2936
6427
  reply.status(422);
2937
- reply.send({ error: "unroutable command" });
6428
+ reply.send({ error: `unroutable command (${resolution.reason})` });
2938
6429
  return;
2939
6430
  }
2940
- const applied = await this.dispatch(command);
6431
+ const applied = await this.dispatch(resolution.command);
2941
6432
  reply.status(applied ? 200 : 422);
2942
6433
  reply.send(applied ? {} : { error: "command not applied" });
2943
6434
  }
2944
6435
  async dispatch(command) {
2945
6436
  try {
6437
+ /**
6438
+ * The capability half goes through the route table that NAMED the
6439
+ * method — `lockControl.lock`, `brightness.setBrightness`,
6440
+ * `alarmPanel.arm`, `switch.setState`. It used to end here
6441
+ * regardless of the capability, calling `device.switch.setState`
6442
+ * for all of them, so a lock got `noProvider` and a 422 and a
6443
+ * brightness command never arrived at all.
6444
+ */
6445
+ if (isCapCommand(command)) {
6446
+ const outcome = await applyCapCommand(await this.ctx.fetchDevice(command.deviceId), command);
6447
+ if (outcome.ok) return true;
6448
+ if (outcome.reason === "no-provider") return this.noProvider(command.deviceId, outcome.capName);
6449
+ this.ctx.logger.warn("ha-export: the device refused the command", {
6450
+ tags: { deviceId: command.deviceId },
6451
+ meta: {
6452
+ capName: outcome.capName,
6453
+ kind: command.kind,
6454
+ ...outcome.error !== void 0 ? { error: outcome.error } : {}
6455
+ }
6456
+ });
6457
+ return false;
6458
+ }
2946
6459
  switch (command.kind) {
2947
6460
  case "camera-switch": {
2948
6461
  const parsed = require_dist.CameraSwitchIdSchema.safeParse(command.switchId);
@@ -2987,18 +6500,6 @@ var HaExportAddon = class extends require_dist.BaseAddon {
2987
6500
  return true;
2988
6501
  }
2989
6502
  case "snooze": return await this.applySnooze(command.deviceId, command.minutes);
2990
- case "cap-switch": {
2991
- const device = await this.ctx.fetchDevice(command.deviceId);
2992
- if (device.switch === void 0) return this.noProvider(command.deviceId, "switch");
2993
- await device.switch.setState({ on: command.on });
2994
- return true;
2995
- }
2996
- case "cap-button": {
2997
- const device = await this.ctx.fetchDevice(command.deviceId);
2998
- if (device.button === void 0) return this.noProvider(command.deviceId, "button");
2999
- await device.button.press({});
3000
- return true;
3001
- }
3002
6503
  }
3003
6504
  } catch (err) {
3004
6505
  /**