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