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