@camstack/addon-provider-homeassistant 1.2.28 → 1.2.29

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -3,7 +3,7 @@ Object.defineProperties(exports, {
3
3
  [Symbol.toStringTag]: { value: "Module" }
4
4
  });
5
5
  //#endregion
6
- const require_dist = require("../dist-D_Zn5oFS.js");
6
+ const require_dist = require("../dist-yXVqdsa3.js");
7
7
  let node_crypto = require("node:crypto");
8
8
  //#region src/ha-export/topics.ts
9
9
  /**
@@ -67,11 +67,893 @@ function humanise(entity) {
67
67
  function toComponentKey(platform, entity) {
68
68
  return `${platform}-${entity.replace(/_/g, "-").replace(/[^a-zA-Z0-9_-]/g, "_")}`;
69
69
  }
70
+ /**
71
+ * The entity id of a DERIVED capability entity — the one expression.
72
+ *
73
+ * The derived half of the catalog names its entity after the capability
74
+ * it comes from, and the projector has to publish to that exact name.
75
+ * They used to be two expressions in two files, and they disagreed:
76
+ * the catalog built `camstack/<key>/motion` while the projector
77
+ * published `motion_detected`, `triggered` and a set of doorbell topics
78
+ * of its own. **Only `battery` and `online` lined up**, so a non-camera
79
+ * motion, contact, smoke, leak or doorbell device got entities that
80
+ * could never receive a value — the same failure the 2026-08-05 audit
81
+ * measured on the old exporter (177 of 293 devices), reproduced on a
82
+ * narrower set.
83
+ *
84
+ * Both sides now come from here, and
85
+ * `__tests__/state-projector.spec.ts` walks every `CAP_ENTITY_MAP` entry
86
+ * asserting the catalog's `state_topic` is the topic the projector
87
+ * publishes to. That test is the guard: it fails the moment either side
88
+ * drifts.
89
+ */
90
+ function derivedEntityId(capName) {
91
+ return toSlug(capName);
92
+ }
93
+ /**
94
+ * The entity id of a SECONDARY entity of the same capability.
95
+ *
96
+ * The nine allowed platforms cannot express a cover, a thermostat or a
97
+ * media player as one entity, so a capability may produce several: a
98
+ * `cover` is its state AND its position, a `climate-control` is its mode
99
+ * AND the temperature it measures. They are still ONE capability and one
100
+ * slice, so their ids are built from the same expression as the primary
101
+ * — `<cap>_<suffix>` — rather than invented per row. Two expressions in
102
+ * two files is exactly what left a non-camera contact sensor with an
103
+ * entity that could never receive a value.
104
+ */
105
+ function derivedExtraEntityId(capName, suffix) {
106
+ return `${toSlug(capName)}_${toSlug(suffix)}`;
107
+ }
70
108
  /** Lower-case, underscore-joined, diacritic-free. Ids only, never labels. */
71
109
  function toSlug(value) {
72
110
  return value.normalize("NFKD").replace(/[\u0300-\u036f]/g, "").replace(/([a-z0-9])([A-Z])/g, "$1_$2").replace(/[^a-zA-Z0-9]+/g, "_").replace(/^_+|_+$/g, "").toLowerCase();
73
111
  }
74
112
  //#endregion
113
+ //#region src/ha-export/camera-entities.ts
114
+ /**
115
+ * The live `camera` entities — one per stream profile, muted and unmuted.
116
+ *
117
+ * `HaPlatform` has declared `camera` since the export was written and the
118
+ * catalog never emitted one, so an operator's Home Assistant carried ~73
119
+ * entities about a camera and no picture of it. This module is that half:
120
+ * pure, so the URL policy below is tested rather than reasoned about.
121
+ *
122
+ * ── The two traps, both measured ────────────────────────────────────────
123
+ *
124
+ * **1. The password.** `streamCatalog.getCatalog` returns the camera's
125
+ * NATIVE url — `rtsp://admin:hunter2@192.168.1.50:554/…`. Writing that
126
+ * into a `state_update` would put the camera's password into Home
127
+ * Assistant's entity registry and into every debug log it writes, on a
128
+ * host camstack does not own. So the source here is the BROKER restream
129
+ * (`cameraStreams.getProfileRtspEntries`), which carries no credentials,
130
+ * and {@link sanitiseStreamUrl} refuses anything with a userinfo section
131
+ * anyway — a second source, a provider change or a future caller must not
132
+ * be able to leak it by accident. A refusal produces NO entity value; it
133
+ * never produces a redacted-looking URL that would then be dialled.
134
+ *
135
+ * **2. The host.** The broker binds `127.0.0.1` and mints its restream
136
+ * URLs against whatever address it was asked for. Home Assistant is not
137
+ * on the hub, so a loopback URL yields an entity that exists and never
138
+ * loads — the identical failure the image entities had before
139
+ * `publicBaseUrl` was added. The caller passes `hostname` explicitly
140
+ * (`getProfileRtspEntries({ deviceId, hostname })`) and this module
141
+ * REFUSES a loopback URL rather than publishing one, because "the picture
142
+ * never appears" is indistinguishable from "the camera is down" on a
143
+ * dashboard.
144
+ *
145
+ * ── Fan-out ─────────────────────────────────────────────────────────────
146
+ *
147
+ * Three profiles × two mute states is six entities on a fleet of ~880
148
+ * cameras. Only the FIRST is `enabled_by_default`; the rest are
149
+ * registered-and-off, for the operator to switch on per camera. That is
150
+ * the same pressure valve the camera catalog already uses, applied to the
151
+ * one part of this export that could double a camera's entity count.
152
+ */
153
+ /** Profile slots, in the order an operator reads them. */
154
+ var CAMERA_PROFILE_ORDER = [
155
+ "high",
156
+ "mid",
157
+ "low"
158
+ ];
159
+ /** Hosts that are only routable from the hub itself. */
160
+ var LOOPBACK_HOSTS = [
161
+ "127.0.0.1",
162
+ "localhost",
163
+ "::1",
164
+ "[::1]",
165
+ "0.0.0.0"
166
+ ];
167
+ /**
168
+ * The one place a stream URL is judged fit to leave the hub.
169
+ *
170
+ * Deliberately a REFUSAL rather than a repair: rewriting a loopback host
171
+ * here would need an address this module does not have, and stripping a
172
+ * userinfo section would produce a URL that dials and fails
173
+ * authentication — both hide the defect instead of reporting it.
174
+ */
175
+ function sanitiseStreamUrl(url) {
176
+ let parsed;
177
+ try {
178
+ parsed = new URL(url);
179
+ } catch {
180
+ return {
181
+ ok: false,
182
+ reason: "unparseable"
183
+ };
184
+ }
185
+ if (parsed.username.length > 0 || parsed.password.length > 0) return {
186
+ ok: false,
187
+ reason: "credentials-inline"
188
+ };
189
+ if (parsed.hostname.length === 0) return {
190
+ ok: false,
191
+ reason: "unparseable"
192
+ };
193
+ if (LOOPBACK_HOSTS.includes(parsed.hostname.toLowerCase())) return {
194
+ ok: false,
195
+ reason: "loopback-host"
196
+ };
197
+ return {
198
+ ok: true,
199
+ url
200
+ };
201
+ }
202
+ /**
203
+ * The host Home Assistant can reach the hub on, from the base URL the
204
+ * media plane already resolved.
205
+ *
206
+ * The same answer feeds both planes on purpose. `publicBaseUrl` (or the
207
+ * lowest-priority non-loopback endpoint behind it) is the ONE address the
208
+ * operator has told us Home Assistant can open; a second discovery here
209
+ * could disagree with the one the image entities use, and then half a
210
+ * camera's entities would load.
211
+ */
212
+ function hostnameFromBaseUrl(baseUrl) {
213
+ if (baseUrl === null || baseUrl.length === 0) return null;
214
+ try {
215
+ const host = new URL(baseUrl).hostname;
216
+ if (host.length === 0 || LOOPBACK_HOSTS.includes(host.toLowerCase())) return null;
217
+ return host;
218
+ } catch {
219
+ return null;
220
+ }
221
+ }
222
+ /** `high` → 0. An unknown profile sorts after the known ones, stably. */
223
+ function profileRank(profile) {
224
+ const index = CAMERA_PROFILE_ORDER.indexOf(profile);
225
+ return index === -1 ? CAMERA_PROFILE_ORDER.length : index;
226
+ }
227
+ /**
228
+ * The profiles that produce entities: assigned, restream-enabled, in
229
+ * `high → mid → low` order, capped at {@link MAX_CAMERA_PROFILES}.
230
+ *
231
+ * A DISABLED profile produces nothing. Its `url` is minted all the same
232
+ * by the broker, and publishing it would give the operator a camera
233
+ * entity that dials a restream nobody is serving — a control that lies,
234
+ * in the shape D62 forbids for switches.
235
+ */
236
+ function activeStreamProfiles(profiles) {
237
+ return [...profiles].filter((entry) => entry.enabled).sort((a, b) => profileRank(a.profile) - profileRank(b.profile)).slice(0, 3);
238
+ }
239
+ /**
240
+ * Profile list → the `camera` entities the catalog builds.
241
+ *
242
+ * Pure and URL-free: an entity EXISTS because a profile is assigned and
243
+ * enabled, and it carries a value only when {@link cameraStreamValues}
244
+ * judges that profile's URL publishable. The two questions are separate
245
+ * because a URL that becomes publishable later (the operator sets
246
+ * `publicBaseUrl`) must fill an entity that is already in HA's registry,
247
+ * not mint a new one and orphan the automations pointing at the old id.
248
+ */
249
+ function cameraStreamEntities(profiles) {
250
+ const entities = [];
251
+ for (const entry of activeStreamProfiles(profiles)) {
252
+ const slug = toSlug(entry.profile);
253
+ for (const muted of [false, true]) entities.push({
254
+ entity: muted ? `stream_${slug}_muted` : `stream_${slug}`,
255
+ platform: "camera",
256
+ label: muted ? `Stream ${entry.profile} (muted)` : `Stream ${entry.profile}`,
257
+ enabledByDefault: entities.length === 0,
258
+ profile: entry.profile,
259
+ muted
260
+ });
261
+ }
262
+ return entities;
263
+ }
264
+ /**
265
+ * The entities' values: one URL per (profile, mute) pair.
266
+ *
267
+ * The muted variant is the broker's own `mutedUrl`, not a query-string
268
+ * edit of `url` — deriving one topic by string-editing another is the
269
+ * mistake `topics.ts` records, and the broker is the authority on what a
270
+ * muted alias of a profile is called.
271
+ */
272
+ function cameraStreamValues(deviceKey, profiles) {
273
+ const values = [];
274
+ const refused = [];
275
+ for (const entity of cameraStreamEntities(profiles)) {
276
+ const source = profiles.find((entry) => entry.profile === entity.profile);
277
+ if (source === void 0) continue;
278
+ const decision = sanitiseStreamUrl(entity.muted ? source.mutedUrl : source.url);
279
+ if (!decision.ok) {
280
+ refused.push({
281
+ entity: entity.entity,
282
+ profile: entity.profile,
283
+ reason: decision.reason
284
+ });
285
+ continue;
286
+ }
287
+ values.push({
288
+ topic: stateTopic(deviceKey, entity.entity),
289
+ value: decision.url
290
+ });
291
+ }
292
+ return {
293
+ values,
294
+ refused
295
+ };
296
+ }
297
+ //#endregion
298
+ //#region src/ha-export/component-support.ts
299
+ /** Where the component publishes what it builds. A wire format. */
300
+ var COMPONENT_VERSION_PATH = "/api/camstack/version";
301
+ /**
302
+ * The platforms every released component builds.
303
+ *
304
+ * Assumed when the component does not answer the probe at all, which is
305
+ * exactly what 0.3.x and older do: the endpoint arrived with the native
306
+ * platforms. `alarm_control_panel` is deliberately ABSENT — the export has
307
+ * been emitting it since T1 and the 0.3.x component never built it, so
308
+ * assuming it here would keep an alarm panel invisible on the very
309
+ * installations this list exists to protect.
310
+ */
311
+ var LEGACY_PLATFORMS = [
312
+ "binary_sensor",
313
+ "sensor",
314
+ "image",
315
+ "switch",
316
+ "button",
317
+ "select",
318
+ "camera",
319
+ "number"
320
+ ];
321
+ var LEGACY_SUPPORT = new Set(LEGACY_PLATFORMS);
322
+ /**
323
+ * What the catalog assumes when NOBODY asked.
324
+ *
325
+ * Deliberately not {@link LEGACY_SUPPORT}: the platforms the degraded
326
+ * tables themselves name, which is exactly the behaviour the export had
327
+ * before any of this existed. A caller that does not negotiate therefore
328
+ * gets what it always got — no native platforms, and no degradation
329
+ * either. Both changes are decisions the NEGOTIATION makes, on an answer
330
+ * from the Home Assistant that has to build them, and neither is a
331
+ * default a test fixture or a future caller can trip over by accident.
332
+ */
333
+ var UNNEGOTIATED_SUPPORT = new Set([...LEGACY_PLATFORMS, "alarm_control_panel"]);
334
+ var KNOWN_PLATFORMS = new Set([
335
+ "binary_sensor",
336
+ "sensor",
337
+ "image",
338
+ "switch",
339
+ "button",
340
+ "select",
341
+ "camera",
342
+ "alarm_control_panel",
343
+ "number",
344
+ "cover",
345
+ "climate",
346
+ "lock",
347
+ "fan",
348
+ "vacuum",
349
+ "valve",
350
+ "humidifier",
351
+ "water_heater",
352
+ "media_player"
353
+ ]);
354
+ function isKnownPlatform(value) {
355
+ return KNOWN_PLATFORMS.has(value);
356
+ }
357
+ /**
358
+ * Parse the component's answer.
359
+ *
360
+ * A platform this hub does not know is DROPPED rather than refused: the
361
+ * component ships on its own train and may well build something newer
362
+ * than this hub can emit, and refusing the whole report over one unknown
363
+ * name would downgrade every entity on that installation.
364
+ */
365
+ function parseComponentReport(body) {
366
+ if (body === null || typeof body !== "object") return null;
367
+ const raw = Reflect.get(body, "platforms");
368
+ if (!Array.isArray(raw)) return null;
369
+ const platforms = /* @__PURE__ */ new Set();
370
+ for (const entry of raw) if (typeof entry === "string" && isKnownPlatform(entry)) platforms.add(entry);
371
+ if (platforms.size === 0) return null;
372
+ const version = Reflect.get(body, "version");
373
+ return {
374
+ version: typeof version === "string" ? version : null,
375
+ platforms
376
+ };
377
+ }
378
+ /** Stable, comparable text for a support set. Used for change detection. */
379
+ function supportSignature(support) {
380
+ return [...support].sort().join(",");
381
+ }
382
+ /**
383
+ * The platforms every one of these components builds.
384
+ *
385
+ * A device exported to two Home Assistant instances is announced ONCE, so
386
+ * a platform only one of them builds cannot be used: the other would
387
+ * receive a component it drops on the floor. Two instances on different
388
+ * component versions is the case this exists for, and the cost is that
389
+ * the newer one waits for the older to be updated — visible in the log,
390
+ * and never an entity that silently does not arrive.
391
+ */
392
+ function intersectSupport(supports) {
393
+ const [first, ...rest] = supports;
394
+ if (first === void 0) return LEGACY_SUPPORT;
395
+ const out = /* @__PURE__ */ new Set();
396
+ for (const platform of first) if (rest.every((other) => other.has(platform))) out.add(platform);
397
+ return out;
398
+ }
399
+ /**
400
+ * Per-broker negotiated support, with the downgrade rule.
401
+ *
402
+ * Pure and injectable-free: the probe is somebody else's job, this only
403
+ * decides what a sequence of answers means.
404
+ */
405
+ var ComponentSupportTracker = class {
406
+ current = /* @__PURE__ */ new Map();
407
+ /** brokerId → the signature of the downgrade that has been seen once. */
408
+ pendingDowngrade = /* @__PURE__ */ new Map();
409
+ /** The support in force for a broker. Legacy until something says otherwise. */
410
+ supportFor(brokerId) {
411
+ return this.current.get(brokerId) ?? LEGACY_SUPPORT;
412
+ }
413
+ /** The support in force for a set of brokers, intersected. */
414
+ supportForAll(brokerIds) {
415
+ if (brokerIds.length === 0) return LEGACY_SUPPORT;
416
+ return intersectSupport(brokerIds.map((id) => this.supportFor(id)));
417
+ }
418
+ forget(brokerId) {
419
+ this.current.delete(brokerId);
420
+ this.pendingDowngrade.delete(brokerId);
421
+ }
422
+ observe(brokerId, probe) {
423
+ const before = this.supportFor(brokerId);
424
+ if (probe.kind === "unknown") return {
425
+ support: before,
426
+ changed: false,
427
+ withheldDowngrade: false
428
+ };
429
+ const next = probe.kind === "absent" ? LEGACY_SUPPORT : probe.report.platforms;
430
+ const signature = supportSignature(next);
431
+ if (signature === supportSignature(before)) {
432
+ this.pendingDowngrade.delete(brokerId);
433
+ this.current.set(brokerId, next);
434
+ return {
435
+ support: before,
436
+ changed: false,
437
+ withheldDowngrade: false
438
+ };
439
+ }
440
+ if ([...before].some((platform) => !next.has(platform)) && this.pendingDowngrade.get(brokerId) !== signature) {
441
+ this.pendingDowngrade.set(brokerId, signature);
442
+ return {
443
+ support: before,
444
+ changed: false,
445
+ withheldDowngrade: true
446
+ };
447
+ }
448
+ this.pendingDowngrade.delete(brokerId);
449
+ this.current.set(brokerId, next);
450
+ return {
451
+ support: next,
452
+ changed: true,
453
+ withheldDowngrade: false
454
+ };
455
+ }
456
+ };
457
+ //#endregion
458
+ //#region src/ha-export/native-platforms.ts
459
+ /**
460
+ * The NATIVE half of the Home Assistant export.
461
+ *
462
+ * A `cover` in Home Assistant is one entity with an open/close/stop
463
+ * surface, a position slider and a tilt slider. Exported through the nine
464
+ * platforms the 0.3.x component builds it is a `sensor` plus two more
465
+ * `sensor`s plus three `button`s — automatable, but not a cover: no
466
+ * `cover.open_cover`, no position in the more-info dialog, no
467
+ * `device_class: garage`, and every dashboard card that expects a cover
468
+ * refuses it. Same for a lock (`jammed` is not a boolean), a thermostat,
469
+ * a vacuum and a media player.
470
+ *
471
+ * From component 0.4.0 those platforms exist, and this table says which
472
+ * capability becomes which one. It is **structure only**: the topics a
473
+ * native component names are the topics the DEGRADED entities already
474
+ * used, so `state-projector.ts` is untouched and the value plane cannot
475
+ * disagree with the entity plane. Nothing here publishes a value.
476
+ *
477
+ * ── Three rules, each with a cost behind it ──────────────────────────────
478
+ *
479
+ * 1. **A control appears only when its descriptor is writable.** The
480
+ * authority is `CAP_ENTITY_MAP` — the same flag that decides whether the
481
+ * degraded entity gets a command topic, and therefore the same flag that
482
+ * tracks `CAP_COMMAND_ROUTES`. A native cover on a hub whose route table
483
+ * cannot move a cover is a READ-ONLY cover: it shows state, position and
484
+ * tilt and offers no buttons. A control that calls nothing is the defect
485
+ * this repo has shipped twice (D62), and a prettier platform is not a
486
+ * reason to ship it a third time.
487
+ *
488
+ * 2. **A binding whose entity does not exist is skipped.** The degraded
489
+ * table is being widened in parallel (verb buttons, writable numbers).
490
+ * Every binding below is resolved against `CAP_ENTITY_MAP` at build
491
+ * time, so a control lights up the moment its descriptor appears and
492
+ * costs nothing until then. This is what lets the two halves land in
493
+ * either order.
494
+ *
495
+ * 3. **What the native entity reads, it OWNS — and only that.** A bound
496
+ * entity is not announced separately: a native cover plus a `sensor`
497
+ * reporting the same position is two readings of one value, and a native
498
+ * cover plus a `number` writing the same position is two knobs (D62).
499
+ * Everything NOT bound stays exactly as it was, and that is the property
500
+ * that makes widening safe in both directions: a vacuum's error label is
501
+ * still a diagnostic sensor because HA's vacuum cannot show one, and a
502
+ * thermostat's vertical-swing toggle is still a `switch` because HA's
503
+ * climate expresses swing as a mode vocabulary the capability does not
504
+ * have. A control this table has no binding for is never silently lost —
505
+ * it is simply not absorbed.
506
+ */
507
+ /**
508
+ * capability → the native Home Assistant platform it becomes.
509
+ *
510
+ * Every capability here also has a row in `CAP_ENTITY_MAP`, which stays
511
+ * the fallback for a component that does not build the platform. The two
512
+ * are never both announced.
513
+ */
514
+ var NATIVE_CAP_PLATFORMS = {
515
+ /**
516
+ * `CoverStatusSchema`: `state` is the HA lifecycle verbatim
517
+ * (`open`/`opening`/`closing`/`closed`/`stopped`), `position` and
518
+ * `tiltPosition` are 0..100 or null. The three verbs are bound BOTH ways:
519
+ * to the primary command topic (one topic, `OPEN`/`CLOSE`/`STOP`) and to
520
+ * the per-verb buttons, so whichever the degraded table grows, the
521
+ * native cover can drive it.
522
+ */
523
+ cover: {
524
+ platform: "cover",
525
+ primary: { from: null },
526
+ controls: {
527
+ position: {
528
+ from: "position",
529
+ min: 0,
530
+ max: 100,
531
+ step: 1
532
+ },
533
+ tilt: {
534
+ from: "tilt",
535
+ min: 0,
536
+ max: 100,
537
+ step: 1
538
+ },
539
+ open: {
540
+ from: "open",
541
+ commandOnly: true
542
+ },
543
+ close: {
544
+ from: "close",
545
+ commandOnly: true
546
+ },
547
+ stop: {
548
+ from: "stop",
549
+ commandOnly: true
550
+ }
551
+ }
552
+ },
553
+ /** `ValveStatusSchema` — the cover lifecycle without tilt. */
554
+ valve: {
555
+ platform: "valve",
556
+ primary: { from: null },
557
+ controls: {
558
+ position: {
559
+ from: "position",
560
+ min: 0,
561
+ max: 100,
562
+ step: 1
563
+ },
564
+ open: {
565
+ from: "open",
566
+ commandOnly: true
567
+ },
568
+ close: {
569
+ from: "close",
570
+ commandOnly: true
571
+ },
572
+ stop: {
573
+ from: "stop",
574
+ commandOnly: true
575
+ }
576
+ }
577
+ },
578
+ /**
579
+ * The reason this platform exists. `LockStateSchema` is
580
+ * `locked | unlocked | locking | unlocking | jammed`, and every one of
581
+ * those is a state Home Assistant's `lock` has — including the one a
582
+ * `switch` silently reports as "unlocked".
583
+ *
584
+ * It READS the enum extra and WRITES the switch. The degraded primary
585
+ * publishes a boolean derived from the same field, so binding the state
586
+ * to it would throw away exactly the distinction the platform is for.
587
+ */
588
+ "lock-control": {
589
+ platform: "lock",
590
+ primary: {
591
+ from: "state",
592
+ commandFrom: null
593
+ },
594
+ controls: { open: {
595
+ from: "open",
596
+ commandOnly: true
597
+ } }
598
+ },
599
+ /**
600
+ * `AlarmStateSchema` IS Home Assistant's alarm vocabulary, and
601
+ * `availableModes` is the subset this panel accepts — a panel that
602
+ * cannot arm `vacation` must not offer the button. `requiresCode` is the
603
+ * panel's own answer about a PIN.
604
+ *
605
+ * This is also the only row here whose degraded form is a REGRESSION
606
+ * rather than the status quo: `CAP_ENTITY_MAP` already names
607
+ * `alarm_control_panel`, which the 0.3.x component does not build, so an
608
+ * alarm panel exported to it produces NO entity at all. The fallback
609
+ * below gives those installations the state as a sensor.
610
+ */
611
+ "alarm-panel": {
612
+ platform: "alarm_control_panel",
613
+ primary: {
614
+ from: null,
615
+ optionsField: "availableModes"
616
+ },
617
+ flagFields: { code_arm_required: "requiresCode" }
618
+ },
619
+ /**
620
+ * `ClimateControlStatusSchema`. Every temperature is Celsius by the
621
+ * cap's own doc comment, and the mode/fan-mode/preset vocabularies are
622
+ * `available*` arrays on the DEVICE — which is exactly why they cannot be
623
+ * a static `select` in the degraded table and can be a native climate
624
+ * here: the component receives the list with the entity.
625
+ */
626
+ "climate-control": {
627
+ platform: "climate",
628
+ primary: {
629
+ from: null,
630
+ optionsField: "availableModes"
631
+ },
632
+ constants: { temperature_unit: "°C" },
633
+ controls: {
634
+ current_temperature: { from: "current-temp" },
635
+ current_humidity: { from: "current-humidity" },
636
+ target_humidity: {
637
+ from: "target-humidity",
638
+ min: 0,
639
+ max: 100,
640
+ step: 1
641
+ },
642
+ target: {
643
+ from: "target",
644
+ min: -20,
645
+ max: 90,
646
+ step: .5
647
+ },
648
+ target_low: {
649
+ from: "target-low",
650
+ min: -20,
651
+ max: 90,
652
+ step: .5
653
+ },
654
+ target_high: {
655
+ from: "target-high",
656
+ min: -20,
657
+ max: 90,
658
+ step: .5
659
+ },
660
+ fan_mode: {
661
+ from: "fan-mode",
662
+ optionsField: "availableFanModes"
663
+ },
664
+ preset: {
665
+ from: "preset",
666
+ optionsField: "availablePresets"
667
+ }
668
+ }
669
+ },
670
+ /**
671
+ * A fan has no `on` field: `percentage` IS its state and 0 is off, which
672
+ * is what Home Assistant's fan does too (`turn_off` writes 0).
673
+ * `percentageStep` is the device's own granularity — a 4-speed fan
674
+ * reports 25, and a step of 1 would offer 37% to hardware that rounds it.
675
+ */
676
+ "fan-control": {
677
+ platform: "fan",
678
+ primary: {
679
+ from: null,
680
+ min: 0,
681
+ max: 100,
682
+ step: 1,
683
+ stepField: "percentageStep"
684
+ },
685
+ controls: {
686
+ preset: {
687
+ from: "preset",
688
+ optionsField: "availablePresets"
689
+ },
690
+ oscillation: { from: "oscillating" },
691
+ direction: { from: "direction" }
692
+ }
693
+ },
694
+ /** `HumidifierStatusSchema`. `minHumidity`/`maxHumidity` are per device. */
695
+ humidifier: {
696
+ platform: "humidifier",
697
+ deviceClass: "humidifier",
698
+ primary: { from: null },
699
+ controls: {
700
+ current_humidity: { from: "current-humidity" },
701
+ target_humidity: {
702
+ from: "target-humidity",
703
+ minField: "minHumidity",
704
+ maxField: "maxHumidity",
705
+ min: 0,
706
+ max: 100,
707
+ step: 1
708
+ },
709
+ mode: {
710
+ from: "mode",
711
+ optionsField: "availableModes"
712
+ },
713
+ action: { from: "action" }
714
+ }
715
+ },
716
+ /**
717
+ * Home Assistant's water heater IS its operation mode — the entity state
718
+ * is `eco`/`performance`/`off`, not a temperature. The capability's
719
+ * primary entity is the MEASURED temperature, so the primary binding
720
+ * names the mode extra and the measurement becomes a control.
721
+ */
722
+ "water-heater": {
723
+ platform: "water_heater",
724
+ primary: {
725
+ from: "mode",
726
+ optionsField: "availableModes"
727
+ },
728
+ constants: { temperature_unit: "°C" },
729
+ controls: {
730
+ current_temperature: { from: null },
731
+ target: {
732
+ from: "target",
733
+ minField: "minTemp",
734
+ maxField: "maxTemp",
735
+ min: -20,
736
+ max: 90,
737
+ step: .5
738
+ },
739
+ away: { from: "away" }
740
+ }
741
+ },
742
+ /**
743
+ * `VacuumStateSchema` is `idle|cleaning|paused|returning|docked|drying|error`;
744
+ * Home Assistant has every one but `drying`, which the component folds
745
+ * into `cleaning` rather than dropping the update.
746
+ *
747
+ * Battery, progress and the error label are deliberately NOT absorbed.
748
+ * Home Assistant deprecated the vacuum battery attribute in favour of a
749
+ * separate `sensor`, and it has nowhere to show a progress percentage or
750
+ * a vendor error string at all — as sensors they stay automatable.
751
+ */
752
+ "vacuum-control": {
753
+ platform: "vacuum",
754
+ primary: { from: null },
755
+ controls: {
756
+ fan_speed: {
757
+ from: "fan-speed",
758
+ optionsField: "availableFanSpeeds"
759
+ },
760
+ start: {
761
+ from: "start",
762
+ commandOnly: true
763
+ },
764
+ pause: {
765
+ from: "pause",
766
+ commandOnly: true
767
+ },
768
+ stop: {
769
+ from: "stop",
770
+ commandOnly: true
771
+ },
772
+ return_to_base: {
773
+ from: "return-to-base",
774
+ commandOnly: true
775
+ },
776
+ locate: {
777
+ from: "locate",
778
+ commandOnly: true
779
+ }
780
+ }
781
+ },
782
+ /**
783
+ * `MediaPlayerStatusSchema`. `volumeLevel` is 0..100 in the cap and
784
+ * 0..1 in Home Assistant — the component converts, in one place, because
785
+ * a factor of 100 applied on the wrong side is a player that jumps to
786
+ * full volume.
787
+ *
788
+ * `currentMedia` is an object and stays unexported: title and artist
789
+ * would need a projector that walks into a sub-object, and an entity
790
+ * that reports `unrenderable` on every delivery is worse than one that
791
+ * does not exist.
792
+ */
793
+ "media-player": {
794
+ platform: "media_player",
795
+ primary: { from: null },
796
+ controls: {
797
+ volume: {
798
+ from: "volume",
799
+ min: 0,
800
+ max: 100,
801
+ step: 1
802
+ },
803
+ mute: { from: "muted" },
804
+ source: {
805
+ from: "source",
806
+ optionsField: "availableSources"
807
+ },
808
+ shuffle: { from: "shuffle" },
809
+ repeat: { from: "repeat" },
810
+ play: {
811
+ from: "play",
812
+ commandOnly: true
813
+ },
814
+ pause: {
815
+ from: "pause",
816
+ commandOnly: true
817
+ },
818
+ stop: {
819
+ from: "stop",
820
+ commandOnly: true
821
+ },
822
+ next: {
823
+ from: "next",
824
+ commandOnly: true
825
+ },
826
+ previous: {
827
+ from: "previous",
828
+ commandOnly: true
829
+ }
830
+ }
831
+ }
832
+ };
833
+ /**
834
+ * What a platform becomes when the component on the other end cannot
835
+ * build it.
836
+ *
837
+ * The native platforms never reach this: `buildDerivedPlan` asks about
838
+ * support BEFORE choosing one, and falls back to the capability's own
839
+ * degraded row. This is for the other direction — a platform the DEGRADED
840
+ * table already names that an older component still does not build.
841
+ *
842
+ * There is exactly one, and it is a live defect rather than a
843
+ * hypothetical: the export has been announcing `alarm_control_panel`
844
+ * since the native export shipped, and the 0.3.x component builds eight
845
+ * platforms not including it. Every alarm panel exported to one of those
846
+ * installations produced a warning line and no entity at all. A `sensor`
847
+ * carrying the panel state is not an alarm card, but it is a value an
848
+ * operator can automate on, which is strictly more than nothing.
849
+ */
850
+ var DEGRADED_PLATFORM = { alarm_control_panel: "sensor" };
851
+ /** Whether a capability has a native platform at all. */
852
+ function nativePlatformFor(capName) {
853
+ return NATIVE_CAP_PLATFORMS[capName]?.platform ?? null;
854
+ }
855
+ function readStringArray$1(slice, field) {
856
+ const raw = slice?.[field];
857
+ if (!Array.isArray(raw)) return void 0;
858
+ const values = raw.filter((entry) => typeof entry === "string");
859
+ return values.length > 0 ? values : void 0;
860
+ }
861
+ function readNumber$1(slice, field) {
862
+ const raw = slice?.[field];
863
+ return typeof raw === "number" && Number.isFinite(raw) ? raw : void 0;
864
+ }
865
+ function resolveEntity(input, suffix) {
866
+ const { capName, mapping } = input;
867
+ const descriptor = suffix === null ? mapping : mapping.extras?.[suffix];
868
+ const isCommandEntity = suffix !== null && descriptor === void 0 && mapping.commands?.[suffix] !== void 0;
869
+ if (descriptor === void 0 && !isCommandEntity) return null;
870
+ return {
871
+ entity: suffix === null ? derivedEntityId(capName) : derivedExtraEntityId(capName, suffix),
872
+ writable: isCommandEntity || descriptor?.writable === true,
873
+ descriptor
874
+ };
875
+ }
876
+ function resolveBinding(input, binding) {
877
+ const { deviceKey } = input;
878
+ const source = resolveEntity(input, binding.from);
879
+ if (source === null) return null;
880
+ /**
881
+ * The written entity, when it is not the read one. Absent rather than
882
+ * refused: a control whose command entity does not exist is still a
883
+ * READING worth announcing.
884
+ */
885
+ const target = binding.commandFrom === void 0 ? source : resolveEntity(input, binding.commandFrom);
886
+ const entity = source.entity;
887
+ const descriptor = source.descriptor;
888
+ const writable = target?.writable === true;
889
+ const options = binding.optionsField === void 0 ? void 0 : readStringArray$1(input.slice, binding.optionsField);
890
+ /**
891
+ * The DEVICE's own bounds win over the static ones. A heat-pump water
892
+ * heater that reports `maxTemp: 60` must not offer 90, and a static
893
+ * range is only ever the backstop for hardware that reports none.
894
+ */
895
+ const min = (binding.minField === void 0 ? void 0 : readNumber$1(input.slice, binding.minField)) ?? descriptor?.range?.min ?? binding.min;
896
+ const max = (binding.maxField === void 0 ? void 0 : readNumber$1(input.slice, binding.maxField)) ?? descriptor?.range?.max ?? binding.max;
897
+ const step = (binding.stepField === void 0 ? void 0 : readNumber$1(input.slice, binding.stepField)) ?? descriptor?.range?.step ?? binding.step;
898
+ const control = {
899
+ ...binding.commandOnly === true ? {} : { state_topic: stateTopic(deviceKey, entity) },
900
+ ...writable && target !== null ? { command_topic: commandTopic(deviceKey, target.entity) } : {},
901
+ ...options !== void 0 ? { options } : {},
902
+ ...min !== void 0 ? { min } : {},
903
+ ...max !== void 0 ? { max } : {},
904
+ ...step !== void 0 ? { step } : {}
905
+ };
906
+ return {
907
+ entities: target === null || target.entity === entity ? [entity] : [entity, target.entity],
908
+ control
909
+ };
910
+ }
911
+ /**
912
+ * Build one native component, or `null` when the capability has none.
913
+ *
914
+ * The component's identity — its `unique_id` and therefore the component
915
+ * key — is the capability's PRIMARY entity id, the same one the degraded
916
+ * `sensor` used. Home Assistant keys its registry on
917
+ * `(platform, integration, unique_id)`, so this is a NEW entity even
918
+ * though the id is unchanged, and the old one is removed by the
919
+ * component's own diff rather than left orphaned. That is the whole
920
+ * migration, and `docs/design/ecosystem-export-plan.md` records why an
921
+ * in-place one is not possible.
922
+ */
923
+ function buildNativeComponent(input) {
924
+ const native = NATIVE_CAP_PLATFORMS[input.capName];
925
+ if (native === void 0) return null;
926
+ const primary = resolveBinding(input, native.primary);
927
+ if (primary === null) return null;
928
+ const absorbed = new Set([derivedEntityId(input.capName), ...primary.entities]);
929
+ const controls = {};
930
+ for (const [name, binding] of Object.entries(native.controls ?? {})) {
931
+ const resolved = resolveBinding(input, binding);
932
+ if (resolved === null) continue;
933
+ controls[name] = resolved.control;
934
+ for (const entity of resolved.entities) absorbed.add(entity);
935
+ }
936
+ const flags = {};
937
+ for (const [key, field] of Object.entries(native.flagFields ?? {})) {
938
+ const raw = input.slice?.[field];
939
+ if (typeof raw === "boolean") flags[key] = raw;
940
+ }
941
+ return {
942
+ component: {
943
+ platform: native.platform,
944
+ unique_id: `${input.stableId}_${derivedEntityId(input.capName)}`,
945
+ name: input.label,
946
+ ...primary.control,
947
+ ...native.deviceClass !== void 0 ? { device_class: native.deviceClass } : {},
948
+ ...native.icon !== void 0 ? { icon: native.icon } : {},
949
+ ...Object.keys(controls).length > 0 ? { controls } : {},
950
+ ...native.constants,
951
+ ...flags
952
+ },
953
+ absorbed
954
+ };
955
+ }
956
+ //#endregion
75
957
  //#region src/ha-export/entity-catalog.ts
76
958
  /**
77
959
  * The entity catalog — pure. Device + zones + macros → the `cmps` set.
@@ -97,6 +979,11 @@ function toSlug(value) {
97
979
  * nothing produces is the failure this repo keeps paying for.
98
980
  */
99
981
  /**
982
+ * The enums a `select` offers come from the CAP, never from a list here: a
983
+ * second copy of `HvacModeSchema` would be one rename away from offering an
984
+ * operator a mode their thermostat has never heard of.
985
+ */
986
+ /**
100
987
  * The macro classes an operator automates on.
101
988
  *
102
989
  * Deliberately the macros and NOT the 21 fine classes: those include
@@ -192,6 +1079,33 @@ var PTZ_BUTTONS = [
192
1079
  "ptz_zoom_in",
193
1080
  "ptz_zoom_out"
194
1081
  ];
1082
+ /** 0..100, the shape every camstack percentage declares in its own schema. */
1083
+ var PERCENT_RANGE = {
1084
+ min: 0,
1085
+ max: 100,
1086
+ step: 1
1087
+ };
1088
+ /**
1089
+ * A temperature setpoint, in Celsius.
1090
+ *
1091
+ * Deliberately WIDER than any one device: a heat-pump water heater runs to
1092
+ * 80 °C, a freezer setpoint is negative, and the per-device truth
1093
+ * (`climate-control`'s `getOptions`, `water-heater`'s `minTemp`/`maxTemp`)
1094
+ * is on the provider and the slice, not in this static table. Half-degree
1095
+ * steps because every thermostat this repo has met accepts them and a step of
1096
+ * 1 would make 20.5 unreachable.
1097
+ */
1098
+ var TEMPERATURE_RANGE = {
1099
+ min: -20,
1100
+ max: 90,
1101
+ step: .5
1102
+ };
1103
+ /** `ColorInputSchema` declares mireds as `int().min(50).max(1000)`. */
1104
+ var MIRED_RANGE = {
1105
+ min: 50,
1106
+ max: 1e3,
1107
+ step: 1
1108
+ };
195
1109
  function buildComponent(device, spec) {
196
1110
  const deviceKey = deviceKeyFor(device.stableId);
197
1111
  return {
@@ -204,6 +1118,11 @@ function buildComponent(device, spec) {
204
1118
  ...spec.unit !== void 0 ? { unit_of_measurement: spec.unit } : {},
205
1119
  ...spec.icon !== void 0 ? { icon: spec.icon } : {},
206
1120
  ...spec.options !== void 0 ? { options: spec.options } : {},
1121
+ ...spec.range !== void 0 ? {
1122
+ min: spec.range.min,
1123
+ max: spec.range.max,
1124
+ step: spec.range.step
1125
+ } : {},
207
1126
  ...spec.entityCategory !== void 0 ? { entity_category: spec.entityCategory } : {},
208
1127
  ...spec.enabledByDefault ? {} : { enabled_by_default: false },
209
1128
  ...spec.platform === "binary_sensor" || spec.platform === "switch" ? {
@@ -528,6 +1447,30 @@ function cameraSpecs(device) {
528
1447
  enabledByDefault: false
529
1448
  });
530
1449
  }
1450
+ /**
1451
+ * The live picture. Six at most, only the first switched on — see
1452
+ * `camera-entities.ts` for the password and loopback traps this side
1453
+ * deliberately knows nothing about.
1454
+ */
1455
+ for (const stream of cameraStreamEntities(device.streams ?? [])) specs.push({
1456
+ entity: stream.entity,
1457
+ platform: stream.platform,
1458
+ label: stream.label,
1459
+ enabledByDefault: stream.enabledByDefault
1460
+ });
1461
+ /**
1462
+ * The two capability-derived entities a camera carries.
1463
+ *
1464
+ * `buildDerivedPlan` is the NON-camera path, so a camera-scoped
1465
+ * capability reaches Home Assistant only if the camera catalog builds
1466
+ * it — and it is built from the same descriptor and the same entity id
1467
+ * as the derived half, so `projectCapSlice` publishes to the topic
1468
+ * this component declares without a second expression anywhere.
1469
+ */
1470
+ for (const cap of CAMERA_DERIVED_CAPS) {
1471
+ if (!device.boundCaps.includes(cap)) continue;
1472
+ specs.push(...capEntitySpecs(cap));
1473
+ }
531
1474
  for (const zone of device.zones) specs.push(...zoneSpecs(zone));
532
1475
  return specs;
533
1476
  }
@@ -545,36 +1488,100 @@ function assemble(device, specs) {
545
1488
  function buildCameraPlan(device) {
546
1489
  return assemble(device, cameraSpecs(device));
547
1490
  }
548
- var CAP_ENTITY_MAP = {
549
- switch: {
550
- platform: "switch",
551
- field: "on",
552
- writable: true
553
- },
554
- "lock-control": {
1491
+ /**
1492
+ * capability → the entities it produces.
1493
+ *
1494
+ * ── What decides read-only ──────────────────────────────────────────────
1495
+ *
1496
+ * The plan's rule, and D62's rule generalised: **a writable descriptor
1497
+ * must name a route `CAP_COMMAND_ROUTES` can resolve, or ship read-only.**
1498
+ * The route table now covers every Tier A family, so the rows below are
1499
+ * writable wherever the capability declares a method that writes exactly
1500
+ * the value the entity carries. Three things still ship read-only, each
1501
+ * for a reason that is a property of the value and not of the table:
1502
+ *
1503
+ * - **A vocabulary that lives on the slice.** `climate-control.fanMode`,
1504
+ * `.preset`, `water-heater.operationMode`, `humidifier.mode`,
1505
+ * `media-player.source` and `vacuum-control.fanSpeed` are free-form
1506
+ * strings whose accepted values are `available*` arrays on the DEVICE.
1507
+ * A `select` needs a closed static list, and inventing one would offer
1508
+ * values the device rejects while hiding the ones it takes. Only an
1509
+ * enum in the cap's own schema becomes a `select` here.
1510
+ * - **A setter that needs two values at once.** `setTargetRange` writes
1511
+ * `targetLow` AND `targetHigh`; two independent `number` entities
1512
+ * cannot make one call without reading each other, and the catalog and
1513
+ * the route table are both pure. Both stay sensors.
1514
+ * - **A value that is not flat.** See below.
1515
+ *
1516
+ * A row that is read-only for one of those reasons says so where it sits.
1517
+ *
1518
+ * ── Why a nested object is never a field ────────────────────────────────
1519
+ *
1520
+ * `renderCapValue` carries strings, numbers and booleans. `color.rgb`,
1521
+ * `vacuum-control.dustBin`, `event-emitter.lastEvent` and
1522
+ * `consumables.items` are objects and arrays: mapping one would publish
1523
+ * an entity that reports `unrenderable` on every single delivery. Where a
1524
+ * capability's headline value is only expressible as an object, the flat
1525
+ * neighbours are exported and the object is named in the doc comment
1526
+ * rather than mapped.
1527
+ */
1528
+ var CAP_ENTITY_MAP = {
1529
+ switch: {
555
1530
  platform: "switch",
556
- field: "locked",
557
- deviceClass: "lock",
1531
+ field: "on",
558
1532
  writable: true
559
1533
  },
560
- siren: {
1534
+ /**
1535
+ * The lock is BOTH: a switch, because that is what an operator
1536
+ * automates on and `lockControl.lock` / `.unlock` is a route that
1537
+ * works — and a sensor, because `LockStateSchema` is
1538
+ * `locked | unlocked | locking | unlocking | jammed` and a boolean
1539
+ * silently reports a JAMMED door as unlocked. The boolean is derived
1540
+ * from the same field, so the two can never disagree.
1541
+ */
1542
+ "lock-control": {
561
1543
  platform: "switch",
562
- field: "active",
563
- writable: true
1544
+ field: "state",
1545
+ deviceClass: "lock",
1546
+ writable: true,
1547
+ derive: {
1548
+ kind: "equals",
1549
+ value: "locked"
1550
+ },
1551
+ extras: { state: {
1552
+ platform: "sensor",
1553
+ field: "state",
1554
+ label: "Lock state",
1555
+ icon: "mdi:lock-question"
1556
+ } }
564
1557
  },
565
1558
  button: {
566
1559
  platform: "button",
567
1560
  field: "pressed",
568
1561
  writable: true
569
1562
  },
1563
+ /**
1564
+ * The range is not decoration. `brightness.setBrightness` declares
1565
+ * `0..100` and the entity carried no bounds, so Home Assistant applied its
1566
+ * own default of min 1 — an operator could not send `0` and could not turn
1567
+ * the light off from the number. It has been that way since the entity
1568
+ * existed.
1569
+ */
570
1570
  brightness: {
571
1571
  platform: "number",
572
- field: "brightness",
1572
+ field: "percentage",
1573
+ unit: "%",
1574
+ writable: true,
1575
+ range: PERCENT_RANGE
1576
+ },
1577
+ "alarm-panel": {
1578
+ platform: "alarm_control_panel",
1579
+ field: "state",
573
1580
  writable: true
574
1581
  },
575
1582
  binary: {
576
1583
  platform: "binary_sensor",
577
- field: "state"
1584
+ field: "on"
578
1585
  },
579
1586
  motion: {
580
1587
  platform: "binary_sensor",
@@ -583,22 +1590,39 @@ var CAP_ENTITY_MAP = {
583
1590
  },
584
1591
  contact: {
585
1592
  platform: "binary_sensor",
586
- field: "open",
1593
+ field: "entryOpen",
587
1594
  deviceClass: "door"
588
1595
  },
1596
+ /**
1597
+ * `state` is a STRING — `home`, `not_home`, or the name of any zone the
1598
+ * operator defined. The binary answers "is anybody in", which is what
1599
+ * an automation triggers on; the extra carries the zone NAME, which is
1600
+ * the whole reason a presence device is not a contact sensor. A native
1601
+ * `device_tracker` (which carries both at once) is Tier B.
1602
+ */
589
1603
  presence: {
590
1604
  platform: "binary_sensor",
591
- field: "present",
592
- deviceClass: "presence"
1605
+ field: "state",
1606
+ deviceClass: "presence",
1607
+ derive: {
1608
+ kind: "not-equals",
1609
+ value: "not_home"
1610
+ },
1611
+ extras: { zone: {
1612
+ platform: "sensor",
1613
+ field: "state",
1614
+ label: "Presence zone",
1615
+ icon: "mdi:map-marker-account"
1616
+ } }
593
1617
  },
594
1618
  connectivity: {
595
1619
  platform: "binary_sensor",
596
- field: "online",
1620
+ field: "connected",
597
1621
  deviceClass: "connectivity"
598
1622
  },
599
1623
  flood: {
600
1624
  platform: "binary_sensor",
601
- field: "detected",
1625
+ field: "flooded",
602
1626
  deviceClass: "moisture"
603
1627
  },
604
1628
  smoke: {
@@ -618,7 +1642,7 @@ var CAP_ENTITY_MAP = {
618
1642
  },
619
1643
  tamper: {
620
1644
  platform: "binary_sensor",
621
- field: "detected",
1645
+ field: "tampered",
622
1646
  deviceClass: "tamper"
623
1647
  },
624
1648
  vibration: {
@@ -633,25 +1657,25 @@ var CAP_ENTITY_MAP = {
633
1657
  },
634
1658
  "temperature-sensor": {
635
1659
  platform: "sensor",
636
- field: "temperature",
1660
+ field: "celsius",
637
1661
  deviceClass: "temperature",
638
1662
  unit: "°C"
639
1663
  },
640
1664
  "humidity-sensor": {
641
1665
  platform: "sensor",
642
- field: "humidity",
1666
+ field: "percent",
643
1667
  deviceClass: "humidity",
644
1668
  unit: "%"
645
1669
  },
646
1670
  "pressure-sensor": {
647
1671
  platform: "sensor",
648
- field: "pressure",
1672
+ field: "hpa",
649
1673
  deviceClass: "pressure",
650
1674
  unit: "hPa"
651
1675
  },
652
1676
  "ambient-light-sensor": {
653
1677
  platform: "sensor",
654
- field: "illuminance",
1678
+ field: "lux",
655
1679
  deviceClass: "illuminance",
656
1680
  unit: "lx"
657
1681
  },
@@ -680,12 +1704,889 @@ var CAP_ENTITY_MAP = {
680
1704
  field: "aqi",
681
1705
  deviceClass: "aqi"
682
1706
  },
683
- "alarm-panel": {
684
- platform: "alarm_control_panel",
1707
+ /**
1708
+ * A cover is its state, its two positions and its three verbs.
1709
+ *
1710
+ * `state` stays a `sensor`: `opening`/`closing` are transitions, not
1711
+ * commands, and there is no cap method that writes the lifecycle directly.
1712
+ * The positions are `number`s because `setPosition` / `setTiltPosition`
1713
+ * write exactly them, and the verbs are buttons because `open`, `close` and
1714
+ * `stop` take no value at all.
1715
+ */
1716
+ cover: {
1717
+ platform: "sensor",
685
1718
  field: "state",
686
- writable: true
1719
+ icon: "mdi:window-shutter",
1720
+ extras: {
1721
+ position: {
1722
+ platform: "number",
1723
+ field: "position",
1724
+ label: "Position",
1725
+ unit: "%",
1726
+ icon: "mdi:arrow-up-down",
1727
+ writable: true,
1728
+ range: PERCENT_RANGE
1729
+ },
1730
+ tilt: {
1731
+ platform: "number",
1732
+ field: "tiltPosition",
1733
+ label: "Tilt position",
1734
+ unit: "%",
1735
+ icon: "mdi:angle-acute",
1736
+ writable: true,
1737
+ range: PERCENT_RANGE,
1738
+ enabledByDefault: false
1739
+ }
1740
+ },
1741
+ commands: {
1742
+ open: {
1743
+ label: "Open",
1744
+ icon: "mdi:arrow-up-box"
1745
+ },
1746
+ close: {
1747
+ label: "Close",
1748
+ icon: "mdi:arrow-down-box"
1749
+ },
1750
+ stop: {
1751
+ label: "Stop",
1752
+ icon: "mdi:stop",
1753
+ enabledByDefault: false
1754
+ }
1755
+ }
1756
+ },
1757
+ /**
1758
+ * Every temperature here is CELSIUS by the cap's own doc comment
1759
+ * ("Single setpoint in Celsius", "Current measured temperature in
1760
+ * Celsius"), so the unit is declared rather than guessed.
1761
+ */
1762
+ "climate-control": {
1763
+ platform: "select",
1764
+ field: "mode",
1765
+ icon: "mdi:thermostat",
1766
+ writable: true,
1767
+ /**
1768
+ * The one closed list on this capability, and it is closed because
1769
+ * `HvacModeSchema` is an enum. `availableModes` narrows it PER DEVICE and
1770
+ * a static descriptor cannot read a slice, so an operator can select a
1771
+ * mode their unit rejects — the provider refuses it and the refusal
1772
+ * surfaces as a 422 with the device's own message, which is the honest
1773
+ * failure. `fanMode` and `preset` get no such list at all: they are
1774
+ * free-form strings, so they stay sensors.
1775
+ */
1776
+ options: require_dist.HvacModeSchema.options,
1777
+ extras: {
1778
+ "current-temp": {
1779
+ platform: "sensor",
1780
+ field: "currentTemp",
1781
+ label: "Current temperature",
1782
+ deviceClass: "temperature",
1783
+ unit: "°C"
1784
+ },
1785
+ target: {
1786
+ platform: "number",
1787
+ field: "target",
1788
+ label: "Target temperature",
1789
+ deviceClass: "temperature",
1790
+ unit: "°C",
1791
+ writable: true,
1792
+ range: TEMPERATURE_RANGE
1793
+ },
1794
+ "current-humidity": {
1795
+ platform: "sensor",
1796
+ field: "currentHumidity",
1797
+ label: "Current humidity",
1798
+ deviceClass: "humidity",
1799
+ unit: "%",
1800
+ enabledByDefault: false
1801
+ },
1802
+ "target-humidity": {
1803
+ platform: "number",
1804
+ field: "targetHumidity",
1805
+ label: "Target humidity",
1806
+ deviceClass: "humidity",
1807
+ unit: "%",
1808
+ writable: true,
1809
+ range: PERCENT_RANGE,
1810
+ enabledByDefault: false
1811
+ },
1812
+ /** Free-form: the accepted values are `availableFanModes` on the slice. */
1813
+ "fan-mode": {
1814
+ platform: "sensor",
1815
+ field: "fanMode",
1816
+ label: "Fan mode",
1817
+ icon: "mdi:fan",
1818
+ enabledByDefault: false
1819
+ },
1820
+ /** Free-form: the accepted values are `availablePresets` on the slice. */
1821
+ preset: {
1822
+ platform: "sensor",
1823
+ field: "preset",
1824
+ label: "Preset",
1825
+ enabledByDefault: false
1826
+ },
1827
+ /**
1828
+ * Read-only, and not for want of a method: `setTargetRange` writes
1829
+ * `targetLow` AND `targetHigh` in one call. Two independent `number`
1830
+ * entities would each have to read the other's current value to make it,
1831
+ * and both the catalog and the route table are pure. A dual-setpoint
1832
+ * control belongs to the native `climate` platform (Tier B), which
1833
+ * carries the pair as one entity.
1834
+ */
1835
+ "target-high": {
1836
+ platform: "sensor",
1837
+ field: "targetHigh",
1838
+ label: "Target high",
1839
+ deviceClass: "temperature",
1840
+ unit: "°C",
1841
+ enabledByDefault: false
1842
+ },
1843
+ "target-low": {
1844
+ platform: "sensor",
1845
+ field: "targetLow",
1846
+ label: "Target low",
1847
+ deviceClass: "temperature",
1848
+ unit: "°C",
1849
+ enabledByDefault: false
1850
+ },
1851
+ /**
1852
+ * Two INDEPENDENT axes, each with its own setter and its own
1853
+ * `DeviceFeature`. A device with neither reports `null` on both, and a
1854
+ * null renders as `unknown` rather than as a switch stuck off.
1855
+ */
1856
+ "swing-vertical": {
1857
+ platform: "switch",
1858
+ field: "swingVertical",
1859
+ label: "Vertical swing",
1860
+ icon: "mdi:arrow-up-down",
1861
+ writable: true,
1862
+ enabledByDefault: false
1863
+ },
1864
+ "swing-horizontal": {
1865
+ platform: "switch",
1866
+ field: "swingHorizontal",
1867
+ label: "Horizontal swing",
1868
+ icon: "mdi:arrow-left-right",
1869
+ writable: true,
1870
+ enabledByDefault: false
1871
+ }
1872
+ }
1873
+ },
1874
+ /** A fan has no `on` field — `percentage` IS its state, and 0 is off. */
1875
+ "fan-control": {
1876
+ platform: "number",
1877
+ field: "percentage",
1878
+ unit: "%",
1879
+ icon: "mdi:fan",
1880
+ writable: true,
1881
+ /**
1882
+ * `percentageStep` on the slice is the device's own granularity (25 for a
1883
+ * four-speed fan). A static step of 1 is the WIDER choice: a value the
1884
+ * device rounds is recoverable, a step that hides three of its four speeds
1885
+ * is not.
1886
+ */
1887
+ range: PERCENT_RANGE,
1888
+ extras: {
1889
+ /** Free-form: the accepted values are `availablePresets` on the slice. */
1890
+ preset: {
1891
+ platform: "sensor",
1892
+ field: "preset",
1893
+ label: "Preset",
1894
+ enabledByDefault: false
1895
+ },
1896
+ oscillating: {
1897
+ platform: "switch",
1898
+ field: "oscillating",
1899
+ label: "Oscillating",
1900
+ icon: "mdi:arrow-oscillating",
1901
+ writable: true,
1902
+ enabledByDefault: false
1903
+ },
1904
+ direction: {
1905
+ platform: "select",
1906
+ field: "direction",
1907
+ label: "Direction",
1908
+ writable: true,
1909
+ options: require_dist.FanDirectionSchema.options,
1910
+ enabledByDefault: false
1911
+ }
1912
+ }
1913
+ },
1914
+ humidifier: {
1915
+ platform: "switch",
1916
+ field: "on",
1917
+ icon: "mdi:air-humidifier",
1918
+ writable: true,
1919
+ extras: {
1920
+ "current-humidity": {
1921
+ platform: "sensor",
1922
+ field: "currentHumidity",
1923
+ label: "Current humidity",
1924
+ deviceClass: "humidity",
1925
+ unit: "%"
1926
+ },
1927
+ /**
1928
+ * `minHumidity`/`maxHumidity` on the slice are the device's own bounds;
1929
+ * the static range is the full percentage for the reason on
1930
+ * {@link HaNumberRange}.
1931
+ */
1932
+ "target-humidity": {
1933
+ platform: "number",
1934
+ field: "targetHumidity",
1935
+ label: "Target humidity",
1936
+ deviceClass: "humidity",
1937
+ unit: "%",
1938
+ writable: true,
1939
+ range: PERCENT_RANGE
1940
+ },
1941
+ /** Free-form: the accepted values are `availableModes` on the slice. */
1942
+ mode: {
1943
+ platform: "sensor",
1944
+ field: "mode",
1945
+ label: "Mode",
1946
+ enabledByDefault: false
1947
+ },
1948
+ action: {
1949
+ platform: "sensor",
1950
+ field: "action",
1951
+ label: "Action",
1952
+ enabledByDefault: false
1953
+ }
1954
+ }
1955
+ },
1956
+ "water-heater": {
1957
+ platform: "sensor",
1958
+ field: "currentTemp",
1959
+ deviceClass: "temperature",
1960
+ unit: "°C",
1961
+ extras: {
1962
+ target: {
1963
+ platform: "number",
1964
+ field: "targetTemp",
1965
+ label: "Target temperature",
1966
+ deviceClass: "temperature",
1967
+ unit: "°C",
1968
+ writable: true,
1969
+ range: TEMPERATURE_RANGE
1970
+ },
1971
+ /** Free-form: the accepted values are `availableModes` on the slice. */
1972
+ mode: {
1973
+ platform: "sensor",
1974
+ field: "operationMode",
1975
+ label: "Operation mode",
1976
+ enabledByDefault: false
1977
+ },
1978
+ away: {
1979
+ platform: "switch",
1980
+ field: "away",
1981
+ label: "Away mode",
1982
+ writable: true,
1983
+ enabledByDefault: false
1984
+ }
1985
+ }
1986
+ },
1987
+ valve: {
1988
+ platform: "sensor",
1989
+ field: "state",
1990
+ icon: "mdi:valve",
1991
+ extras: { position: {
1992
+ platform: "number",
1993
+ field: "position",
1994
+ label: "Position",
1995
+ unit: "%",
1996
+ icon: "mdi:arrow-up-down",
1997
+ writable: true,
1998
+ range: PERCENT_RANGE
1999
+ } },
2000
+ commands: {
2001
+ open: {
2002
+ label: "Open",
2003
+ icon: "mdi:valve-open"
2004
+ },
2005
+ close: {
2006
+ label: "Close",
2007
+ icon: "mdi:valve-closed"
2008
+ },
2009
+ stop: {
2010
+ label: "Stop",
2011
+ icon: "mdi:stop",
2012
+ enabledByDefault: false
2013
+ }
2014
+ }
2015
+ },
2016
+ "vacuum-control": {
2017
+ platform: "sensor",
2018
+ field: "state",
2019
+ icon: "mdi:robot-vacuum",
2020
+ extras: {
2021
+ battery: {
2022
+ platform: "sensor",
2023
+ field: "batteryLevel",
2024
+ label: "Battery",
2025
+ deviceClass: "battery",
2026
+ unit: "%"
2027
+ },
2028
+ /** Free-form: the accepted values are `availableFanSpeeds` on the slice. */
2029
+ "fan-speed": {
2030
+ platform: "sensor",
2031
+ field: "fanSpeed",
2032
+ label: "Fan speed",
2033
+ enabledByDefault: false
2034
+ },
2035
+ progress: {
2036
+ platform: "sensor",
2037
+ field: "progressPercent",
2038
+ label: "Progress",
2039
+ unit: "%",
2040
+ enabledByDefault: false
2041
+ },
2042
+ error: {
2043
+ platform: "sensor",
2044
+ field: "errorLabel",
2045
+ label: "Error",
2046
+ entityCategory: "diagnostic",
2047
+ enabledByDefault: false
2048
+ }
2049
+ },
2050
+ commands: {
2051
+ start: {
2052
+ label: "Start",
2053
+ icon: "mdi:play"
2054
+ },
2055
+ pause: {
2056
+ label: "Pause",
2057
+ icon: "mdi:pause"
2058
+ },
2059
+ stop: {
2060
+ label: "Stop",
2061
+ icon: "mdi:stop",
2062
+ enabledByDefault: false
2063
+ },
2064
+ "return-to-base": {
2065
+ label: "Return to base",
2066
+ icon: "mdi:home-import-outline"
2067
+ },
2068
+ locate: {
2069
+ label: "Locate",
2070
+ icon: "mdi:map-marker-radius",
2071
+ enabledByDefault: false
2072
+ }
2073
+ }
2074
+ },
2075
+ "lawn-mower-control": {
2076
+ platform: "sensor",
2077
+ field: "activity",
2078
+ icon: "mdi:robot-mower",
2079
+ extras: {
2080
+ battery: {
2081
+ platform: "sensor",
2082
+ field: "batteryLevel",
2083
+ label: "Battery",
2084
+ deviceClass: "battery",
2085
+ unit: "%"
2086
+ },
2087
+ progress: {
2088
+ platform: "sensor",
2089
+ field: "progressPercent",
2090
+ label: "Progress",
2091
+ unit: "%",
2092
+ enabledByDefault: false
2093
+ },
2094
+ error: {
2095
+ platform: "sensor",
2096
+ field: "currentCodeLabel",
2097
+ label: "Status code",
2098
+ entityCategory: "diagnostic",
2099
+ enabledByDefault: false
2100
+ }
2101
+ },
2102
+ commands: {
2103
+ start: {
2104
+ label: "Start mowing",
2105
+ icon: "mdi:play"
2106
+ },
2107
+ pause: {
2108
+ label: "Pause",
2109
+ icon: "mdi:pause"
2110
+ },
2111
+ dock: {
2112
+ label: "Dock",
2113
+ icon: "mdi:home-import-outline"
2114
+ }
2115
+ }
2116
+ },
2117
+ /** `currentMedia` is an object; the flat fields around it are exported. */
2118
+ "media-player": {
2119
+ platform: "sensor",
2120
+ field: "state",
2121
+ icon: "mdi:play-circle",
2122
+ extras: {
2123
+ volume: {
2124
+ platform: "number",
2125
+ field: "volumeLevel",
2126
+ label: "Volume",
2127
+ icon: "mdi:volume-high",
2128
+ writable: true,
2129
+ range: PERCENT_RANGE
2130
+ },
2131
+ muted: {
2132
+ platform: "switch",
2133
+ field: "isMuted",
2134
+ label: "Muted",
2135
+ icon: "mdi:volume-off",
2136
+ writable: true
2137
+ },
2138
+ /** Free-form: the accepted values are `availableSources` on the slice. */
2139
+ source: {
2140
+ platform: "sensor",
2141
+ field: "source",
2142
+ label: "Source",
2143
+ enabledByDefault: false
2144
+ },
2145
+ repeat: {
2146
+ platform: "select",
2147
+ field: "repeat",
2148
+ label: "Repeat",
2149
+ writable: true,
2150
+ options: require_dist.MediaPlayerRepeatSchema.options,
2151
+ enabledByDefault: false
2152
+ },
2153
+ shuffle: {
2154
+ platform: "switch",
2155
+ field: "shuffle",
2156
+ label: "Shuffle",
2157
+ writable: true,
2158
+ enabledByDefault: false
2159
+ }
2160
+ },
2161
+ commands: {
2162
+ play: {
2163
+ label: "Play",
2164
+ icon: "mdi:play"
2165
+ },
2166
+ pause: {
2167
+ label: "Pause",
2168
+ icon: "mdi:pause"
2169
+ },
2170
+ stop: {
2171
+ label: "Stop",
2172
+ icon: "mdi:stop",
2173
+ enabledByDefault: false
2174
+ },
2175
+ next: {
2176
+ label: "Next",
2177
+ icon: "mdi:skip-next"
2178
+ },
2179
+ previous: {
2180
+ label: "Previous",
2181
+ icon: "mdi:skip-previous"
2182
+ }
2183
+ }
2184
+ },
2185
+ /**
2186
+ * `rgb` and `hsv` are nested objects, so hue and saturation cannot be
2187
+ * two flat `number` entities without a projector that walks into a
2188
+ * sub-object — and a `light` platform that carries the whole colour is
2189
+ * Tier B. What IS flat is the colour MODE and the colour temperature,
2190
+ * and both are automatable.
2191
+ */
2192
+ color: {
2193
+ platform: "sensor",
2194
+ field: "mode",
2195
+ icon: "mdi:palette",
2196
+ extras: {
2197
+ /**
2198
+ * The one writable surface `setColor` has that is a single number: its
2199
+ * input is a discriminated union and `{ mode: 'mired', mireds }` is the
2200
+ * only arm with exactly one scalar in it. `mode` itself stays read-only
2201
+ * — writing it alone would be a call with no colour in it.
2202
+ */
2203
+ mireds: {
2204
+ platform: "number",
2205
+ field: "mireds",
2206
+ label: "Colour temperature",
2207
+ unit: "mired",
2208
+ writable: true,
2209
+ range: MIRED_RANGE,
2210
+ enabledByDefault: false
2211
+ } }
2212
+ },
2213
+ update: {
2214
+ platform: "binary_sensor",
2215
+ field: "updatable",
2216
+ deviceClass: "update",
2217
+ extras: {
2218
+ available: {
2219
+ platform: "sensor",
2220
+ field: "availableVersion",
2221
+ label: "Available version",
2222
+ entityCategory: "diagnostic"
2223
+ },
2224
+ current: {
2225
+ platform: "sensor",
2226
+ field: "currentVersion",
2227
+ label: "Installed version",
2228
+ entityCategory: "diagnostic"
2229
+ },
2230
+ installing: {
2231
+ platform: "binary_sensor",
2232
+ field: "inProgress",
2233
+ label: "Installing",
2234
+ deviceClass: "running",
2235
+ enabledByDefault: false
2236
+ }
2237
+ }
2238
+ },
2239
+ image: {
2240
+ platform: "image",
2241
+ field: "url",
2242
+ extras: { updated: {
2243
+ platform: "sensor",
2244
+ field: "lastUpdated",
2245
+ label: "Last updated",
2246
+ deviceClass: "timestamp",
2247
+ enabledByDefault: false
2248
+ } }
2249
+ },
2250
+ "script-runner": {
2251
+ platform: "binary_sensor",
2252
+ field: "isRunning",
2253
+ deviceClass: "running",
2254
+ icon: "mdi:script-text-play",
2255
+ extras: {
2256
+ "last-run": {
2257
+ platform: "sensor",
2258
+ field: "lastRunAt",
2259
+ label: "Last run",
2260
+ deviceClass: "timestamp"
2261
+ },
2262
+ "last-error": {
2263
+ platform: "sensor",
2264
+ field: "lastError",
2265
+ label: "Last error",
2266
+ entityCategory: "diagnostic",
2267
+ enabledByDefault: false
2268
+ }
2269
+ },
2270
+ commands: {
2271
+ run: {
2272
+ label: "Run",
2273
+ icon: "mdi:play"
2274
+ },
2275
+ stop: {
2276
+ label: "Stop",
2277
+ icon: "mdi:stop"
2278
+ }
2279
+ }
2280
+ },
2281
+ /**
2282
+ * `enabled` is a switch because `enable`/`disable` write exactly it;
2283
+ * `trigger` is a button because firing the action block is a verb, and it
2284
+ * works on a DISABLED automation — which is why it is not folded into the
2285
+ * switch.
2286
+ */
2287
+ "automation-control": {
2288
+ platform: "switch",
2289
+ field: "enabled",
2290
+ icon: "mdi:robot",
2291
+ writable: true,
2292
+ commands: { trigger: {
2293
+ label: "Trigger",
2294
+ icon: "mdi:flash"
2295
+ } },
2296
+ extras: {
2297
+ running: {
2298
+ platform: "binary_sensor",
2299
+ field: "isRunning",
2300
+ label: "Running",
2301
+ deviceClass: "running",
2302
+ enabledByDefault: false
2303
+ },
2304
+ "last-triggered": {
2305
+ platform: "sensor",
2306
+ field: "lastTriggeredAt",
2307
+ label: "Last triggered",
2308
+ deviceClass: "timestamp"
2309
+ },
2310
+ "last-error": {
2311
+ platform: "sensor",
2312
+ field: "lastError",
2313
+ label: "Last error",
2314
+ entityCategory: "diagnostic",
2315
+ enabledByDefault: false
2316
+ }
2317
+ }
2318
+ },
2319
+ /**
2320
+ * `lastEvent` is an object and `eventTypes` an array, so the plan's
2321
+ * "last-event + last-event-at" pair has nothing flat to read. The COUNT
2322
+ * is flat, monotonic and the thing an automation can trigger on ("it
2323
+ * went up") — a native `event` platform, which carries the payload, is
2324
+ * Tier B.
2325
+ */
2326
+ "event-emitter": {
2327
+ platform: "sensor",
2328
+ field: "eventCountSinceStart",
2329
+ icon: "mdi:counter"
2330
+ },
2331
+ "pet-feeder": {
2332
+ platform: "sensor",
2333
+ field: "foodLevel",
2334
+ unit: "%",
2335
+ icon: "mdi:food-drumstick",
2336
+ extras: {
2337
+ "low-food": {
2338
+ platform: "binary_sensor",
2339
+ field: "lowFood",
2340
+ label: "Low food",
2341
+ deviceClass: "problem"
2342
+ },
2343
+ feeding: {
2344
+ platform: "binary_sensor",
2345
+ field: "feeding",
2346
+ label: "Feeding",
2347
+ deviceClass: "running",
2348
+ enabledByDefault: false
2349
+ },
2350
+ desiccant: {
2351
+ platform: "sensor",
2352
+ field: "desiccantLeftDays",
2353
+ label: "Desiccant remaining",
2354
+ unit: "d",
2355
+ entityCategory: "diagnostic",
2356
+ enabledByDefault: false
2357
+ },
2358
+ status: {
2359
+ platform: "sensor",
2360
+ field: "status",
2361
+ label: "Status",
2362
+ entityCategory: "diagnostic",
2363
+ enabledByDefault: false
2364
+ }
2365
+ }
2366
+ },
2367
+ /**
2368
+ * The cap carries its OWN unit next to each measurement
2369
+ * (`temperatureUnit`, `pressureUnit`, `windSpeedUnit`), so no static
2370
+ * `unit_of_measurement` can be honest for a provider reporting °F.
2371
+ * Declaring one anyway is how a dashboard comes to say 71 °C. Humidity
2372
+ * is the exception: a percentage is a percentage.
2373
+ */
2374
+ weather: {
2375
+ platform: "sensor",
2376
+ field: "condition",
2377
+ icon: "mdi:weather-partly-cloudy",
2378
+ extras: {
2379
+ temperature: {
2380
+ platform: "sensor",
2381
+ field: "temperature",
2382
+ label: "Temperature",
2383
+ icon: "mdi:thermometer"
2384
+ },
2385
+ humidity: {
2386
+ platform: "sensor",
2387
+ field: "humidity",
2388
+ label: "Humidity",
2389
+ deviceClass: "humidity",
2390
+ unit: "%"
2391
+ },
2392
+ pressure: {
2393
+ platform: "sensor",
2394
+ field: "pressure",
2395
+ label: "Pressure",
2396
+ icon: "mdi:gauge",
2397
+ enabledByDefault: false
2398
+ },
2399
+ wind: {
2400
+ platform: "sensor",
2401
+ field: "windSpeed",
2402
+ label: "Wind speed",
2403
+ icon: "mdi:weather-windy",
2404
+ enabledByDefault: false
2405
+ },
2406
+ bearing: {
2407
+ platform: "sensor",
2408
+ field: "windBearing",
2409
+ label: "Wind bearing",
2410
+ unit: "°",
2411
+ enabledByDefault: false
2412
+ }
2413
+ }
2414
+ },
2415
+ /**
2416
+ * The generic user-settable input. `value` is `number | string` and
2417
+ * `kind` says which of the four shapes it is — a `number`, a `select`
2418
+ * or a text box, all three of which would need `control.setValue` in
2419
+ * the route table before they could be anything but a reading.
2420
+ */
2421
+ control: {
2422
+ platform: "sensor",
2423
+ field: "value",
2424
+ extras: { kind: {
2425
+ platform: "sensor",
2426
+ field: "kind",
2427
+ label: "Input kind",
2428
+ entityCategory: "diagnostic",
2429
+ enabledByDefault: false
2430
+ } }
2431
+ },
2432
+ "motion-trigger": {
2433
+ platform: "binary_sensor",
2434
+ field: "enabled",
2435
+ icon: "mdi:motion-sensor"
2436
+ },
2437
+ /**
2438
+ * Three capabilities a camera carries that an operator automates on. They
2439
+ * are in this table because the descriptor and the push path are the
2440
+ * derived half's; they reach a camera through {@link CAMERA_DERIVED_CAPS}
2441
+ * because `buildDerivedPlan` never runs for one.
2442
+ */
2443
+ /**
2444
+ * Talk-back, exported on the operator's decision and READ-ONLY.
2445
+ *
2446
+ * It was excluded until 2026-08-14 for a measured reason: the cap declared
2447
+ * `status` and no `runtimeState`, and the export's only two sources of a
2448
+ * value — the `device.state-changed` slice and the
2449
+ * `deviceState.getAllSnapshots` snapshot — are both built from runtime
2450
+ * state. The entity would have been published and never received a value.
2451
+ * `intercom.cap.ts` now declares the slice and the Reolink and Hikvision
2452
+ * providers write it at the four points that open and close a session, so
2453
+ * the entity has a feed before it has a row here.
2454
+ *
2455
+ * Read-only, and that is not a missing route: talk-back is an AUDIO
2456
+ * STREAM. `startTalkSession` opens a channel that `pushTalkAudio` feeds
2457
+ * frame by frame, and Home Assistant's switch carries `true`. A switch
2458
+ * that opened a session nobody could speak into is the control that lies.
2459
+ */
2460
+ intercom: {
2461
+ platform: "binary_sensor",
2462
+ field: "talking",
2463
+ icon: "mdi:account-voice",
2464
+ extras: { "last-session": {
2465
+ platform: "sensor",
2466
+ field: "lastSessionAt",
2467
+ label: "Last talk session",
2468
+ deviceClass: "timestamp",
2469
+ enabledByDefault: false
2470
+ } }
2471
+ },
2472
+ "day-night": {
2473
+ platform: "sensor",
2474
+ field: "mode",
2475
+ icon: "mdi:theme-light-dark",
2476
+ extras: {
2477
+ sensitivity: {
2478
+ platform: "sensor",
2479
+ field: "sensitivity",
2480
+ label: "IR-cut sensitivity",
2481
+ entityCategory: "diagnostic",
2482
+ enabledByDefault: false
2483
+ },
2484
+ "switch-delay": {
2485
+ platform: "sensor",
2486
+ field: "switchDelaySec",
2487
+ label: "IR-cut switch delay",
2488
+ unit: "s",
2489
+ entityCategory: "diagnostic",
2490
+ enabledByDefault: false
2491
+ }
2492
+ }
2493
+ },
2494
+ "ptz-autotrack": {
2495
+ platform: "binary_sensor",
2496
+ field: "enabled",
2497
+ icon: "mdi:crosshairs-gps"
687
2498
  }
688
2499
  };
2500
+ /**
2501
+ * Capabilities exported on a CAMERA rather than through
2502
+ * `buildDerivedPlan`, which only ever runs for a non-camera device.
2503
+ */
2504
+ var CAMERA_DERIVED_CAPS = [
2505
+ "day-night",
2506
+ "ptz-autotrack",
2507
+ "intercom"
2508
+ ];
2509
+ /**
2510
+ * The command buttons a capability produces.
2511
+ *
2512
+ * Separate from {@link capEntities} on purpose: that list is what the
2513
+ * PROJECTOR walks, and a button has nothing to project. Keeping them in one
2514
+ * list would put an entity with no `field` in front of code whose whole job is
2515
+ * to read one.
2516
+ */
2517
+ function capCommandEntities(capName) {
2518
+ const mapping = CAP_ENTITY_MAP[capName];
2519
+ if (mapping === void 0) return [];
2520
+ return Object.entries(mapping.commands ?? {}).map(([suffix, command]) => ({
2521
+ entity: derivedExtraEntityId(capName, suffix),
2522
+ suffix,
2523
+ label: command.label,
2524
+ command
2525
+ }));
2526
+ }
2527
+ /**
2528
+ * The one expression that turns a descriptor into entities.
2529
+ *
2530
+ * Both halves of the catalog and the projector call it, so a capability
2531
+ * cannot build a component under one id and publish under another — the
2532
+ * failure `derivedEntityId` was extracted to end, now that a capability
2533
+ * can produce more than one entity.
2534
+ */
2535
+ function capEntities(capName) {
2536
+ const mapping = CAP_ENTITY_MAP[capName];
2537
+ if (mapping === void 0) return [];
2538
+ const refs = [{
2539
+ entity: derivedEntityId(capName),
2540
+ label: humanise(capName),
2541
+ descriptor: mapping
2542
+ }];
2543
+ for (const [suffix, extra] of Object.entries(mapping.extras ?? {})) refs.push({
2544
+ entity: derivedExtraEntityId(capName, suffix),
2545
+ label: extra.label,
2546
+ descriptor: extra
2547
+ });
2548
+ return refs;
2549
+ }
2550
+ /**
2551
+ * Apply a descriptor's derivation.
2552
+ *
2553
+ * Returns `null` — which renders as `unrenderable` and is LOGGED — when
2554
+ * the slice carried something a comparison cannot be made against. A
2555
+ * derivation that quietly answered `false` for a non-string would report
2556
+ * a jammed lock as unlocked, which is the failure the derivation exists
2557
+ * to avoid.
2558
+ */
2559
+ function deriveCapValue(derivation, raw) {
2560
+ if (derivation === void 0) return raw;
2561
+ if (typeof raw !== "string") return null;
2562
+ return derivation.kind === "equals" ? raw === derivation.value : raw !== derivation.value;
2563
+ }
2564
+ /** A capability's entities as the catalog builders consume them. */
2565
+ function capEntitySpecs(capName) {
2566
+ const specs = capEntities(capName).map((ref) => ({
2567
+ entity: ref.entity,
2568
+ platform: ref.descriptor.platform,
2569
+ label: ref.label,
2570
+ enabledByDefault: ref.descriptor.enabledByDefault ?? true,
2571
+ ...ref.descriptor.deviceClass !== void 0 ? { deviceClass: ref.descriptor.deviceClass } : {},
2572
+ ...ref.descriptor.unit !== void 0 ? { unit: ref.descriptor.unit } : {},
2573
+ ...ref.descriptor.icon !== void 0 ? { icon: ref.descriptor.icon } : {},
2574
+ ...ref.descriptor.options !== void 0 ? { options: ref.descriptor.options } : {},
2575
+ ...ref.descriptor.range !== void 0 ? { range: ref.descriptor.range } : {},
2576
+ ...ref.descriptor.entityCategory !== void 0 ? { entityCategory: ref.descriptor.entityCategory } : {},
2577
+ ...ref.descriptor.writable === true ? { writable: true } : {}
2578
+ }));
2579
+ for (const ref of capCommandEntities(capName)) specs.push({
2580
+ entity: ref.entity,
2581
+ platform: "button",
2582
+ label: ref.label,
2583
+ enabledByDefault: ref.command.enabledByDefault ?? true,
2584
+ writable: true,
2585
+ ...ref.command.icon !== void 0 ? { icon: ref.command.icon } : {},
2586
+ ...ref.command.entityCategory !== void 0 ? { entityCategory: ref.command.entityCategory } : {}
2587
+ });
2588
+ return specs;
2589
+ }
689
2590
  var CAPS_NOT_EXPORTED = [
690
2591
  {
691
2592
  cap: "snapshot",
@@ -726,8 +2627,116 @@ var CAPS_NOT_EXPORTED = [
726
2627
  {
727
2628
  cap: "device-discovery",
728
2629
  reason: "Adoption-time only — it describes candidates, never the state of an adopted device."
2630
+ },
2631
+ {
2632
+ cap: "camera-credentials",
2633
+ 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."
2634
+ },
2635
+ {
2636
+ cap: "stream-catalog",
2637
+ 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)."
2638
+ },
2639
+ {
2640
+ cap: "audio-analysis",
2641
+ 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."
2642
+ },
2643
+ {
2644
+ cap: "camera-pipeline-config",
2645
+ reason: "Transport/pipeline configuration — it selects the road, it is not a state."
2646
+ },
2647
+ {
2648
+ cap: "camera-streams",
2649
+ 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."
2650
+ },
2651
+ {
2652
+ cap: "detection-pipeline",
2653
+ 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."
2654
+ },
2655
+ {
2656
+ cap: "image-settings",
2657
+ reason: "Firmware image tuning — encoder brightness/contrast/saturation. A rendering setting with no state an automation reads."
2658
+ },
2659
+ {
2660
+ cap: "motion-detection",
2661
+ 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."
2662
+ },
2663
+ {
2664
+ cap: "motion-zones",
2665
+ reason: "Zone geometry, not state. Per-zone counts already reach HA through the camera catalog's zone fan-out."
2666
+ },
2667
+ {
2668
+ cap: "osd",
2669
+ reason: "On-screen-display text overlay — a rendering setting with no automatable state."
2670
+ },
2671
+ {
2672
+ cap: "pipeline-analytics",
2673
+ 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."
2674
+ },
2675
+ {
2676
+ cap: "stream-params",
2677
+ reason: "Encoder parameters (bitrate/resolution/fps) — transport rather than state."
2678
+ },
2679
+ {
2680
+ cap: "videoclips",
2681
+ reason: "Media retrieval by handle, not a state. Clips reach the operator through notifications and the viewer."
2682
+ },
2683
+ {
2684
+ cap: "webrtc-session",
2685
+ reason: "A session opener — the definition of transport."
2686
+ },
2687
+ {
2688
+ cap: "events",
2689
+ reason: "A paginated event LOG, not a state. Its natural target is HA's `event` platform, which the component does not build (Tier B)."
2690
+ },
2691
+ {
2692
+ cap: "consumables",
2693
+ 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."
2694
+ },
2695
+ {
2696
+ cap: "scene-monitor",
2697
+ 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."
2698
+ },
2699
+ {
2700
+ cap: "audio-metrics",
2701
+ 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."
2702
+ },
2703
+ {
2704
+ cap: "notifier",
2705
+ reason: "A delivery service, not an entity — HA models these as `notify.<service>`, which is not one of the nine platforms the component builds."
2706
+ },
2707
+ {
2708
+ cap: "accessories",
2709
+ 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."
2710
+ },
2711
+ {
2712
+ cap: "zones",
2713
+ reason: "Zone geometry — configuration, not state. Same reason as `zone-analytics`, whose per-zone counts are what actually reaches HA."
2714
+ },
2715
+ {
2716
+ cap: "zone-rules",
2717
+ reason: "Per-stage zone rules — configuration the pipeline consumes, with no value an automation reads."
729
2718
  }
730
2719
  ];
2720
+ var UNNEGOTIATED_PLAN_OPTIONS = { platforms: UNNEGOTIATED_SUPPORT };
2721
+ /**
2722
+ * An entity whose platform the component cannot build, degraded to one it
2723
+ * can.
2724
+ *
2725
+ * The command topic goes with the platform: the degraded form is a
2726
+ * READING, and announcing a command topic on it would leave a route open
2727
+ * that nothing can reach. See `DEGRADED_PLATFORM` for the one entry this
2728
+ * exists for.
2729
+ */
2730
+ function degradeUnsupported(spec, options) {
2731
+ if (options.platforms.has(spec.platform)) return spec;
2732
+ const fallback = DEGRADED_PLATFORM[spec.platform];
2733
+ if (fallback === void 0) return spec;
2734
+ const { writable: _writable, ...rest } = spec;
2735
+ return {
2736
+ ...rest,
2737
+ platform: fallback
2738
+ };
2739
+ }
731
2740
  /** A capability in neither list — the thing the guard exists to surface. */
732
2741
  function unclassifiedCaps(caps) {
733
2742
  const known = new Set([...Object.keys(CAP_ENTITY_MAP), ...CAPS_NOT_EXPORTED.map((e) => e.cap)]);
@@ -746,8 +2755,19 @@ function unclassifiedCaps(caps) {
746
2755
  * (`device-status`, `feature-probe`, `device-ops`) carry no entity at
747
2756
  * all. The camera valve is for 73-per-device; this is not that problem,
748
2757
  * and exporting the operator's temperature switched off was the bug.
2758
+ *
2759
+ * **The Tier A widening qualifies that, per ENTITY rather than per
2760
+ * device.** A cover, a thermostat or a media player is not one value; it
2761
+ * is a state plus the numbers around it, and the numbers are the reason
2762
+ * to own the device. So the PRIMARY entity of every capability still
2763
+ * arrives enabled — it is what the device is — and a secondary one
2764
+ * arrives enabled only when an operator automates on it. A thermostat's
2765
+ * measured temperature: yes. Its swing preset and its dual-setpoint
2766
+ * bounds: registered and off. That keeps a widened `climate-control`
2767
+ * device at three enabled entities rather than eight, without hiding the
2768
+ * data from anyone who wants it.
749
2769
  */
750
- function buildDerivedPlan(device) {
2770
+ function buildDerivedPlan(device, options = UNNEGOTIATED_PLAN_OPTIONS) {
751
2771
  const specs = [{
752
2772
  entity: "online",
753
2773
  platform: "binary_sensor",
@@ -756,24 +2776,50 @@ function buildDerivedPlan(device) {
756
2776
  entityCategory: "diagnostic",
757
2777
  enabledByDefault: true
758
2778
  }];
2779
+ const native = {};
759
2780
  for (const cap of device.boundCaps) {
2781
+ /**
2782
+ * A camera-scoped capability is built by the CAMERA half. Nothing
2783
+ * binds `day-night` to a base device today, and if something did,
2784
+ * two halves of this file would build the same entity id twice.
2785
+ */
2786
+ if (CAMERA_DERIVED_CAPS.includes(cap)) continue;
760
2787
  const mapping = CAP_ENTITY_MAP[cap];
761
- if (mapping === void 0) continue;
762
- specs.push({
763
- entity: toSlug(cap),
764
- platform: mapping.platform,
2788
+ const platform = nativePlatformFor(cap);
2789
+ const plan = mapping === void 0 || platform === null || !options.platforms.has(platform) ? null : buildNativeComponent({
2790
+ deviceKey: deviceKeyFor(device.stableId),
2791
+ stableId: device.stableId,
2792
+ capName: cap,
765
2793
  label: humanise(cap),
766
- enabledByDefault: true,
767
- ...mapping.deviceClass !== void 0 ? { deviceClass: mapping.deviceClass } : {},
768
- ...mapping.unit !== void 0 ? { unit: mapping.unit } : {},
769
- ...mapping.writable === true ? { writable: true } : {}
2794
+ slice: device.capSlices?.[cap],
2795
+ mapping
770
2796
  });
2797
+ if (plan === null) {
2798
+ specs.push(...capEntitySpecs(cap).map((spec) => degradeUnsupported(spec, options)));
2799
+ continue;
2800
+ }
2801
+ native[toComponentKey(plan.component.platform, derivedEntityId(cap))] = plan.component;
2802
+ /**
2803
+ * Rule 3 of `native-platforms.ts`: what the native entity reads, it
2804
+ * owns. What it does NOT read is still announced — a vacuum's error
2805
+ * label has nowhere to go on Home Assistant's vacuum, and dropping it
2806
+ * to make the device look tidy would be a silent loss of a value an
2807
+ * operator already automates on.
2808
+ */
2809
+ specs.push(...capEntitySpecs(cap).filter((spec) => !plan.absorbed.has(spec.entity)));
771
2810
  }
772
- return assemble(device, specs);
2811
+ const assembled = assemble(device, specs);
2812
+ return {
2813
+ ...assembled,
2814
+ cmps: {
2815
+ ...assembled.cmps,
2816
+ ...native
2817
+ }
2818
+ };
773
2819
  }
774
2820
  /** Camera or base kind — the only place that decision is made. */
775
- function buildDevicePlan(device) {
776
- return device.type === "camera" ? buildCameraPlan(device) : buildDerivedPlan(device);
2821
+ function buildDevicePlan(device, options = UNNEGOTIATED_PLAN_OPTIONS) {
2822
+ return device.type === "camera" ? buildCameraPlan(device) : buildDerivedPlan(device, options);
777
2823
  }
778
2824
  //#endregion
779
2825
  //#region src/ha-export/command-routes.ts
@@ -781,104 +2827,864 @@ function buildDevicePlan(device) {
781
2827
  * Home Assistant → camstack.
782
2828
  *
783
2829
  * The component POSTs `{topic, value}` onto this addon's `addon-routes`
784
- * surface; this module turns that pair into a typed command. Resolution
785
- * is pure so it can be tested without a device, and the addon does the
786
- * dispatching.
2830
+ * surface; this module turns that pair into a typed command AND owns the
2831
+ * call each capability command makes. Resolution is pure; the dispatch
2832
+ * takes an injected `DeviceProxy` slice, so both halves are testable
2833
+ * without a hub.
2834
+ *
2835
+ * **The route and the call live together on purpose.** They used not to:
2836
+ * `resolveCommand` collapsed every non-button writable cap into
2837
+ * `kind: 'cap-switch'` and the addon called `device.switch.setState`
2838
+ * unconditionally, so `lock-control`, `siren`, `alarm-panel` and
2839
+ * `brightness` all advertised a control in Home Assistant that could not
2840
+ * work — a lock got `noProvider` and a 422, and `brightness` is a number
2841
+ * that never parsed as a boolean and never routed at all. A table that
2842
+ * names a method in one file and a switch that calls it in another is
2843
+ * exactly the drift that produced four lying controls.
787
2844
  *
788
2845
  * The rule that shapes every branch: **an unroutable or malformed
789
2846
  * command is refused, never approximated.** A `switch` payload that is
790
2847
  * neither `true` nor `false` is not "off"; a PTZ press on a camera that
791
- * declares no `ptz` cap is not a no-op worth pretending succeeded. Both
792
- * return `null`, and the caller logs the drop.
2848
+ * declares no `ptz` cap is not a no-op worth pretending succeeded; a cap
2849
+ * with no route does NOT fall through to `switch.setState`. Every
2850
+ * refusal carries a named reason so the log line says which of those it
2851
+ * was.
2852
+ */
2853
+ /** The camera switch ids, keyed by the entity slug the catalog emits. */
2854
+ var CAMERA_SWITCH_BY_SLUG = Object.fromEntries([
2855
+ "stream-broker",
2856
+ "object-detection",
2857
+ "privacy-mask",
2858
+ "device-audio",
2859
+ "broker-audio",
2860
+ "audio-analysis",
2861
+ "recording",
2862
+ "notifications"
2863
+ ].map((id) => [toSlug(id), id]));
2864
+ var PTZ_DIRECTION_BY_ENTITY = Object.fromEntries(PTZ_BUTTONS.map((entity) => [entity, entity.slice(4)]));
2865
+ function parseBool(value) {
2866
+ const lower = value.trim().toLowerCase();
2867
+ if (lower === "true" || lower === "on" || lower === "1") return true;
2868
+ if (lower === "false" || lower === "off" || lower === "0") return false;
2869
+ return null;
2870
+ }
2871
+ /**
2872
+ * A lock's payload.
2873
+ *
2874
+ * The catalog renders `lock-control` as a `switch`, so Home Assistant
2875
+ * sends `true`/`false` — but the component is not the only caller and a
2876
+ * lock's own vocabulary is `lock`/`unlock`. Both are accepted because
2877
+ * both are unambiguous; anything else is refused rather than read as
2878
+ * "unlock", which is the wrong way to be wrong about a door.
2879
+ */
2880
+ function parseLocked(value) {
2881
+ const asBool = parseBool(value);
2882
+ if (asBool !== null) return asBool;
2883
+ const lower = value.trim().toLowerCase();
2884
+ if (lower === "lock" || lower === "locked") return true;
2885
+ if (lower === "unlock" || lower === "unlocked") return false;
2886
+ return null;
2887
+ }
2888
+ /** 0..100 inclusive, as `brightness.setBrightness` declares it. */
2889
+ function parsePercentage(value) {
2890
+ const parsed = Number(value.trim());
2891
+ if (!Number.isFinite(parsed)) return null;
2892
+ if (parsed < 0 || parsed > 100) return null;
2893
+ return parsed;
2894
+ }
2895
+ /**
2896
+ * An alarm panel's payload.
2897
+ *
2898
+ * Home Assistant's alarm-panel command vocabulary is `ARM_HOME`,
2899
+ * `ARM_AWAY`, `ARM_NIGHT`, `ARM_VACATION`, `ARM_CUSTOM_BYPASS`, `DISARM`
2900
+ * and `TRIGGER`; the cap's own vocabulary is the arm MODE (`home`,
2901
+ * `away`, …) and its state vocabulary is `armed_home`. All three are
2902
+ * accepted — they are the same instruction spelled by three layers of the
2903
+ * same stack — and the mode itself is validated by `AlarmArmModeSchema`
2904
+ * rather than by a second list here that could drift from the cap.
2905
+ */
2906
+ function parseAlarmAction(value) {
2907
+ const lower = value.trim().toLowerCase();
2908
+ if (lower === "disarm" || lower === "disarmed") return { kind: "disarm" };
2909
+ if (lower === "trigger" || lower === "triggered") return { kind: "trigger" };
2910
+ const withoutPrefix = lower.startsWith("armed_") ? lower.slice(6) : lower.startsWith("arm_") ? lower.slice(4) : lower;
2911
+ const mode = require_dist.AlarmArmModeSchema.safeParse(withoutPrefix);
2912
+ return mode.success ? {
2913
+ kind: "arm",
2914
+ mode: mode.data
2915
+ } : null;
2916
+ }
2917
+ /** A finite number in `[min, max]`, or `null` when the payload is neither. */
2918
+ function parseBounded(value, min, max) {
2919
+ const parsed = Number(value.trim());
2920
+ if (!Number.isFinite(parsed)) return null;
2921
+ if (parsed < min || parsed > max) return null;
2922
+ return parsed;
2923
+ }
2924
+ /**
2925
+ * A payload against an enum the CAP declares.
2926
+ *
2927
+ * Never against a second list here. `HvacModeSchema`, `FanDirectionSchema`
2928
+ * and `MediaPlayerRepeatSchema` are the same objects the catalog offers as
2929
+ * the `select`'s options, so the values HA can send and the values this
2930
+ * accepts cannot drift apart.
2931
+ */
2932
+ function parseEnum(schema, value) {
2933
+ const parsed = schema.safeParse(value.trim().toLowerCase());
2934
+ return parsed.success ? parsed.data : null;
2935
+ }
2936
+ /**
2937
+ * capability → the commands that WRITE it.
2938
+ *
2939
+ * The entity catalog decides WHICH entities are writable; this decides what
2940
+ * writing each one does. An entity declared `writable` with no entry here is
2941
+ * refused with `no-route` and the drop is logged — it must never fall through
2942
+ * to another cap's method, which is how four controls came to advertise a
2943
+ * function they could not perform.
2944
+ *
2945
+ * Both directions are asserted by the specs: a writable descriptor with no
2946
+ * route fails, and a route with no descriptor fails too. An unreachable route
2947
+ * is the same defect read backwards — it describes a control the catalog
2948
+ * never built, and a leftover that describes the right design reads as
2949
+ * verification.
2950
+ *
2951
+ * **Every builder names ONE method with ONE value.** Nothing here composes
2952
+ * two slice fields into one call, because the resolution is pure and has no
2953
+ * slice: `climate-control.setTargetRange` writes `targetLow` and
2954
+ * `targetHigh` together and is therefore absent, not approximated.
2955
+ */
2956
+ var CAP_COMMAND_ROUTES = {
2957
+ switch: { primary: (deviceId, value) => {
2958
+ const on = parseBool(value);
2959
+ return on === null ? null : {
2960
+ kind: "cap-switch",
2961
+ deviceId,
2962
+ capName: "switch",
2963
+ on
2964
+ };
2965
+ } },
2966
+ "lock-control": { primary: (deviceId, value) => {
2967
+ const locked = parseLocked(value);
2968
+ return locked === null ? null : {
2969
+ kind: "cap-lock",
2970
+ deviceId,
2971
+ locked
2972
+ };
2973
+ } },
2974
+ brightness: { primary: (deviceId, value) => {
2975
+ const percentage = parsePercentage(value);
2976
+ return percentage === null ? null : {
2977
+ kind: "cap-brightness",
2978
+ deviceId,
2979
+ percentage
2980
+ };
2981
+ } },
2982
+ "alarm-panel": { primary: (deviceId, value) => {
2983
+ const action = parseAlarmAction(value);
2984
+ return action === null ? null : {
2985
+ kind: "cap-alarm",
2986
+ deviceId,
2987
+ action
2988
+ };
2989
+ } },
2990
+ button: { primary: (deviceId) => ({
2991
+ kind: "cap-button",
2992
+ deviceId,
2993
+ capName: "button"
2994
+ }) },
2995
+ cover: {
2996
+ extras: {
2997
+ position: (deviceId, value) => {
2998
+ const position = parsePercentage(value);
2999
+ return position === null ? null : {
3000
+ kind: "cover-position",
3001
+ deviceId,
3002
+ position
3003
+ };
3004
+ },
3005
+ tilt: (deviceId, value) => {
3006
+ const tiltPosition = parsePercentage(value);
3007
+ return tiltPosition === null ? null : {
3008
+ kind: "cover-tilt",
3009
+ deviceId,
3010
+ tiltPosition
3011
+ };
3012
+ }
3013
+ },
3014
+ commands: {
3015
+ open: (deviceId) => ({
3016
+ kind: "cover-verb",
3017
+ deviceId,
3018
+ verb: "open"
3019
+ }),
3020
+ close: (deviceId) => ({
3021
+ kind: "cover-verb",
3022
+ deviceId,
3023
+ verb: "close"
3024
+ }),
3025
+ stop: (deviceId) => ({
3026
+ kind: "cover-verb",
3027
+ deviceId,
3028
+ verb: "stop"
3029
+ })
3030
+ }
3031
+ },
3032
+ "climate-control": {
3033
+ primary: (deviceId, value) => {
3034
+ const mode = parseEnum(require_dist.HvacModeSchema, value);
3035
+ return mode === null ? null : {
3036
+ kind: "climate-mode",
3037
+ deviceId,
3038
+ mode
3039
+ };
3040
+ },
3041
+ extras: {
3042
+ /**
3043
+ * Bounded by the entity's declared range and nothing narrower: the
3044
+ * per-device limits are the provider's (`getOptions`), and refusing
3045
+ * here on a guess would reject a setpoint the device accepts.
3046
+ */
3047
+ target: (deviceId, value) => {
3048
+ const target = parseBounded(value, TEMPERATURE_MIN, TEMPERATURE_MAX);
3049
+ return target === null ? null : {
3050
+ kind: "climate-target",
3051
+ deviceId,
3052
+ target
3053
+ };
3054
+ },
3055
+ "target-humidity": (deviceId, value) => {
3056
+ const targetHumidity = parsePercentage(value);
3057
+ return targetHumidity === null ? null : {
3058
+ kind: "climate-target-humidity",
3059
+ deviceId,
3060
+ targetHumidity
3061
+ };
3062
+ },
3063
+ "swing-vertical": (deviceId, value) => {
3064
+ const on = parseBool(value);
3065
+ return on === null ? null : {
3066
+ kind: "climate-swing",
3067
+ deviceId,
3068
+ axis: "vertical",
3069
+ on
3070
+ };
3071
+ },
3072
+ "swing-horizontal": (deviceId, value) => {
3073
+ const on = parseBool(value);
3074
+ return on === null ? null : {
3075
+ kind: "climate-swing",
3076
+ deviceId,
3077
+ axis: "horizontal",
3078
+ on
3079
+ };
3080
+ }
3081
+ }
3082
+ },
3083
+ "fan-control": {
3084
+ primary: (deviceId, value) => {
3085
+ const percentage = parsePercentage(value);
3086
+ return percentage === null ? null : {
3087
+ kind: "fan-percentage",
3088
+ deviceId,
3089
+ percentage
3090
+ };
3091
+ },
3092
+ extras: {
3093
+ oscillating: (deviceId, value) => {
3094
+ const oscillating = parseBool(value);
3095
+ return oscillating === null ? null : {
3096
+ kind: "fan-oscillating",
3097
+ deviceId,
3098
+ oscillating
3099
+ };
3100
+ },
3101
+ direction: (deviceId, value) => {
3102
+ const direction = parseEnum(require_dist.FanDirectionSchema, value);
3103
+ return direction === null ? null : {
3104
+ kind: "fan-direction",
3105
+ deviceId,
3106
+ direction
3107
+ };
3108
+ }
3109
+ }
3110
+ },
3111
+ humidifier: {
3112
+ primary: (deviceId, value) => {
3113
+ const on = parseBool(value);
3114
+ return on === null ? null : {
3115
+ kind: "humidifier-on",
3116
+ deviceId,
3117
+ on
3118
+ };
3119
+ },
3120
+ extras: { "target-humidity": (deviceId, value) => {
3121
+ const humidity = parsePercentage(value);
3122
+ return humidity === null ? null : {
3123
+ kind: "humidifier-humidity",
3124
+ deviceId,
3125
+ humidity
3126
+ };
3127
+ } }
3128
+ },
3129
+ "water-heater": { extras: {
3130
+ target: (deviceId, value) => {
3131
+ const temp = parseBounded(value, TEMPERATURE_MIN, TEMPERATURE_MAX);
3132
+ return temp === null ? null : {
3133
+ kind: "water-heater-temp",
3134
+ deviceId,
3135
+ temp
3136
+ };
3137
+ },
3138
+ away: (deviceId, value) => {
3139
+ const on = parseBool(value);
3140
+ return on === null ? null : {
3141
+ kind: "water-heater-away",
3142
+ deviceId,
3143
+ on
3144
+ };
3145
+ }
3146
+ } },
3147
+ valve: {
3148
+ extras: { position: (deviceId, value) => {
3149
+ const position = parsePercentage(value);
3150
+ return position === null ? null : {
3151
+ kind: "valve-position",
3152
+ deviceId,
3153
+ position
3154
+ };
3155
+ } },
3156
+ commands: {
3157
+ open: (deviceId) => ({
3158
+ kind: "valve-verb",
3159
+ deviceId,
3160
+ verb: "open"
3161
+ }),
3162
+ close: (deviceId) => ({
3163
+ kind: "valve-verb",
3164
+ deviceId,
3165
+ verb: "close"
3166
+ }),
3167
+ stop: (deviceId) => ({
3168
+ kind: "valve-verb",
3169
+ deviceId,
3170
+ verb: "stop"
3171
+ })
3172
+ }
3173
+ },
3174
+ "vacuum-control": { commands: {
3175
+ start: (deviceId) => ({
3176
+ kind: "vacuum-verb",
3177
+ deviceId,
3178
+ verb: "start"
3179
+ }),
3180
+ pause: (deviceId) => ({
3181
+ kind: "vacuum-verb",
3182
+ deviceId,
3183
+ verb: "pause"
3184
+ }),
3185
+ stop: (deviceId) => ({
3186
+ kind: "vacuum-verb",
3187
+ deviceId,
3188
+ verb: "stop"
3189
+ }),
3190
+ "return-to-base": (deviceId) => ({
3191
+ kind: "vacuum-verb",
3192
+ deviceId,
3193
+ verb: "return-to-base"
3194
+ }),
3195
+ locate: (deviceId) => ({
3196
+ kind: "vacuum-verb",
3197
+ deviceId,
3198
+ verb: "locate"
3199
+ })
3200
+ } },
3201
+ "lawn-mower-control": { commands: {
3202
+ start: (deviceId) => ({
3203
+ kind: "mower-verb",
3204
+ deviceId,
3205
+ verb: "start"
3206
+ }),
3207
+ pause: (deviceId) => ({
3208
+ kind: "mower-verb",
3209
+ deviceId,
3210
+ verb: "pause"
3211
+ }),
3212
+ dock: (deviceId) => ({
3213
+ kind: "mower-verb",
3214
+ deviceId,
3215
+ verb: "dock"
3216
+ })
3217
+ } },
3218
+ "media-player": {
3219
+ extras: {
3220
+ volume: (deviceId, value) => {
3221
+ const volumeLevel = parsePercentage(value);
3222
+ return volumeLevel === null ? null : {
3223
+ kind: "media-volume",
3224
+ deviceId,
3225
+ volumeLevel
3226
+ };
3227
+ },
3228
+ muted: (deviceId, value) => {
3229
+ const muted = parseBool(value);
3230
+ return muted === null ? null : {
3231
+ kind: "media-mute",
3232
+ deviceId,
3233
+ muted
3234
+ };
3235
+ },
3236
+ shuffle: (deviceId, value) => {
3237
+ const shuffle = parseBool(value);
3238
+ return shuffle === null ? null : {
3239
+ kind: "media-shuffle",
3240
+ deviceId,
3241
+ shuffle
3242
+ };
3243
+ },
3244
+ repeat: (deviceId, value) => {
3245
+ const repeat = parseEnum(require_dist.MediaPlayerRepeatSchema, value);
3246
+ return repeat === null ? null : {
3247
+ kind: "media-repeat",
3248
+ deviceId,
3249
+ repeat
3250
+ };
3251
+ }
3252
+ },
3253
+ commands: {
3254
+ play: (deviceId) => ({
3255
+ kind: "media-verb",
3256
+ deviceId,
3257
+ verb: "play"
3258
+ }),
3259
+ pause: (deviceId) => ({
3260
+ kind: "media-verb",
3261
+ deviceId,
3262
+ verb: "pause"
3263
+ }),
3264
+ stop: (deviceId) => ({
3265
+ kind: "media-verb",
3266
+ deviceId,
3267
+ verb: "stop"
3268
+ }),
3269
+ next: (deviceId) => ({
3270
+ kind: "media-verb",
3271
+ deviceId,
3272
+ verb: "next"
3273
+ }),
3274
+ previous: (deviceId) => ({
3275
+ kind: "media-verb",
3276
+ deviceId,
3277
+ verb: "previous"
3278
+ })
3279
+ }
3280
+ },
3281
+ color: { extras: { mireds: (deviceId, value) => {
3282
+ const mireds = parseBounded(value, MIRED_MIN, MIRED_MAX);
3283
+ if (mireds === null || !Number.isInteger(mireds)) return null;
3284
+ return {
3285
+ kind: "color-mireds",
3286
+ deviceId,
3287
+ mireds
3288
+ };
3289
+ } } },
3290
+ "script-runner": { commands: {
3291
+ run: (deviceId) => ({
3292
+ kind: "script-verb",
3293
+ deviceId,
3294
+ verb: "run"
3295
+ }),
3296
+ stop: (deviceId) => ({
3297
+ kind: "script-verb",
3298
+ deviceId,
3299
+ verb: "stop"
3300
+ })
3301
+ } },
3302
+ "automation-control": {
3303
+ primary: (deviceId, value) => {
3304
+ const enabled = parseBool(value);
3305
+ return enabled === null ? null : {
3306
+ kind: "automation-enabled",
3307
+ deviceId,
3308
+ enabled
3309
+ };
3310
+ },
3311
+ commands: { trigger: (deviceId) => ({
3312
+ kind: "automation-trigger",
3313
+ deviceId
3314
+ }) }
3315
+ }
3316
+ };
3317
+ /**
3318
+ * The bounds the parsers enforce, kept HERE rather than imported from the
3319
+ * catalog's `TEMPERATURE_RANGE` / `MIRED_RANGE`: the catalog's ranges are
3320
+ * what Home Assistant is TOLD, and these are what the hub ACCEPTS. They
3321
+ * agree today, and the catalog spec asserts they still do — but the
3322
+ * assertion has to be able to fail, which it cannot if both read one
3323
+ * constant.
793
3324
  */
794
- /** The camera switch ids, keyed by the entity slug the catalog emits. */
795
- var CAMERA_SWITCH_BY_SLUG = Object.fromEntries([
796
- "stream-broker",
797
- "object-detection",
798
- "privacy-mask",
799
- "device-audio",
800
- "broker-audio",
801
- "audio-analysis",
802
- "recording",
803
- "notifications"
804
- ].map((id) => [toSlug(id), id]));
805
- var PTZ_DIRECTION_BY_ENTITY = Object.fromEntries(PTZ_BUTTONS.map((entity) => [entity, entity.slice(4)]));
806
- function parseBool(value) {
807
- const lower = value.trim().toLowerCase();
808
- if (lower === "true" || lower === "on" || lower === "1") return true;
809
- if (lower === "false" || lower === "off" || lower === "0") return false;
810
- return null;
811
- }
3325
+ var TEMPERATURE_MIN = -20;
3326
+ var TEMPERATURE_MAX = 90;
3327
+ var MIRED_MIN = 50;
3328
+ var MIRED_MAX = 1e3;
812
3329
  function resolveCommand(target, entity, value) {
813
3330
  if (target.type === "camera") {
814
3331
  const switchId = CAMERA_SWITCH_BY_SLUG[entity];
815
3332
  if (switchId !== void 0) {
816
3333
  const enabled = parseBool(value);
817
- if (enabled === null) return null;
3334
+ if (enabled === null) return {
3335
+ ok: false,
3336
+ reason: "bad-value"
3337
+ };
818
3338
  return {
819
- kind: "camera-switch",
820
- deviceId: target.deviceId,
821
- switchId,
822
- enabled
3339
+ ok: true,
3340
+ command: {
3341
+ kind: "camera-switch",
3342
+ deviceId: target.deviceId,
3343
+ switchId,
3344
+ enabled
3345
+ }
823
3346
  };
824
3347
  }
825
3348
  if (entity === "reboot") return {
826
- kind: "reboot",
827
- deviceId: target.deviceId
3349
+ ok: true,
3350
+ command: {
3351
+ kind: "reboot",
3352
+ deviceId: target.deviceId
3353
+ }
828
3354
  };
829
3355
  const direction = PTZ_DIRECTION_BY_ENTITY[entity];
830
3356
  if (direction !== void 0) {
831
- if (!target.boundCaps.includes("ptz")) return null;
3357
+ if (!target.boundCaps.includes("ptz")) return {
3358
+ ok: false,
3359
+ reason: "cap-not-bound",
3360
+ capName: "ptz"
3361
+ };
832
3362
  return {
833
- kind: "ptz-move",
834
- deviceId: target.deviceId,
835
- direction
3363
+ ok: true,
3364
+ command: {
3365
+ kind: "ptz-move",
3366
+ deviceId: target.deviceId,
3367
+ direction
3368
+ }
836
3369
  };
837
3370
  }
838
3371
  if (entity === "ptz_preset") {
839
- if (!target.boundCaps.includes("ptz")) return null;
840
- if (value.trim().length === 0) return null;
3372
+ if (!target.boundCaps.includes("ptz")) return {
3373
+ ok: false,
3374
+ reason: "cap-not-bound",
3375
+ capName: "ptz"
3376
+ };
3377
+ if (value.trim().length === 0) return {
3378
+ ok: false,
3379
+ reason: "bad-value",
3380
+ capName: "ptz"
3381
+ };
841
3382
  return {
842
- kind: "ptz-preset",
843
- deviceId: target.deviceId,
844
- preset: value
3383
+ ok: true,
3384
+ command: {
3385
+ kind: "ptz-preset",
3386
+ deviceId: target.deviceId,
3387
+ preset: value
3388
+ }
845
3389
  };
846
3390
  }
847
3391
  if (entity === "snooze") {
848
- if (!SNOOZE_OPTIONS.includes(value)) return null;
3392
+ if (!SNOOZE_OPTIONS.includes(value)) return {
3393
+ ok: false,
3394
+ reason: "bad-value"
3395
+ };
849
3396
  const minutes = SNOOZE_MINUTES[value];
850
3397
  return {
851
- kind: "snooze",
852
- deviceId: target.deviceId,
853
- minutes: minutes ?? null
3398
+ ok: true,
3399
+ command: {
3400
+ kind: "snooze",
3401
+ deviceId: target.deviceId,
3402
+ minutes: minutes ?? null
3403
+ }
854
3404
  };
855
3405
  }
856
- return null;
3406
+ return {
3407
+ ok: false,
3408
+ reason: "unknown-entity"
3409
+ };
857
3410
  }
858
3411
  /**
859
- * The derived half. The entity slug is the capability name, so the
860
- * mapping table decides both the platform and whether it is writable —
861
- * there is no separate list to fall out of sync with the catalog.
3412
+ * The derived half.
3413
+ *
3414
+ * One capability can produce several entities a cover is a state, two
3415
+ * positions and three verbs — so the match is against every id the CATALOG
3416
+ * builds for the cap, through the same two expressions it used
3417
+ * (`derivedEntityId` / `derivedExtraEntityId`). Anything derived from the
3418
+ * catalog rather than re-spelled here cannot address an entity that does
3419
+ * not exist, which is the failure the shared expressions were extracted to
3420
+ * end.
862
3421
  */
863
- for (const [capName, mapping] of Object.entries(CAP_ENTITY_MAP)) {
864
- if (toSlug(capName) !== entity) continue;
865
- if (mapping.writable !== true) return null;
866
- if (!target.boundCaps.includes(capName)) return null;
867
- if (mapping.platform === "button") return {
868
- kind: "cap-button",
869
- deviceId: target.deviceId,
870
- capName
871
- };
872
- const on = parseBool(value);
873
- if (on === null) return null;
3422
+ for (const capName of Object.keys(CAP_ENTITY_MAP)) {
3423
+ const route = CAP_COMMAND_ROUTES[capName];
3424
+ if (derivedEntityId(capName) === entity) {
3425
+ if (CAP_ENTITY_MAP[capName]?.writable !== true) return {
3426
+ ok: false,
3427
+ reason: "not-writable",
3428
+ capName
3429
+ };
3430
+ if (!target.boundCaps.includes(capName)) return {
3431
+ ok: false,
3432
+ reason: "cap-not-bound",
3433
+ capName
3434
+ };
3435
+ return build(route?.primary, target.deviceId, value, capName);
3436
+ }
3437
+ for (const [suffix, extra] of Object.entries(CAP_ENTITY_MAP[capName]?.extras ?? {})) {
3438
+ if (derivedExtraEntityId(capName, suffix) !== entity) continue;
3439
+ if (extra.writable !== true) return {
3440
+ ok: false,
3441
+ reason: "not-writable",
3442
+ capName
3443
+ };
3444
+ if (!target.boundCaps.includes(capName)) return {
3445
+ ok: false,
3446
+ reason: "cap-not-bound",
3447
+ capName
3448
+ };
3449
+ return build(route?.extras?.[suffix], target.deviceId, value, capName);
3450
+ }
3451
+ /**
3452
+ * Command buttons. A button carries no state, so there is no `writable`
3453
+ * to consult — its existence in the catalog IS the declaration, and the
3454
+ * route table is the other half. The payload (`PRESS`) is ignored on
3455
+ * purpose: HA sends whatever `payload_press` says and the verb is
3456
+ * already in the topic.
3457
+ */
3458
+ for (const ref of capCommandEntities(capName)) {
3459
+ if (ref.entity !== entity) continue;
3460
+ if (!target.boundCaps.includes(capName)) return {
3461
+ ok: false,
3462
+ reason: "cap-not-bound",
3463
+ capName
3464
+ };
3465
+ return build(route?.commands?.[ref.suffix], target.deviceId, value, capName);
3466
+ }
3467
+ }
3468
+ return {
3469
+ ok: false,
3470
+ reason: "unknown-entity"
3471
+ };
3472
+ }
3473
+ /** Run a builder, or name the refusal. The one place `no-route` is decided. */
3474
+ function build(builder, deviceId, value, capName) {
3475
+ if (builder === void 0) return {
3476
+ ok: false,
3477
+ reason: "no-route",
3478
+ capName
3479
+ };
3480
+ const command = builder(deviceId, value);
3481
+ if (command === null) return {
3482
+ ok: false,
3483
+ reason: "bad-value",
3484
+ capName
3485
+ };
3486
+ return {
3487
+ ok: true,
3488
+ command
3489
+ };
3490
+ }
3491
+ /**
3492
+ * The camera half, listed rather than the capability half.
3493
+ *
3494
+ * The capability half is the one that grows — every Tier A row adds a
3495
+ * variant — and a narrowing written the other way round would silently send
3496
+ * each new one down the camera switch below. There are five camera
3497
+ * projections and there have been for the life of this file.
3498
+ */
3499
+ function isCameraCommand(command) {
3500
+ switch (command.kind) {
3501
+ case "camera-switch":
3502
+ case "reboot":
3503
+ case "ptz-move":
3504
+ case "ptz-preset":
3505
+ case "snooze": return true;
3506
+ default: return false;
3507
+ }
3508
+ }
3509
+ /** Narrows an `ExportCommand` to the half {@link applyCapCommand} can make. */
3510
+ function isCapCommand(command) {
3511
+ return !isCameraCommand(command);
3512
+ }
3513
+ /**
3514
+ * Make the call the command names.
3515
+ *
3516
+ * Every branch names ONE method on ONE cap. Nothing here falls back to a
3517
+ * neighbouring cap: a device that declares `lock-control` but serves no
3518
+ * provider for it is refused with `no-provider`, and the caller logs the
3519
+ * drop rather than turning a lock into a switch.
3520
+ */
3521
+ async function applyCapCommand(device, command) {
3522
+ switch (command.kind) {
3523
+ case "cap-switch":
3524
+ if (device.switch === void 0) return {
3525
+ ok: false,
3526
+ reason: "no-provider",
3527
+ capName: command.capName
3528
+ };
3529
+ await device.switch.setState({ on: command.on });
3530
+ return { ok: true };
3531
+ case "cap-lock":
3532
+ if (device.lockControl === void 0) return {
3533
+ ok: false,
3534
+ reason: "no-provider",
3535
+ capName: "lock-control"
3536
+ };
3537
+ if (command.locked) await device.lockControl.lock({});
3538
+ else await device.lockControl.unlock({});
3539
+ return { ok: true };
3540
+ case "cap-brightness":
3541
+ if (device.brightness === void 0) return {
3542
+ ok: false,
3543
+ reason: "no-provider",
3544
+ capName: "brightness"
3545
+ };
3546
+ await device.brightness.setBrightness({ percentage: command.percentage });
3547
+ return { ok: true };
3548
+ case "cap-alarm":
3549
+ if (device.alarmPanel === void 0) return {
3550
+ ok: false,
3551
+ reason: "no-provider",
3552
+ capName: "alarm-panel"
3553
+ };
3554
+ /**
3555
+ * **The arm can be REFUSED.** CamStack's own panel throws
3556
+ * `NcAlarmArmRefusedError` when a contact the mode covers is open,
3557
+ * and that refusal is the operator's answer — reporting success
3558
+ * over it would leave Home Assistant showing `armed_away` on a
3559
+ * house with an open door. It is caught HERE rather than left to
3560
+ * the caller's generic catch so the reason is named `refused` and
3561
+ * the panel's own message survives into the log.
3562
+ */
3563
+ try {
3564
+ switch (command.action.kind) {
3565
+ case "arm":
3566
+ await device.alarmPanel.arm({ mode: command.action.mode });
3567
+ break;
3568
+ case "disarm":
3569
+ await device.alarmPanel.disarm({});
3570
+ break;
3571
+ case "trigger":
3572
+ await device.alarmPanel.trigger({});
3573
+ break;
3574
+ }
3575
+ } catch (err) {
3576
+ return {
3577
+ ok: false,
3578
+ reason: "refused",
3579
+ capName: "alarm-panel",
3580
+ error: err instanceof Error ? err.message : String(err)
3581
+ };
3582
+ }
3583
+ return { ok: true };
3584
+ case "cap-button":
3585
+ if (device.button === void 0) return {
3586
+ ok: false,
3587
+ reason: "no-provider",
3588
+ capName: command.capName
3589
+ };
3590
+ await device.button.press({});
3591
+ return { ok: true };
3592
+ case "cover-position": return call(device.cover, "cover", (cover) => cover.setPosition({ position: command.position }));
3593
+ case "cover-tilt": return call(device.cover, "cover", (cover) => cover.setTiltPosition({ tiltPosition: command.tiltPosition }));
3594
+ case "cover-verb": return call(device.cover, "cover", (cover) => {
3595
+ switch (command.verb) {
3596
+ case "open": return cover.open({});
3597
+ case "close": return cover.close({});
3598
+ case "stop": return cover.stop({});
3599
+ }
3600
+ });
3601
+ case "climate-mode": return call(device.climateControl, "climate-control", (climate) => climate.setMode({ mode: command.mode }));
3602
+ case "climate-target": return call(device.climateControl, "climate-control", (climate) => climate.setTarget({ target: command.target }));
3603
+ case "climate-target-humidity": return call(device.climateControl, "climate-control", (climate) => climate.setTargetHumidity({ targetHumidity: command.targetHumidity }));
3604
+ case "climate-swing": return call(device.climateControl, "climate-control", (climate) => command.axis === "vertical" ? climate.setSwingVertical({ on: command.on }) : climate.setSwingHorizontal({ on: command.on }));
3605
+ case "fan-percentage": return call(device.fanControl, "fan-control", (fan) => fan.setPercentage({ percentage: command.percentage }));
3606
+ case "fan-oscillating": return call(device.fanControl, "fan-control", (fan) => fan.setOscillating({ oscillating: command.oscillating }));
3607
+ case "fan-direction": return call(device.fanControl, "fan-control", (fan) => fan.setDirection({ direction: command.direction }));
3608
+ case "humidifier-on": return call(device.humidifier, "humidifier", (humidifier) => humidifier.setOn({ on: command.on }));
3609
+ case "humidifier-humidity": return call(device.humidifier, "humidifier", (humidifier) => humidifier.setTargetHumidity({ humidity: command.humidity }));
3610
+ case "water-heater-temp": return call(device.waterHeater, "water-heater", (heater) => heater.setTargetTemp({ temp: command.temp }));
3611
+ case "water-heater-away": return call(device.waterHeater, "water-heater", (heater) => heater.setAway({ on: command.on }));
3612
+ case "valve-position": return call(device.valve, "valve", (valve) => valve.setPosition({ position: command.position }));
3613
+ case "valve-verb": return call(device.valve, "valve", (valve) => {
3614
+ switch (command.verb) {
3615
+ case "open": return valve.open({});
3616
+ case "close": return valve.close({});
3617
+ case "stop": return valve.stop({});
3618
+ }
3619
+ });
3620
+ case "vacuum-verb": return call(device.vacuumControl, "vacuum-control", (vacuum) => {
3621
+ switch (command.verb) {
3622
+ case "start": return vacuum.start({});
3623
+ case "pause": return vacuum.pause({});
3624
+ case "stop": return vacuum.stop({});
3625
+ case "return-to-base": return vacuum.returnToBase({});
3626
+ case "locate": return vacuum.locate({});
3627
+ }
3628
+ });
3629
+ case "mower-verb": return call(device.lawnMowerControl, "lawn-mower-control", (mower) => {
3630
+ switch (command.verb) {
3631
+ case "start": return mower.startMowing({});
3632
+ case "pause": return mower.pause({});
3633
+ case "dock": return mower.dock({});
3634
+ }
3635
+ });
3636
+ case "media-verb": return call(device.mediaPlayer, "media-player", (media) => {
3637
+ switch (command.verb) {
3638
+ case "play": return media.play({});
3639
+ case "pause": return media.pause({});
3640
+ case "stop": return media.stop({});
3641
+ case "next": return media.next({});
3642
+ case "previous": return media.previous({});
3643
+ }
3644
+ });
3645
+ case "media-volume": return call(device.mediaPlayer, "media-player", (media) => media.setVolume({ volumeLevel: command.volumeLevel }));
3646
+ case "media-mute": return call(device.mediaPlayer, "media-player", (media) => media.setMute({ muted: command.muted }));
3647
+ case "media-shuffle": return call(device.mediaPlayer, "media-player", (media) => media.setShuffle({ shuffle: command.shuffle }));
3648
+ case "media-repeat": return call(device.mediaPlayer, "media-player", (media) => media.setRepeat({ repeat: command.repeat }));
3649
+ case "color-mireds": return call(device.color, "color", (color) => color.setColor({ color: {
3650
+ mode: "mired",
3651
+ mireds: command.mireds
3652
+ } }));
3653
+ case "script-verb": return call(device.scriptRunner, "script-runner", (script) => command.verb === "run" ? script.run({}) : script.stop({}));
3654
+ case "automation-enabled": return call(device.automationControl, "automation-control", (automation) => command.enabled ? automation.enable({}) : automation.disable({}));
3655
+ case "automation-trigger": return call(device.automationControl, "automation-control", (automation) => automation.trigger({}));
3656
+ }
3657
+ }
3658
+ /**
3659
+ * One capability call, with the two outcomes that are not success.
3660
+ *
3661
+ * `no-provider` is a device that DECLARES the cap and serves nothing —
3662
+ * distinct from a throw, which is the device REFUSING. The distinction is the
3663
+ * whole reason `alarm-panel` grew its own try/catch first: an arm the panel
3664
+ * turns down (`NcAlarmArmRefusedError`, a contact the mode covers is open) is
3665
+ * the operator's answer and must reach them with the panel's own words, not
3666
+ * as an optimistic success. Every Tier A method can refuse the same way — a
3667
+ * cover already moving, a vacuum with an empty tank, a climate mode the unit
3668
+ * does not have in `availableModes` — so the handling is here rather than
3669
+ * repeated per branch.
3670
+ */
3671
+ async function call(slice, capName, make) {
3672
+ if (slice === void 0) return {
3673
+ ok: false,
3674
+ reason: "no-provider",
3675
+ capName
3676
+ };
3677
+ try {
3678
+ await make(slice);
3679
+ } catch (err) {
874
3680
  return {
875
- kind: "cap-switch",
876
- deviceId: target.deviceId,
3681
+ ok: false,
3682
+ reason: "refused",
877
3683
  capName,
878
- on
3684
+ error: err instanceof Error ? err.message : String(err)
879
3685
  };
880
3686
  }
881
- return null;
3687
+ return { ok: true };
882
3688
  }
883
3689
  /**
884
3690
  * The ONE derivation of "a signed, expiring URL".
@@ -1363,6 +4169,110 @@ function bool(value) {
1363
4169
  return value ? "true" : "false";
1364
4170
  }
1365
4171
  /**
4172
+ * A value → the string Home Assistant's platform expects.
4173
+ *
4174
+ * Exhaustive over `HaPlatform` so a new platform cannot be added
4175
+ * without deciding what its values look like. Every value is a STRING:
4176
+ * the component lowercases binary values verbatim and a JSON number or
4177
+ * boolean raises inside its state callback, which it swallows — so the
4178
+ * entity silently never updates again.
4179
+ */
4180
+ function renderCapValue(mapping, raw) {
4181
+ switch (mapping.platform) {
4182
+ case "binary_sensor":
4183
+ case "switch": return typeof raw === "boolean" ? bool(raw) : null;
4184
+ case "number": return typeof raw === "number" && Number.isFinite(raw) ? String(raw) : null;
4185
+ case "sensor":
4186
+ if (mapping.deviceClass === "timestamp") {
4187
+ if (typeof raw === "number" && Number.isFinite(raw)) return iso(raw);
4188
+ return typeof raw === "string" ? raw : null;
4189
+ }
4190
+ if (typeof raw === "number") return Number.isFinite(raw) ? String(raw) : null;
4191
+ if (typeof raw === "string") return raw;
4192
+ return typeof raw === "boolean" ? bool(raw) : null;
4193
+ case "select":
4194
+ case "alarm_control_panel":
4195
+ case "image":
4196
+ case "camera": return typeof raw === "string" ? raw : null;
4197
+ case "button": return null;
4198
+ case "cover":
4199
+ case "climate":
4200
+ case "lock":
4201
+ case "fan":
4202
+ case "vacuum":
4203
+ case "valve":
4204
+ case "humidifier":
4205
+ case "water_heater":
4206
+ case "media_player":
4207
+ /**
4208
+ * A native platform carries no value of its own. It is built from
4209
+ * the topics of the DEGRADED descriptors — which are the ones this
4210
+ * function renders — so `CAP_ENTITY_MAP` never names one, and a
4211
+ * descriptor that did would be publishing to a topic no entity
4212
+ * reads. `null` here is `unrenderable`, which the addon LOGS: the
4213
+ * loud failure is the point.
4214
+ */
4215
+ return null;
4216
+ }
4217
+ }
4218
+ /**
4219
+ * One capability slice → the derived entity's value.
4220
+ *
4221
+ * **This is the push path every derived capability was missing.** The
4222
+ * addon used to handle four caps by hand (`device-status`, `battery`,
4223
+ * `doorbell`, `motion`) and the other 23 entries in `CAP_ENTITY_MAP` had
4224
+ * no push path at all — they moved only on the 300 s reconcile, and the
4225
+ * ones whose topics disagreed with the catalog never moved at all. This
4226
+ * is descriptor-driven, so a capability T3 adds to `CAP_ENTITY_MAP`
4227
+ * arrives with its push path already built.
4228
+ */
4229
+ function projectCapSlice(deviceKey, capName, slice) {
4230
+ const mapping = CAP_ENTITY_MAP[capName];
4231
+ if (mapping === void 0) return {
4232
+ kind: "no-entity",
4233
+ capName
4234
+ };
4235
+ const entity = derivedEntityId(capName);
4236
+ const values = [];
4237
+ /**
4238
+ * Every entity the capability produces, not just its primary. A `cover`
4239
+ * is a state AND a position, and projecting only the first would leave
4240
+ * the second in Home Assistant's registry with nothing arriving on it —
4241
+ * which is precisely the defect the descriptor table replaced.
4242
+ */
4243
+ for (const ref of capEntities(capName)) {
4244
+ if (ref.descriptor.platform === "button") continue;
4245
+ if (!Object.prototype.hasOwnProperty.call(slice, ref.descriptor.field)) return {
4246
+ kind: "missing-field",
4247
+ capName,
4248
+ field: ref.descriptor.field,
4249
+ sliceFields: Object.keys(slice)
4250
+ };
4251
+ const raw = slice[ref.descriptor.field];
4252
+ if (raw === null || raw === void 0) continue;
4253
+ const rendered = renderCapValue(ref.descriptor, deriveCapValue(ref.descriptor.derive, raw));
4254
+ if (rendered === null) return {
4255
+ kind: "unrenderable",
4256
+ capName,
4257
+ field: ref.descriptor.field,
4258
+ valueType: typeof raw
4259
+ };
4260
+ values.push({
4261
+ topic: stateTopic(deviceKey, ref.entity),
4262
+ value: rendered
4263
+ });
4264
+ }
4265
+ if (mapping.platform === "button" && mapping.extras === void 0) return {
4266
+ kind: "no-entity",
4267
+ capName
4268
+ };
4269
+ return {
4270
+ kind: "values",
4271
+ entity,
4272
+ values
4273
+ };
4274
+ }
4275
+ /**
1366
4276
  * Reachability.
1367
4277
  *
1368
4278
  * `device.sleeping` deliberately does NOT feed this: a battery camera in
@@ -1889,6 +4799,22 @@ function projectSynthetic(input, nowMs) {
1889
4799
  }
1890
4800
  return values;
1891
4801
  }
4802
+ //#endregion
4803
+ //#region src/ha-export/reconcile-fingerprint.ts
4804
+ function structureFingerprint(exported) {
4805
+ return JSON.stringify([...exported.keys()].sort().map((key) => {
4806
+ const entry = exported.get(key);
4807
+ return [
4808
+ key,
4809
+ entry?.brokerIds,
4810
+ entry?.plan
4811
+ ];
4812
+ }));
4813
+ }
4814
+ /** Only an event-debounced pass with an unchanged structure may skip. */
4815
+ function shouldSkipAnnounce(reason, fingerprint, lastFingerprint) {
4816
+ return reason === "structure-changed" && fingerprint === lastFingerprint;
4817
+ }
1892
4818
  /** The `BrokerInfo.kind` tag the Home Assistant provider stamps. */
1893
4819
  var HA_BROKER_KIND = "home-assistant";
1894
4820
  /**
@@ -1975,6 +4901,17 @@ var HaExportAddon = class extends require_dist.BaseAddon {
1975
4901
  /** A reason asked for while one was running. Re-run, never dropped. */
1976
4902
  reconcilePending = null;
1977
4903
  lastError;
4904
+ /**
4905
+ * Fingerprint of the last ANNOUNCED structure. An event-debounced
4906
+ * reconcile that rebuilds an identical structure skips the announce and
4907
+ * the full state push: a flapping camera (RTSP retry every 4 s emits a
4908
+ * device event per attempt) was driving a full 2.7 s reconcile every
4909
+ * ~13 s, forever. Only the 'structure-changed' reason short-circuits —
4910
+ * 'boot', 'periodic' and config-driven passes always announce, so the
4911
+ * D8/D11 contract (events are an optimisation, never a substitute)
4912
+ * still holds through the periodic pass.
4913
+ */
4914
+ lastStructureFingerprint;
1978
4915
  entityCount = 0;
1979
4916
  /** deviceId → the `lastPressedAt` already rung, so a re-read does not ring again. */
1980
4917
  lastDoorbellPressAt = /* @__PURE__ */ new Map();
@@ -1982,6 +4919,15 @@ var HaExportAddon = class extends require_dist.BaseAddon {
1982
4919
  doorbellReleaseTimers = /* @__PURE__ */ new Map();
1983
4920
  unclassified = [];
1984
4921
  /**
4922
+ * What each Home Assistant's custom component can BUILD.
4923
+ *
4924
+ * The hub and the component ship on different trains — the operator
4925
+ * updates the integration through HACS when they decide to — so a
4926
+ * platform this hub knows about is not one the component has. Refreshed
4927
+ * at the top of every reconcile, before any plan is built.
4928
+ */
4929
+ componentSupport = new ComponentSupportTracker();
4930
+ /**
1985
4931
  * The rules as of the last reconcile, so a command arriving on
1986
4932
  * `rule_<slug>` can be turned back into the rule id it came from. Refreshed
1987
4933
  * whenever the synthetic devices are, never written to.
@@ -2116,6 +5062,19 @@ var HaExportAddon = class extends require_dist.BaseAddon {
2116
5062
  this.reconcileSoon();
2117
5063
  });
2118
5064
  }
5065
+ /**
5066
+ * A slice changed → the entities that read it.
5067
+ *
5068
+ * Two halves, exactly as the catalog has two halves. `device-status`
5069
+ * and `battery` are projected by hand for BOTH kinds — their topics are
5070
+ * the same on a camera and on a base device, and `battery` deliberately
5071
+ * publishes nothing when the device reports a low-battery INDICATOR
5072
+ * rather than a level, which a descriptor cannot express. Everything
5073
+ * else on a camera is a projection of pipeline output, and everything
5074
+ * else on a base device is derived from `CAP_ENTITY_MAP` — the same
5075
+ * branch `buildDevicePlan` takes, so the value can only land on an
5076
+ * entity the plan actually built.
5077
+ */
2119
5078
  onSliceChanged(data) {
2120
5079
  const deviceId = readNumber(data, "deviceId");
2121
5080
  const capName = readString(data, "capName");
@@ -2123,6 +5082,19 @@ var HaExportAddon = class extends require_dist.BaseAddon {
2123
5082
  if (deviceId === null || capName === null || slice === null) return;
2124
5083
  const deviceKey = this.keyByDeviceId.get(deviceId);
2125
5084
  if (deviceKey === void 0) return;
5085
+ if (capName !== "device-status" && capName !== "battery" && !this.isCamera(deviceKey)) {
5086
+ this.pushDerivedCap(deviceId, deviceKey, capName, slice);
5087
+ return;
5088
+ }
5089
+ /**
5090
+ * The two capabilities a CAMERA carries from the derived half. Same
5091
+ * descriptor, same entity id, same projector — the camera catalog
5092
+ * simply builds the component instead of `buildDerivedPlan`.
5093
+ */
5094
+ if (CAMERA_DERIVED_CAPS.includes(capName)) {
5095
+ this.pushDerivedCap(deviceId, deviceKey, capName, slice);
5096
+ return;
5097
+ }
2126
5098
  if (capName === "device-status") {
2127
5099
  const online = slice["online"];
2128
5100
  if (typeof online === "boolean") this.push(deviceId, projectDeviceStatus(deviceKey, online));
@@ -2186,6 +5158,61 @@ var HaExportAddon = class extends require_dist.BaseAddon {
2186
5158
  }));
2187
5159
  }
2188
5160
  }
5161
+ /** Camera or base kind — the same question `buildDevicePlan` asks. */
5162
+ isCamera(deviceKey) {
5163
+ return this.exported.get(deviceKey)?.target.type === "camera";
5164
+ }
5165
+ /**
5166
+ * The generic derived push: any capability in `CAP_ENTITY_MAP`.
5167
+ *
5168
+ * Every branch that produces no value SAYS so, once per
5169
+ * (device, cap, field): an entity that exists and never receives a
5170
+ * value used to be indistinguishable from an entity nothing had
5171
+ * happened to, and that is how 23 of the 27 mapped capabilities went
5172
+ * unnoticed with no push path at all. Once, because a slice that
5173
+ * changes every second must not turn a catalog defect into a log
5174
+ * flood — the first line already carries everything the fix needs.
5175
+ */
5176
+ pushDerivedCap(deviceId, deviceKey, capName, slice) {
5177
+ const projection = projectCapSlice(deviceKey, capName, slice);
5178
+ switch (projection.kind) {
5179
+ case "values":
5180
+ if (projection.values.length > 0) this.push(deviceId, projection.values);
5181
+ return;
5182
+ case "no-entity": return;
5183
+ case "missing-field":
5184
+ this.warnOnce(deviceId, capName, projection.field, {
5185
+ message: "ha-export: a derived entity reads a field its slice does not carry",
5186
+ meta: {
5187
+ capName,
5188
+ field: projection.field,
5189
+ sliceFields: projection.sliceFields
5190
+ }
5191
+ });
5192
+ return;
5193
+ case "unrenderable":
5194
+ this.warnOnce(deviceId, capName, projection.field, {
5195
+ message: "ha-export: a derived entity cannot render the value its slice carries",
5196
+ meta: {
5197
+ capName,
5198
+ field: projection.field,
5199
+ valueType: projection.valueType
5200
+ }
5201
+ });
5202
+ return;
5203
+ }
5204
+ }
5205
+ /** deviceId:cap:field already reported. Bounded by devices × mapped caps. */
5206
+ warnedCapFields = /* @__PURE__ */ new Set();
5207
+ warnOnce(deviceId, capName, field, line) {
5208
+ const key = `${deviceId}:${capName}:${field}`;
5209
+ if (this.warnedCapFields.has(key)) return;
5210
+ this.warnedCapFields.add(key);
5211
+ this.ctx.logger.warn(line.message, {
5212
+ tags: { deviceId },
5213
+ meta: line.meta
5214
+ });
5215
+ }
2189
5216
  onOnlineChanged(data, online) {
2190
5217
  const deviceId = readNumber(data, "deviceId");
2191
5218
  if (deviceId === null) return;
@@ -2199,6 +5226,17 @@ var HaExportAddon = class extends require_dist.BaseAddon {
2199
5226
  if (deviceId === null || typeof detected !== "boolean") return;
2200
5227
  const deviceKey = this.keyByDeviceId.get(deviceId);
2201
5228
  if (deviceKey === void 0) return;
5229
+ /**
5230
+ * A base device's motion entity is `motion`, a camera's is
5231
+ * `motion_detected` + `triggered`. The cap event is the same one, so
5232
+ * the projection has to follow the device's own half of the catalog —
5233
+ * publishing the camera topics for a PIR sensor addressed three
5234
+ * entities the sensor's plan never built.
5235
+ */
5236
+ if (!this.isCamera(deviceKey)) {
5237
+ this.pushDerivedCap(deviceId, deviceKey, "motion", { detected });
5238
+ return;
5239
+ }
2202
5240
  const timestamp = readNumber(data, "timestamp") ?? Date.now();
2203
5241
  this.push(deviceId, projectMotion(deviceKey, {
2204
5242
  detected,
@@ -2390,6 +5428,7 @@ var HaExportAddon = class extends require_dist.BaseAddon {
2390
5428
  try {
2391
5429
  await this.refreshBrokers();
2392
5430
  await this.refreshMediaBaseUrl();
5431
+ await this.refreshComponentSupport();
2393
5432
  const enabled = this.enabledBrokerIds();
2394
5433
  const devices = await this.ctx.api.deviceManager.listAll.query({});
2395
5434
  const snapshots = await this.ctx.api.deviceState.getAllSnapshots.query({});
@@ -2397,6 +5436,7 @@ var HaExportAddon = class extends require_dist.BaseAddon {
2397
5436
  const exported = /* @__PURE__ */ new Map();
2398
5437
  const keyByDeviceId = /* @__PURE__ */ new Map();
2399
5438
  const allCaps = /* @__PURE__ */ new Set();
5439
+ const exportedDeviceLog = [];
2400
5440
  let entityCount = 0;
2401
5441
  for (const deviceIdStr of exposedDeviceIds(this.config.membership, enabled)) {
2402
5442
  const numericId = Number(deviceIdStr);
@@ -2420,8 +5460,15 @@ var HaExportAddon = class extends require_dist.BaseAddon {
2420
5460
  }
2421
5461
  const catalogDevice = await this.buildCatalogDevice(device, snapshots[String(numericId)]);
2422
5462
  for (const cap of catalogDevice.boundCaps) allCaps.add(cap);
2423
- const plan = buildDevicePlan(catalogDevice);
2424
5463
  const brokerIds = brokersExposing(this.config.membership, deviceIdStr).filter((id) => enabled.includes(id));
5464
+ /**
5465
+ * The plan is built ONCE and announced to every broker that
5466
+ * receives the device, so it can only use platforms ALL of them
5467
+ * build — see `intersectSupport`. One instance on an old component
5468
+ * therefore holds its device back and nobody's entities silently
5469
+ * fail to arrive.
5470
+ */
5471
+ const plan = buildDevicePlan(catalogDevice, { platforms: this.componentSupport.supportForAll(brokerIds) });
2425
5472
  exported.set(plan.deviceKey, {
2426
5473
  plan,
2427
5474
  deviceId: numericId,
@@ -2430,7 +5477,8 @@ var HaExportAddon = class extends require_dist.BaseAddon {
2430
5477
  type: device.type,
2431
5478
  boundCaps: catalogDevice.boundCaps
2432
5479
  },
2433
- brokerIds
5480
+ brokerIds,
5481
+ streams: catalogDevice.streams ?? []
2434
5482
  });
2435
5483
  keyByDeviceId.set(numericId, plan.deviceKey);
2436
5484
  entityCount += Object.keys(plan.cmps).length;
@@ -2439,16 +5487,16 @@ var HaExportAddon = class extends require_dist.BaseAddon {
2439
5487
  * device being DROPPED; without this one an operator can see why
2440
5488
  * a device is missing but cannot confirm which devices were
2441
5489
  * actually exported, to which Home Assistant, with which
2442
- * entities. Silence reads as "never happened" both ways.
5490
+ * entities. Silence reads as "never happened" both ways. Deferred
5491
+ * until the announce decision: a skipped identical pass must not
5492
+ * repeat the whole roster either.
2443
5493
  */
2444
- this.ctx.logger.info("ha-export: exporting device", {
2445
- tags: { deviceId: numericId },
2446
- meta: {
2447
- deviceType: device.type,
2448
- deviceKey: plan.deviceKey,
2449
- brokers: brokerIds,
2450
- entities: Object.keys(plan.cmps).length
2451
- }
5494
+ exportedDeviceLog.push({
5495
+ deviceId: numericId,
5496
+ deviceType: device.type,
5497
+ deviceKey: plan.deviceKey,
5498
+ brokers: brokerIds,
5499
+ entities: Object.keys(plan.cmps).length
2452
5500
  });
2453
5501
  }
2454
5502
  /**
@@ -2469,7 +5517,8 @@ var HaExportAddon = class extends require_dist.BaseAddon {
2469
5517
  type: "synthetic",
2470
5518
  boundCaps: []
2471
5519
  },
2472
- brokerIds: [...enabled]
5520
+ brokerIds: [...enabled],
5521
+ streams: []
2473
5522
  });
2474
5523
  entityCount += Object.keys(plan.cmps).length;
2475
5524
  }
@@ -2477,6 +5526,25 @@ var HaExportAddon = class extends require_dist.BaseAddon {
2477
5526
  this.keyByDeviceId = keyByDeviceId;
2478
5527
  this.entityCount = entityCount;
2479
5528
  this.unclassified = unclassifiedCaps([...allCaps]);
5529
+ const fingerprint = structureFingerprint(exported);
5530
+ if (shouldSkipAnnounce(reason, fingerprint, this.lastStructureFingerprint)) {
5531
+ this.ctx.logger.debug("ha-export: structure unchanged, skipping announce", { meta: {
5532
+ devices: exported.size,
5533
+ entities: entityCount
5534
+ } });
5535
+ this.lastError = void 0;
5536
+ return;
5537
+ }
5538
+ this.lastStructureFingerprint = fingerprint;
5539
+ for (const line of exportedDeviceLog) this.ctx.logger.info("ha-export: exporting device", {
5540
+ tags: { deviceId: line.deviceId },
5541
+ meta: {
5542
+ deviceType: line.deviceType,
5543
+ deviceKey: line.deviceKey,
5544
+ brokers: line.brokers,
5545
+ entities: line.entities
5546
+ }
5547
+ });
2480
5548
  await this.announce(exported);
2481
5549
  await this.pushFullState(exported, snapshots);
2482
5550
  if (synthetic !== null) await this.pushSynthetic(projectSynthetic(synthetic, Date.now()), [...enabled]);
@@ -2616,7 +5684,65 @@ var HaExportAddon = class extends require_dist.BaseAddon {
2616
5684
  }));
2617
5685
  }
2618
5686
  }
5687
+ /**
5688
+ * The derived half, from the snapshot the reconcile already read.
5689
+ *
5690
+ * The reconcile is the CONTRACT — events are an optimisation over it
5691
+ * (D8) — so every entity it announces must also get a value from it.
5692
+ * It used to push only `device-status` and `battery`, which is why a
5693
+ * derived entity whose event path was missing stayed blank for ever
5694
+ * rather than for one interval.
5695
+ */
5696
+ {
5697
+ /**
5698
+ * The derived half, and the two capabilities a CAMERA carries from
5699
+ * it (`CAMERA_DERIVED_CAPS`). A camera takes the hand-written
5700
+ * catalog, so every other cap slice on one addresses no component;
5701
+ * these two do, because the camera catalog builds them from the
5702
+ * same descriptor and the same entity id.
5703
+ */
5704
+ const isCamera = entry.target.type === "camera";
5705
+ for (const [capName, capSlice] of Object.entries(snapshot)) {
5706
+ if (capName === "device-status" || capName === "battery") continue;
5707
+ if (isCamera && !CAMERA_DERIVED_CAPS.includes(capName)) continue;
5708
+ const projection = projectCapSlice(key, capName, capSlice);
5709
+ if (projection.kind === "values") values.push(...projection.values);
5710
+ else if (projection.kind === "missing-field") this.warnOnce(entry.deviceId, capName, projection.field, {
5711
+ message: "ha-export: a derived entity reads a field its slice does not carry",
5712
+ meta: {
5713
+ capName,
5714
+ field: projection.field,
5715
+ sliceFields: projection.sliceFields
5716
+ }
5717
+ });
5718
+ else if (projection.kind === "unrenderable") this.warnOnce(entry.deviceId, capName, projection.field, {
5719
+ message: "ha-export: a derived entity cannot render the value its slice carries",
5720
+ meta: {
5721
+ capName,
5722
+ field: projection.field,
5723
+ valueType: projection.valueType
5724
+ }
5725
+ });
5726
+ }
5727
+ }
2619
5728
  if (entry.target.type === "camera") {
5729
+ /**
5730
+ * The live picture. A refused URL is DROPPED WORK and says so: an
5731
+ * entity Home Assistant shows and never loads is indistinguishable
5732
+ * from a camera that is down, and `credentials-inline` in
5733
+ * particular means a source handed us the camera password — which
5734
+ * is a defect to fix, not a value to publish.
5735
+ */
5736
+ const streams = cameraStreamValues(key, entry.streams);
5737
+ values.push(...streams.values);
5738
+ for (const refusal of streams.refused) this.warnOnce(entry.deviceId, "camera-streams", refusal.entity, {
5739
+ message: "ha-export: a camera entity exists and its stream URL cannot be published",
5740
+ meta: {
5741
+ entity: refusal.entity,
5742
+ profile: refusal.profile,
5743
+ reason: refusal.reason
5744
+ }
5745
+ });
2620
5746
  const group = await this.ctx.api.pipelineOrchestrator.getCameraSwitches.query({ deviceId: entry.deviceId });
2621
5747
  values.push(...projectCameraSwitches(key, group.switches.map((sw) => ({
2622
5748
  id: sw.id,
@@ -2645,6 +5771,7 @@ var HaExportAddon = class extends require_dist.BaseAddon {
2645
5771
  const zones = device.type === "camera" ? await this.loadZones(device.id) : [];
2646
5772
  const switches = device.type === "camera" ? await this.loadSwitches(device.id) : [];
2647
5773
  const ptzPresets = device.type === "camera" && boundCaps.includes("ptz") ? await this.loadPresets(device.id) : [];
5774
+ const streams = device.type === "camera" && boundCaps.includes("camera-streams") ? await this.loadStreamProfiles(device.id) : [];
2648
5775
  const manufacturer = readString(device.metadata ?? {}, "manufacturer");
2649
5776
  const model = readString(device.metadata ?? {}, "model");
2650
5777
  return {
@@ -2659,9 +5786,60 @@ var HaExportAddon = class extends require_dist.BaseAddon {
2659
5786
  zones,
2660
5787
  switches,
2661
5788
  ptzPresets,
2662
- slices: Object.keys(snapshot)
5789
+ streams,
5790
+ slices: Object.keys(snapshot),
5791
+ /**
5792
+ * The slice VALUES, for the native platforms only: a native
5793
+ * `climate` announces the modes the device accepts, and
5794
+ * `availableModes` is on the slice and nowhere else. See
5795
+ * `CatalogDevice.capSlices`.
5796
+ */
5797
+ capSlices: snapshot
2663
5798
  };
2664
5799
  }
5800
+ /**
5801
+ * The camera's assigned profile slots, as broker restream URLs.
5802
+ *
5803
+ * `getProfileRtspEntries`, never `streamCatalog.getCatalog`: the latter
5804
+ * returns the camera's NATIVE url with its password inline, and this
5805
+ * value is written into Home Assistant's entity registry and its debug
5806
+ * logs. `camera-entities.ts` refuses a credentialed URL as well — two
5807
+ * gates, because the cost of one of them being wrong is the operator's
5808
+ * camera password on a host we do not own.
5809
+ *
5810
+ * `hostname` is passed EXPLICITLY. The broker binds `127.0.0.1` and
5811
+ * mints its URLs against whatever address it was asked for; Home
5812
+ * Assistant is not on the hub, so omitting it produces entities that
5813
+ * exist and never load — the identical failure the image entities had
5814
+ * before `publicBaseUrl`.
5815
+ */
5816
+ async loadStreamProfiles(deviceId) {
5817
+ const hostname = hostnameFromBaseUrl(this.mediaBaseUrl);
5818
+ if (hostname === null) {
5819
+ this.ctx.logger.warn("ha-export: no reachable hub hostname — this camera gets no live camera entity", {
5820
+ tags: { deviceId },
5821
+ meta: { mediaBaseUrl: this.mediaBaseUrl }
5822
+ });
5823
+ return [];
5824
+ }
5825
+ try {
5826
+ return (await this.ctx.api.cameraStreams.getProfileRtspEntries.query({
5827
+ deviceId,
5828
+ hostname
5829
+ })).map((entry) => ({
5830
+ profile: entry.profile,
5831
+ url: entry.url,
5832
+ mutedUrl: entry.mutedUrl,
5833
+ enabled: entry.enabled
5834
+ }));
5835
+ } catch (err) {
5836
+ this.ctx.logger.warn("ha-export: could not read stream profiles, no camera entity", {
5837
+ tags: { deviceId },
5838
+ meta: { error: errMsg(err) }
5839
+ });
5840
+ return [];
5841
+ }
5842
+ }
2665
5843
  async loadBoundCaps(deviceId) {
2666
5844
  try {
2667
5845
  return (await this.ctx.api.deviceManager.getBindings.query({ deviceId })).entries.map((entry) => entry.capName);
@@ -2769,6 +5947,7 @@ var HaExportAddon = class extends require_dist.BaseAddon {
2769
5947
  if (wanted.has(brokerId)) continue;
2770
5948
  await link.client.dispose();
2771
5949
  this.links.delete(brokerId);
5950
+ this.componentSupport.forget(brokerId);
2772
5951
  this.ctx.logger.info("ha-export: stopped exporting to a broker", { meta: { brokerId } });
2773
5952
  }
2774
5953
  for (const broker of haBrokers) {
@@ -2796,6 +5975,75 @@ var HaExportAddon = class extends require_dist.BaseAddon {
2796
5975
  } });
2797
5976
  }
2798
5977
  }
5978
+ /**
5979
+ * Ask every live link what its component builds.
5980
+ *
5981
+ * Sequential and cheap — one GET per Home Assistant per reconcile, and
5982
+ * there is one Home Assistant on almost every installation. A link that
5983
+ * does not answer keeps whatever was negotiated last: the tracker treats
5984
+ * silence as no information, because the alternative is that one
5985
+ * timeout migrates every native entity back to a sensor.
5986
+ */
5987
+ async refreshComponentSupport() {
5988
+ for (const [brokerId, link] of this.links) {
5989
+ const probe = await this.probeComponent(link);
5990
+ const change = this.componentSupport.observe(brokerId, probe);
5991
+ if (change.withheldDowngrade) {
5992
+ /**
5993
+ * D49. The read that DESTROYS work needs a second read to agree,
5994
+ * and the operator gets to see that this is what happened rather
5995
+ * than watching their entities migrate on one bad answer.
5996
+ */
5997
+ 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: {
5998
+ brokerId,
5999
+ current: supportSignature(change.support),
6000
+ reported: probe.kind === "reported" ? supportSignature(probe.report.platforms) : "none"
6001
+ } });
6002
+ continue;
6003
+ }
6004
+ if (!change.changed) continue;
6005
+ this.ctx.logger.info("ha-export: negotiated the Home Assistant component platform set", { meta: {
6006
+ brokerId,
6007
+ componentVersion: probe.kind === "reported" ? probe.report.version ?? "unknown" : "pre-0.4.0",
6008
+ platforms: supportSignature(change.support)
6009
+ } });
6010
+ }
6011
+ }
6012
+ /**
6013
+ * One `GET /api/camstack/version`, turned into the three answers the
6014
+ * tracker distinguishes.
6015
+ *
6016
+ * A 404 is the OLD component saying it has no such endpoint, which is
6017
+ * information. Anything else — a timeout, a 500, a rotated token — is
6018
+ * not, and must not be read as one.
6019
+ */
6020
+ async probeComponent(link) {
6021
+ try {
6022
+ const response = await fetch(`${link.baseUrl.replace(/\/+$/, "")}${COMPONENT_VERSION_PATH}`, {
6023
+ headers: { authorization: `Bearer ${link.token}` },
6024
+ signal: AbortSignal.timeout(1e4)
6025
+ });
6026
+ if (response.status === 404 || response.status === 405) return { kind: "absent" };
6027
+ if (!response.ok) return {
6028
+ kind: "unknown",
6029
+ error: `Home Assistant answered ${response.status}`
6030
+ };
6031
+ const report = parseComponentReport(await response.json());
6032
+ if (report === null) return {
6033
+ kind: "unknown",
6034
+ error: "the component answered without a platform list"
6035
+ };
6036
+ return {
6037
+ kind: "reported",
6038
+ report
6039
+ };
6040
+ } catch (err) {
6041
+ return {
6042
+ kind: "unknown",
6043
+ error: errMsg(err)
6044
+ };
6045
+ }
6046
+ }
2799
6047
  async resolveBrokerConnection(brokerId, addonId) {
2800
6048
  try {
2801
6049
  const raw = await this.ctx.api.broker.getBrokerConfig.query({
@@ -2924,25 +6172,56 @@ var HaExportAddon = class extends require_dist.BaseAddon {
2924
6172
  }
2925
6173
  return;
2926
6174
  }
2927
- const command = resolveCommand(exported.target, parsed.entity, value);
2928
- if (command === null) {
2929
- this.ctx.logger.warn("ha-export: dropping an unroutable command", {
6175
+ const resolution = resolveCommand(exported.target, parsed.entity, value);
6176
+ if (!resolution.ok) {
6177
+ /**
6178
+ * The reason is NAMED. "Unroutable" covered five different faults —
6179
+ * a typo in a topic, a capability the device does not declare, a
6180
+ * payload the platform cannot carry, and a control the catalog
6181
+ * advertised with no route behind it — and an operator watching a
6182
+ * switch snap back in Home Assistant could not tell which.
6183
+ */
6184
+ this.ctx.logger.warn("ha-export: dropping a command it cannot route", {
2930
6185
  tags: { deviceId: exported.deviceId },
2931
6186
  meta: {
2932
6187
  topic,
2933
- entity: parsed.entity
6188
+ entity: parsed.entity,
6189
+ reason: resolution.reason,
6190
+ ...resolution.capName !== void 0 ? { capName: resolution.capName } : {}
2934
6191
  }
2935
6192
  });
2936
6193
  reply.status(422);
2937
- reply.send({ error: "unroutable command" });
6194
+ reply.send({ error: `unroutable command (${resolution.reason})` });
2938
6195
  return;
2939
6196
  }
2940
- const applied = await this.dispatch(command);
6197
+ const applied = await this.dispatch(resolution.command);
2941
6198
  reply.status(applied ? 200 : 422);
2942
6199
  reply.send(applied ? {} : { error: "command not applied" });
2943
6200
  }
2944
6201
  async dispatch(command) {
2945
6202
  try {
6203
+ /**
6204
+ * The capability half goes through the route table that NAMED the
6205
+ * method — `lockControl.lock`, `brightness.setBrightness`,
6206
+ * `alarmPanel.arm`, `switch.setState`. It used to end here
6207
+ * regardless of the capability, calling `device.switch.setState`
6208
+ * for all of them, so a lock got `noProvider` and a 422 and a
6209
+ * brightness command never arrived at all.
6210
+ */
6211
+ if (isCapCommand(command)) {
6212
+ const outcome = await applyCapCommand(await this.ctx.fetchDevice(command.deviceId), command);
6213
+ if (outcome.ok) return true;
6214
+ if (outcome.reason === "no-provider") return this.noProvider(command.deviceId, outcome.capName);
6215
+ this.ctx.logger.warn("ha-export: the device refused the command", {
6216
+ tags: { deviceId: command.deviceId },
6217
+ meta: {
6218
+ capName: outcome.capName,
6219
+ kind: command.kind,
6220
+ ...outcome.error !== void 0 ? { error: outcome.error } : {}
6221
+ }
6222
+ });
6223
+ return false;
6224
+ }
2946
6225
  switch (command.kind) {
2947
6226
  case "camera-switch": {
2948
6227
  const parsed = require_dist.CameraSwitchIdSchema.safeParse(command.switchId);
@@ -2987,18 +6266,6 @@ var HaExportAddon = class extends require_dist.BaseAddon {
2987
6266
  return true;
2988
6267
  }
2989
6268
  case "snooze": return await this.applySnooze(command.deviceId, command.minutes);
2990
- case "cap-switch": {
2991
- const device = await this.ctx.fetchDevice(command.deviceId);
2992
- if (device.switch === void 0) return this.noProvider(command.deviceId, "switch");
2993
- await device.switch.setState({ on: command.on });
2994
- return true;
2995
- }
2996
- case "cap-button": {
2997
- const device = await this.ctx.fetchDevice(command.deviceId);
2998
- if (device.button === void 0) return this.noProvider(command.deviceId, "button");
2999
- await device.button.press({});
3000
- return true;
3001
- }
3002
6269
  }
3003
6270
  } catch (err) {
3004
6271
  /**