@camstack/addon-provider-homeassistant 1.2.14 → 1.2.16

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.
@@ -0,0 +1,2511 @@
1
+ import { Ct as EventCategory, O as deviceExportCapability, c as addonRoutesCapability, i as CameraSwitchIdSchema, r as COCO_TO_MACRO, st as BaseAddon, v as buildAddonRouteProvider, yt as string } from "../dist-B04tcTnS.mjs";
2
+ import { createHmac, timingSafeEqual } from "node:crypto";
3
+ //#region src/ha-export/topics.ts
4
+ /**
5
+ * The topic grammar. Every topic in the export is built HERE.
6
+ *
7
+ * The previous HA exporter learned this the hard way: deriving one topic
8
+ * by string-editing another (`stateTopic.replace(/\/[^/]+$/, '/availability')`)
9
+ * broke the moment two components had different layouts. There is one
10
+ * builder per topic kind and no exceptions.
11
+ *
12
+ * A topic is a wire format. `state` and `command` are matched verbatim by
13
+ * the component, so a change here silently detaches every entity from its
14
+ * value.
15
+ */
16
+ /** Root of every topic. Namespaces the export inside HA's event bus. */
17
+ var TOPIC_ROOT = "camstack";
18
+ /** Prefix that turns a device's `stableId` into its HA `device_id`. */
19
+ var DEVICE_KEY_PREFIX = "camstack-";
20
+ /**
21
+ * The HA `device_id` for a camstack device.
22
+ *
23
+ * Derived from `stableId`, never from the numeric id: numeric ids are
24
+ * reallocated by a re-sync, and an operator who re-adopts a camera would
25
+ * get a second HA device with every automation pointing at the orphan.
26
+ */
27
+ function deviceKeyFor(stableId) {
28
+ return `${DEVICE_KEY_PREFIX}${stableId}`;
29
+ }
30
+ function stateTopic(deviceKey, entity) {
31
+ return `${TOPIC_ROOT}/${deviceKey}/${entity}`;
32
+ }
33
+ function commandTopic(deviceKey, entity) {
34
+ return `${TOPIC_ROOT}/${deviceKey}/${entity}/set`;
35
+ }
36
+ function parseCommandTopic(topic) {
37
+ const parts = topic.split("/");
38
+ if (parts.length !== 4) return null;
39
+ if (parts[0] !== "camstack") return null;
40
+ if (parts[3] !== "set") return null;
41
+ const deviceKey = parts[1];
42
+ const entity = parts[2];
43
+ if (deviceKey === void 0 || entity === void 0) return null;
44
+ if (deviceKey.length === 0 || entity.length === 0) return null;
45
+ return {
46
+ deviceKey,
47
+ entity
48
+ };
49
+ }
50
+ /** `Person last image` ← `person_last_image`. Display only, never an id. */
51
+ function humanise(entity) {
52
+ const words = entity.replace(/[_-]+/g, " ").trim();
53
+ return words.charAt(0).toUpperCase() + words.slice(1);
54
+ }
55
+ /**
56
+ * `person_detected` → `person-detected`.
57
+ *
58
+ * Component keys are kebab; entity/topic segments are snake. They are two
59
+ * different namespaces and conflating them produces entities that build
60
+ * but never receive a value.
61
+ */
62
+ function toComponentKey(platform, entity) {
63
+ return `${platform}-${entity.replace(/_/g, "-").replace(/[^a-zA-Z0-9_-]/g, "_")}`;
64
+ }
65
+ /** Lower-case, underscore-joined, diacritic-free. Ids only, never labels. */
66
+ function toSlug(value) {
67
+ 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
+ }
69
+ //#endregion
70
+ //#region src/ha-export/entity-catalog.ts
71
+ /**
72
+ * The entity catalog — pure. Device + zones + macros → the `cmps` set.
73
+ *
74
+ * No I/O lives here on purpose: this is the 1:1 mapping the design
75
+ * agreed, and it is where the mapping is tested. A change to this file
76
+ * changes what an operator sees in Home Assistant, so it is tested
77
+ * rather than reasoned about.
78
+ *
79
+ * The split is principled, not expedient (design addendum):
80
+ *
81
+ * - **Cameras** get a HAND-WRITTEN catalog, because their entities are
82
+ * projections of pipeline and notification-center output — there is
83
+ * no capability to derive `last_detection`, `last_label` or a
84
+ * per-zone count from.
85
+ * - **Every other kind** is DERIVED from the capability the device
86
+ * declares. A switch's state IS modelled by a cap; an alarm panel's
87
+ * state IS `AlarmStateSchema`.
88
+ *
89
+ * Two guards protect the half that a future change would silently break:
90
+ * `MACROS_NOT_EXPORTED` and `CAPS_NOT_EXPORTED` force a written decision
91
+ * for every macro and every mapped capability. Something declared that
92
+ * nothing produces is the failure this repo keeps paying for.
93
+ */
94
+ /**
95
+ * The macro classes an operator automates on.
96
+ *
97
+ * Deliberately the macros and NOT the 21 fine classes: those include
98
+ * `kite` and `toothbrush`, and 21 × 4 entities per camera buys nothing.
99
+ */
100
+ var EXPORTED_MACROS = [
101
+ "motion",
102
+ "audio",
103
+ "person",
104
+ "vehicle",
105
+ "animal",
106
+ "package"
107
+ ];
108
+ /**
109
+ * Macros that carry an IDENTIFICATION: the recognised face for `person`,
110
+ * the plate for `vehicle`, the sub-class for `animal`.
111
+ *
112
+ * `package`, `motion` and `audio` have no label, so the entity is NOT
113
+ * created for them rather than created empty — an entity that can never
114
+ * receive a value reads to an operator as a broken integration.
115
+ */
116
+ var LABELLED_MACROS = [
117
+ "person",
118
+ "vehicle",
119
+ "animal"
120
+ ];
121
+ /** Per zone the macro set is restricted — matching the reference. */
122
+ var ZONE_MACROS = [
123
+ "person",
124
+ "vehicle",
125
+ "animal"
126
+ ];
127
+ /**
128
+ * `enabled_by_default: false` is a DECISION, taken per entity, never
129
+ * inferred from its name or its platform.
130
+ *
131
+ * It used to be inferred — a name-and-platform heuristic written for the
132
+ * camera catalog (`endsWith('_detected')`, `platform === 'switch'`) and
133
+ * then applied to every device the catalog builds. On a camera it is
134
+ * right: three zones is ~73 entities and the fleet is ~880, so only what
135
+ * an operator automates on arrives enabled. On a DERIVED device it was
136
+ * simply wrong — every sensor, every number and the alarm panel matched
137
+ * nothing and shipped switched off, so a temperature sensor exported its
138
+ * temperature disabled and the operator found the thing they installed
139
+ * the integration for turned off. Measured on the live hub, the whole
140
+ * derived half is **228 entities across 115 devices — 2 on average, 3 at
141
+ * most**: there is no fan-out there to protect against.
142
+ *
143
+ * So the flag is a required field on every spec. A new entity — or the
144
+ * synthetic devices the design addendum still owes — cannot inherit a
145
+ * camera's pressure valve by accident.
146
+ */
147
+ /**
148
+ * The snooze surface, as a `select`.
149
+ *
150
+ * `NC_SNOOZE_MAX_MINUTES` is 1440 server-side and there is no enum of
151
+ * preset durations, so the presets are chosen here. `Off` cancels.
152
+ */
153
+ var SNOOZE_OPTIONS = [
154
+ "Off",
155
+ "15 minutes",
156
+ "30 minutes",
157
+ "1 hour",
158
+ "2 hours",
159
+ "4 hours",
160
+ "8 hours",
161
+ "24 hours"
162
+ ];
163
+ var SNOOZE_MINUTES = {
164
+ "15 minutes": 15,
165
+ "30 minutes": 30,
166
+ "1 hour": 60,
167
+ "2 hours": 120,
168
+ "4 hours": 240,
169
+ "8 hours": 480,
170
+ "24 hours": 1440
171
+ };
172
+ var PTZ_BUTTONS = [
173
+ "ptz_up",
174
+ "ptz_down",
175
+ "ptz_left",
176
+ "ptz_right",
177
+ "ptz_zoom_in",
178
+ "ptz_zoom_out"
179
+ ];
180
+ function buildComponent(device, spec) {
181
+ const deviceKey = deviceKeyFor(device.stableId);
182
+ return {
183
+ platform: spec.platform,
184
+ unique_id: `${device.stableId}_${spec.uniqueSuffix ?? spec.entity}`,
185
+ name: spec.label,
186
+ ...spec.platform === "button" ? {} : { state_topic: stateTopic(deviceKey, spec.entity) },
187
+ ...spec.writable === true ? { command_topic: commandTopic(deviceKey, spec.entity) } : {},
188
+ ...spec.deviceClass !== void 0 ? { device_class: spec.deviceClass } : {},
189
+ ...spec.unit !== void 0 ? { unit_of_measurement: spec.unit } : {},
190
+ ...spec.icon !== void 0 ? { icon: spec.icon } : {},
191
+ ...spec.options !== void 0 ? { options: spec.options } : {},
192
+ ...spec.entityCategory !== void 0 ? { entity_category: spec.entityCategory } : {},
193
+ ...spec.enabledByDefault ? {} : { enabled_by_default: false },
194
+ ...spec.platform === "binary_sensor" || spec.platform === "switch" ? {
195
+ payload_on: "true",
196
+ payload_off: "false"
197
+ } : {},
198
+ ...spec.platform === "button" ? { payload_press: "PRESS" } : {}
199
+ };
200
+ }
201
+ function deviceBlock(device) {
202
+ return {
203
+ ids: [deviceKeyFor(device.stableId)],
204
+ name: device.name,
205
+ mf: device.manufacturer ?? "CamStack",
206
+ mdl: device.model ?? device.type
207
+ };
208
+ }
209
+ /**
210
+ * The fan-out, and the reason the valve exists: zones × zone-macros × 4.
211
+ * The detector is what an operator automates on; the other three are
212
+ * detail they can switch on per entity.
213
+ */
214
+ function zoneSpecs(zone) {
215
+ const slug = toSlug(zone.id);
216
+ const specs = [];
217
+ for (const macro of ZONE_MACROS) {
218
+ const label = `${zone.name} ${macro}`;
219
+ specs.push({
220
+ entity: `${slug}_${macro}_detected`,
221
+ platform: "binary_sensor",
222
+ label: `${label} detected`,
223
+ uniqueSuffix: `${zone.id}_${macro}_detected`,
224
+ deviceClass: "motion",
225
+ enabledByDefault: true
226
+ }, {
227
+ entity: `${slug}_${macro}_last_image`,
228
+ platform: "image",
229
+ label: `${label} last image`,
230
+ uniqueSuffix: `${zone.id}_${macro}_last_image`,
231
+ enabledByDefault: false
232
+ }, {
233
+ entity: `${slug}_${macro}_last_detection`,
234
+ platform: "sensor",
235
+ label: `${label} last detection`,
236
+ uniqueSuffix: `${zone.id}_${macro}_last_detection`,
237
+ deviceClass: "timestamp",
238
+ enabledByDefault: false
239
+ }, {
240
+ entity: `${slug}_${macro}_objects`,
241
+ platform: "sensor",
242
+ label: `${label} objects`,
243
+ uniqueSuffix: `${zone.id}_${macro}_objects`,
244
+ icon: "mdi:counter",
245
+ enabledByDefault: false
246
+ });
247
+ }
248
+ return specs;
249
+ }
250
+ function cameraSpecs(device) {
251
+ const specs = [
252
+ {
253
+ entity: "triggered",
254
+ platform: "binary_sensor",
255
+ label: "Triggered",
256
+ deviceClass: "motion",
257
+ enabledByDefault: true
258
+ },
259
+ {
260
+ entity: "online",
261
+ platform: "binary_sensor",
262
+ label: "Online",
263
+ deviceClass: "connectivity",
264
+ entityCategory: "diagnostic",
265
+ enabledByDefault: true
266
+ },
267
+ {
268
+ entity: "last_image",
269
+ platform: "image",
270
+ label: "Last image",
271
+ enabledByDefault: true
272
+ },
273
+ {
274
+ entity: "last_detection",
275
+ platform: "sensor",
276
+ label: "Last detection",
277
+ deviceClass: "timestamp",
278
+ icon: "mdi:clock",
279
+ enabledByDefault: false
280
+ }
281
+ ];
282
+ if (device.slices.includes("battery")) specs.push({
283
+ entity: "battery",
284
+ platform: "sensor",
285
+ label: "Battery",
286
+ deviceClass: "battery",
287
+ unit: "%",
288
+ entityCategory: "diagnostic",
289
+ enabledByDefault: false
290
+ }, {
291
+ entity: "charger",
292
+ platform: "binary_sensor",
293
+ label: "Charging",
294
+ deviceClass: "battery_charging",
295
+ entityCategory: "diagnostic",
296
+ enabledByDefault: false
297
+ }, {
298
+ entity: "sleeping",
299
+ platform: "binary_sensor",
300
+ label: "Sleeping",
301
+ entityCategory: "diagnostic",
302
+ enabledByDefault: false
303
+ });
304
+ /**
305
+ * One HA switch per AVAILABLE camera switch, rendered from
306
+ * `getCameraSwitches` and commanded back through `setCameraSwitch`.
307
+ *
308
+ * The export never writes an authority itself: a second knob that can
309
+ * disagree with the admin UI is worse than no knob (D62). An
310
+ * unavailable switch produces NO entity — a control defaulted to `on`
311
+ * because a source did not answer is exactly the lie that rule exists
312
+ * to prevent.
313
+ */
314
+ for (const sw of device.switches) {
315
+ if (!sw.available) continue;
316
+ specs.push({
317
+ entity: toSlug(sw.id),
318
+ platform: "switch",
319
+ label: sw.label,
320
+ writable: true,
321
+ enabledByDefault: true
322
+ });
323
+ }
324
+ if (device.features.includes("rebootable")) specs.push({
325
+ entity: "reboot",
326
+ platform: "button",
327
+ label: "Reboot",
328
+ writable: true,
329
+ enabledByDefault: true
330
+ });
331
+ if (device.boundCaps.includes("ptz")) {
332
+ for (const entity of PTZ_BUTTONS) specs.push({
333
+ entity,
334
+ platform: "button",
335
+ label: humanise(entity),
336
+ writable: true,
337
+ enabledByDefault: true
338
+ });
339
+ specs.push({
340
+ entity: "ptz_preset",
341
+ platform: "select",
342
+ label: "PTZ preset",
343
+ writable: true,
344
+ options: device.ptzPresets ?? [],
345
+ enabledByDefault: true
346
+ });
347
+ }
348
+ specs.push({
349
+ entity: "snooze",
350
+ platform: "select",
351
+ label: "Snooze notifications",
352
+ writable: true,
353
+ options: SNOOZE_OPTIONS,
354
+ enabledByDefault: true
355
+ });
356
+ for (const macro of EXPORTED_MACROS) {
357
+ const label = humanise(macro);
358
+ specs.push({
359
+ entity: `${macro}_detected`,
360
+ platform: "binary_sensor",
361
+ label: `${label} detected`,
362
+ deviceClass: "motion",
363
+ enabledByDefault: true
364
+ }, {
365
+ entity: `${macro}_last_image`,
366
+ platform: "image",
367
+ label: `${label} last image`,
368
+ enabledByDefault: false
369
+ }, {
370
+ entity: `${macro}_last_detection`,
371
+ platform: "sensor",
372
+ label: `${label} last detection`,
373
+ deviceClass: "timestamp",
374
+ enabledByDefault: false
375
+ });
376
+ if (LABELLED_MACROS.includes(macro)) specs.push({
377
+ entity: `${macro}_last_label`,
378
+ platform: "sensor",
379
+ label: `${label} last label`,
380
+ icon: "mdi:tag",
381
+ enabledByDefault: false
382
+ });
383
+ }
384
+ for (const zone of device.zones) specs.push(...zoneSpecs(zone));
385
+ return specs;
386
+ }
387
+ function assemble(device, specs) {
388
+ const cmps = {};
389
+ for (const spec of specs) cmps[toComponentKey(spec.platform, spec.entity)] = buildComponent(device, spec);
390
+ return {
391
+ deviceKey: deviceKeyFor(device.stableId),
392
+ deviceId: device.id,
393
+ dev: deviceBlock(device),
394
+ cmps
395
+ };
396
+ }
397
+ /** The hand-written half: a camera's entities come from analytics. */
398
+ function buildCameraPlan(device) {
399
+ return assemble(device, cameraSpecs(device));
400
+ }
401
+ var CAP_ENTITY_MAP = {
402
+ switch: {
403
+ platform: "switch",
404
+ field: "on",
405
+ writable: true
406
+ },
407
+ "lock-control": {
408
+ platform: "switch",
409
+ field: "locked",
410
+ deviceClass: "lock",
411
+ writable: true
412
+ },
413
+ siren: {
414
+ platform: "switch",
415
+ field: "active",
416
+ writable: true
417
+ },
418
+ button: {
419
+ platform: "button",
420
+ field: "pressed",
421
+ writable: true
422
+ },
423
+ brightness: {
424
+ platform: "number",
425
+ field: "brightness",
426
+ writable: true
427
+ },
428
+ binary: {
429
+ platform: "binary_sensor",
430
+ field: "state"
431
+ },
432
+ motion: {
433
+ platform: "binary_sensor",
434
+ field: "detected",
435
+ deviceClass: "motion"
436
+ },
437
+ contact: {
438
+ platform: "binary_sensor",
439
+ field: "open",
440
+ deviceClass: "door"
441
+ },
442
+ presence: {
443
+ platform: "binary_sensor",
444
+ field: "present",
445
+ deviceClass: "presence"
446
+ },
447
+ connectivity: {
448
+ platform: "binary_sensor",
449
+ field: "online",
450
+ deviceClass: "connectivity"
451
+ },
452
+ flood: {
453
+ platform: "binary_sensor",
454
+ field: "detected",
455
+ deviceClass: "moisture"
456
+ },
457
+ smoke: {
458
+ platform: "binary_sensor",
459
+ field: "detected",
460
+ deviceClass: "smoke"
461
+ },
462
+ gas: {
463
+ platform: "binary_sensor",
464
+ field: "detected",
465
+ deviceClass: "gas"
466
+ },
467
+ "carbon-monoxide": {
468
+ platform: "binary_sensor",
469
+ field: "detected",
470
+ deviceClass: "carbon_monoxide"
471
+ },
472
+ tamper: {
473
+ platform: "binary_sensor",
474
+ field: "detected",
475
+ deviceClass: "tamper"
476
+ },
477
+ vibration: {
478
+ platform: "binary_sensor",
479
+ field: "detected",
480
+ deviceClass: "vibration"
481
+ },
482
+ doorbell: {
483
+ platform: "sensor",
484
+ field: "lastPressedAt",
485
+ deviceClass: "timestamp"
486
+ },
487
+ "temperature-sensor": {
488
+ platform: "sensor",
489
+ field: "temperature",
490
+ deviceClass: "temperature",
491
+ unit: "°C"
492
+ },
493
+ "humidity-sensor": {
494
+ platform: "sensor",
495
+ field: "humidity",
496
+ deviceClass: "humidity",
497
+ unit: "%"
498
+ },
499
+ "pressure-sensor": {
500
+ platform: "sensor",
501
+ field: "pressure",
502
+ deviceClass: "pressure",
503
+ unit: "hPa"
504
+ },
505
+ "ambient-light-sensor": {
506
+ platform: "sensor",
507
+ field: "illuminance",
508
+ deviceClass: "illuminance",
509
+ unit: "lx"
510
+ },
511
+ "numeric-sensor": {
512
+ platform: "sensor",
513
+ field: "value"
514
+ },
515
+ "enum-sensor": {
516
+ platform: "sensor",
517
+ field: "value"
518
+ },
519
+ battery: {
520
+ platform: "sensor",
521
+ field: "percentage",
522
+ deviceClass: "battery",
523
+ unit: "%"
524
+ },
525
+ "power-meter": {
526
+ platform: "sensor",
527
+ field: "watts",
528
+ deviceClass: "power",
529
+ unit: "W"
530
+ },
531
+ "air-quality-sensor": {
532
+ platform: "sensor",
533
+ field: "aqi",
534
+ deviceClass: "aqi"
535
+ },
536
+ "alarm-panel": {
537
+ platform: "alarm_control_panel",
538
+ field: "state",
539
+ writable: true
540
+ }
541
+ };
542
+ var CAPS_NOT_EXPORTED = [
543
+ {
544
+ cap: "snapshot",
545
+ reason: "Carried by the camera catalog as `last_image`; a second image entity from the cap would duplicate it."
546
+ },
547
+ {
548
+ cap: "privacy-mask",
549
+ reason: "Rendered as a camera switch through getCameraSwitches, never written directly (D62)."
550
+ },
551
+ {
552
+ cap: "zone-analytics",
553
+ reason: "Projected into the per-zone object counts on the camera, not an entity of its own."
554
+ },
555
+ {
556
+ cap: "native-object-detection",
557
+ reason: "On-camera AI feeds the same per-macro entities as the pipeline; a parallel set would disagree with them."
558
+ },
559
+ {
560
+ cap: "ptz",
561
+ reason: "Rendered as buttons and a preset select by the camera catalog."
562
+ },
563
+ {
564
+ cap: "reboot",
565
+ reason: "Rendered as a button, gated on the `rebootable` feature rather than the binding."
566
+ },
567
+ {
568
+ cap: "device-status",
569
+ reason: "Auto-registered by BaseDevice on EVERY device, so it would add an entity to all of them; availability is already the heartbeat, per device."
570
+ },
571
+ {
572
+ cap: "feature-probe",
573
+ reason: "The runtime truth about what a device CAN do — it decides which entities exist, so exporting it as one would be circular."
574
+ },
575
+ {
576
+ cap: "device-ops",
577
+ reason: "The per-device operations envelope; its members surface individually (e.g. reboot)."
578
+ },
579
+ {
580
+ cap: "device-discovery",
581
+ reason: "Adoption-time only — it describes candidates, never the state of an adopted device."
582
+ }
583
+ ];
584
+ /** A capability in neither list — the thing the guard exists to surface. */
585
+ function unclassifiedCaps(caps) {
586
+ const known = new Set([...Object.keys(CAP_ENTITY_MAP), ...CAPS_NOT_EXPORTED.map((e) => e.cap)]);
587
+ return [...new Set(caps)].filter((cap) => !known.has(cap)).sort();
588
+ }
589
+ /**
590
+ * The derived half: every non-camera kind, from the caps it declares.
591
+ *
592
+ * **Everything here arrives ENABLED**, and that is the rule rather than
593
+ * an oversight in the other direction. A cap-derived entity IS the reason
594
+ * the device exists — a temperature sensor's temperature, a light's
595
+ * brightness, an alarm panel's state — and there is no fan-out to
596
+ * protect against: measured on the live hub, 115 non-camera devices
597
+ * produce 228 entities, 2 per device on average and 3 at most, because a
598
+ * device declares one or two mapped caps and the rest of its bindings
599
+ * (`device-status`, `feature-probe`, `device-ops`) carry no entity at
600
+ * all. The camera valve is for 73-per-device; this is not that problem,
601
+ * and exporting the operator's temperature switched off was the bug.
602
+ */
603
+ function buildDerivedPlan(device) {
604
+ const specs = [{
605
+ entity: "online",
606
+ platform: "binary_sensor",
607
+ label: "Online",
608
+ deviceClass: "connectivity",
609
+ entityCategory: "diagnostic",
610
+ enabledByDefault: true
611
+ }];
612
+ for (const cap of device.boundCaps) {
613
+ const mapping = CAP_ENTITY_MAP[cap];
614
+ if (mapping === void 0) continue;
615
+ specs.push({
616
+ entity: toSlug(cap),
617
+ platform: mapping.platform,
618
+ label: humanise(cap),
619
+ enabledByDefault: true,
620
+ ...mapping.deviceClass !== void 0 ? { deviceClass: mapping.deviceClass } : {},
621
+ ...mapping.unit !== void 0 ? { unit: mapping.unit } : {},
622
+ ...mapping.writable === true ? { writable: true } : {}
623
+ });
624
+ }
625
+ return assemble(device, specs);
626
+ }
627
+ /** Camera or base kind — the only place that decision is made. */
628
+ function buildDevicePlan(device) {
629
+ return device.type === "camera" ? buildCameraPlan(device) : buildDerivedPlan(device);
630
+ }
631
+ //#endregion
632
+ //#region src/ha-export/command-routes.ts
633
+ /**
634
+ * Home Assistant → camstack.
635
+ *
636
+ * The component POSTs `{topic, value}` onto this addon's `addon-routes`
637
+ * surface; this module turns that pair into a typed command. Resolution
638
+ * is pure so it can be tested without a device, and the addon does the
639
+ * dispatching.
640
+ *
641
+ * The rule that shapes every branch: **an unroutable or malformed
642
+ * command is refused, never approximated.** A `switch` payload that is
643
+ * neither `true` nor `false` is not "off"; a PTZ press on a camera that
644
+ * declares no `ptz` cap is not a no-op worth pretending succeeded. Both
645
+ * return `null`, and the caller logs the drop.
646
+ */
647
+ /** The camera switch ids, keyed by the entity slug the catalog emits. */
648
+ var CAMERA_SWITCH_BY_SLUG = Object.fromEntries([
649
+ "stream-broker",
650
+ "object-detection",
651
+ "privacy-mask",
652
+ "device-audio",
653
+ "broker-audio",
654
+ "audio-analysis",
655
+ "recording",
656
+ "notifications"
657
+ ].map((id) => [toSlug(id), id]));
658
+ var PTZ_DIRECTION_BY_ENTITY = Object.fromEntries(PTZ_BUTTONS.map((entity) => [entity, entity.slice(4)]));
659
+ function parseBool(value) {
660
+ const lower = value.trim().toLowerCase();
661
+ if (lower === "true" || lower === "on" || lower === "1") return true;
662
+ if (lower === "false" || lower === "off" || lower === "0") return false;
663
+ return null;
664
+ }
665
+ function resolveCommand(target, entity, value) {
666
+ if (target.type === "camera") {
667
+ const switchId = CAMERA_SWITCH_BY_SLUG[entity];
668
+ if (switchId !== void 0) {
669
+ const enabled = parseBool(value);
670
+ if (enabled === null) return null;
671
+ return {
672
+ kind: "camera-switch",
673
+ deviceId: target.deviceId,
674
+ switchId,
675
+ enabled
676
+ };
677
+ }
678
+ if (entity === "reboot") return {
679
+ kind: "reboot",
680
+ deviceId: target.deviceId
681
+ };
682
+ const direction = PTZ_DIRECTION_BY_ENTITY[entity];
683
+ if (direction !== void 0) {
684
+ if (!target.boundCaps.includes("ptz")) return null;
685
+ return {
686
+ kind: "ptz-move",
687
+ deviceId: target.deviceId,
688
+ direction
689
+ };
690
+ }
691
+ if (entity === "ptz_preset") {
692
+ if (!target.boundCaps.includes("ptz")) return null;
693
+ if (value.trim().length === 0) return null;
694
+ return {
695
+ kind: "ptz-preset",
696
+ deviceId: target.deviceId,
697
+ preset: value
698
+ };
699
+ }
700
+ if (entity === "snooze") {
701
+ if (!SNOOZE_OPTIONS.includes(value)) return null;
702
+ const minutes = SNOOZE_MINUTES[value];
703
+ return {
704
+ kind: "snooze",
705
+ deviceId: target.deviceId,
706
+ minutes: minutes ?? null
707
+ };
708
+ }
709
+ return null;
710
+ }
711
+ /**
712
+ * The derived half. The entity slug is the capability name, so the
713
+ * mapping table decides both the platform and whether it is writable —
714
+ * there is no separate list to fall out of sync with the catalog.
715
+ */
716
+ for (const [capName, mapping] of Object.entries(CAP_ENTITY_MAP)) {
717
+ if (toSlug(capName) !== entity) continue;
718
+ if (mapping.writable !== true) return null;
719
+ if (!target.boundCaps.includes(capName)) return null;
720
+ if (mapping.platform === "button") return {
721
+ kind: "cap-button",
722
+ deviceId: target.deviceId,
723
+ capName
724
+ };
725
+ const on = parseBool(value);
726
+ if (on === null) return null;
727
+ return {
728
+ kind: "cap-switch",
729
+ deviceId: target.deviceId,
730
+ capName,
731
+ on
732
+ };
733
+ }
734
+ return null;
735
+ }
736
+ /**
737
+ * The ONE derivation of "a signed, expiring URL".
738
+ *
739
+ * This repo mints unguessable, self-expiring links in three places now — the
740
+ * notification artifact plane, the Home Assistant media plane, and the snapshot
741
+ * link plane. The first two grew independently and are byte-identical logic
742
+ * (`hmac(secret, "<id>:<exp>")`, expiry checked before a constant-time compare),
743
+ * each restating the crypto locally because **addons never import each other**.
744
+ *
745
+ * That reason is real, and the conclusion drawn from it was wrong. Two copies of
746
+ * a signing scheme is how one of them quietly ends up with a different TTL, a
747
+ * different compare, or a missing expiry check, and nothing fails until a link
748
+ * that should have died keeps working. The fix is the same one D52 applies to
749
+ * crop geometry: one derivation, in a place every addon may depend on. A
750
+ * framework package is exactly that place — `@camstack/types/node`, off the root
751
+ * entry because `node:crypto` must never be traversed by a browser bundler.
752
+ *
753
+ * What this module deliberately does NOT decide: the TTL, the base URL, the
754
+ * shape of `id`, and the access level of the route. Those are per-plane policy
755
+ * and each caller states them where a reader can see them.
756
+ */
757
+ /**
758
+ * The signature over `(id, expMs)`.
759
+ *
760
+ * `id` is whatever the plane uses to name the thing being served — an artifact
761
+ * id, a track id, a `<deviceId>:<width>` pair. It is joined with `:` so a caller
762
+ * must not put a `:` inside a field whose boundary matters; where a plane has
763
+ * more than one field, it composes them itself and owns that ambiguity.
764
+ */
765
+ function signExpiringUrl(secret, id, expMs) {
766
+ return createHmac("sha256", secret).update(`${id}:${String(expMs)}`).digest("hex");
767
+ }
768
+ /**
769
+ * Verify a request's `(id, exp, sig)`.
770
+ *
771
+ * **Expiry is checked BEFORE the compare**, so an expired link is refused
772
+ * whether or not its signature is valid — a leaked URL stops working on its own
773
+ * and cannot be kept alive by holding a correct signature. The compare itself is
774
+ * constant-time so a public route cannot be probed for the signature byte by
775
+ * byte.
776
+ */
777
+ function verifyExpiringUrl(input) {
778
+ const { secret, id, exp, sig, nowMs } = input;
779
+ if (exp === void 0 || sig === void 0) return false;
780
+ const expMs = typeof exp === "number" ? exp : Number(exp);
781
+ if (!Number.isFinite(expMs)) return false;
782
+ if (expMs <= nowMs) return false;
783
+ const expected = signExpiringUrl(secret, id, expMs);
784
+ const a = Buffer.from(expected, "utf8");
785
+ const b = Buffer.from(sig, "utf8");
786
+ if (a.length !== b.length) return false;
787
+ return timingSafeEqual(a, b);
788
+ }
789
+ //#endregion
790
+ //#region src/ha-export/media-url.ts
791
+ /**
792
+ * Signed, expiring media links.
793
+ *
794
+ * Images never cross the push transport. Home Assistant is handed a URL
795
+ * and fetches the bytes itself, on demand, which is what keeps a camera
796
+ * with a `last_image` entity from turning every detection into a
797
+ * multi-megabyte POST.
798
+ *
799
+ * The link is signed rather than protected by a session, because the
800
+ * fetcher is HA's own frontend or a phone — neither of which holds a
801
+ * camstack token.
802
+ *
803
+ * The HMAC itself is NOT restated here. It used to be — this module and the
804
+ * notification artifact plane each carried their own copy, because addons never
805
+ * import each other and neither could reach the other's. That reasoning was
806
+ * right and the conclusion was wrong: both now delegate to the single
807
+ * derivation in `@camstack/types/node`, which every addon may depend on. What
808
+ * stays local is policy — the TTL below, and the id this plane signs.
809
+ *
810
+ * Expiry is checked BEFORE the constant-time compare (in the shared verifier):
811
+ * an expired link is refused whether or not its signature is valid, so a leaked
812
+ * URL stops working on its own.
813
+ */
814
+ /** How long a minted link stays valid. Long enough for HA to render it. */
815
+ var MEDIA_URL_TTL_MS = 360 * 60 * 1e3;
816
+ function signMedia(secret, id, expMs) {
817
+ return signExpiringUrl(secret, id, expMs);
818
+ }
819
+ function buildMediaUrl(input) {
820
+ const base = input.baseUrl.replace(/\/+$/, "");
821
+ const sig = signMedia(input.secret, input.id, input.expMs);
822
+ return `${base}${input.routePrefix}/${encodeURIComponent(input.id)}?exp=${input.expMs}&sig=${sig}`;
823
+ }
824
+ function verifyMediaSignature(input) {
825
+ return verifyExpiringUrl(input);
826
+ }
827
+ //#endregion
828
+ //#region src/ha-export/membership.ts
829
+ function devicesForBroker(membership, brokerId) {
830
+ return membership[brokerId] ?? [];
831
+ }
832
+ function isExposed(membership, brokerId, deviceId) {
833
+ return devicesForBroker(membership, brokerId).includes(deviceId);
834
+ }
835
+ /** Every broker a device reaches. Drives the N switches in the panel. */
836
+ function brokersExposing(membership, deviceId) {
837
+ return Object.keys(membership).filter((brokerId) => isExposed(membership, brokerId, deviceId)).sort();
838
+ }
839
+ /** Every device exported to at least one of the given brokers. */
840
+ function exposedDeviceIds(membership, brokerIds) {
841
+ const ids = /* @__PURE__ */ new Set();
842
+ for (const brokerId of brokerIds) for (const deviceId of devicesForBroker(membership, brokerId)) ids.add(deviceId);
843
+ return [...ids].sort();
844
+ }
845
+ function setExposed(membership, brokerId, deviceId, exposed) {
846
+ const current = devicesForBroker(membership, brokerId);
847
+ if (exposed === current.includes(deviceId)) return membership;
848
+ const next = exposed ? [...current, deviceId].sort() : current.filter((id) => id !== deviceId);
849
+ return {
850
+ ...membership,
851
+ [brokerId]: next
852
+ };
853
+ }
854
+ /**
855
+ * Forget brokers that no longer exist.
856
+ *
857
+ * A removed HA instance must not keep a membership list that reappears if
858
+ * a broker id is ever reused — and a stale key would also inflate
859
+ * `exposedDeviceCount` with devices going nowhere.
860
+ */
861
+ function pruneBrokers(membership, knownBrokerIds) {
862
+ const known = new Set(knownBrokerIds);
863
+ const kept = Object.keys(membership).filter((brokerId) => known.has(brokerId));
864
+ if (kept.length === Object.keys(membership).length) return membership;
865
+ const next = {};
866
+ for (const brokerId of kept) next[brokerId] = membership[brokerId] ?? [];
867
+ return next;
868
+ }
869
+ //#endregion
870
+ //#region src/ha-export/push-client.ts
871
+ /** How long state updates accumulate before one POST carries them all. */
872
+ var BATCH_FLUSH_MS = 1e3;
873
+ /** Availability cadence. The component's timeout is a multiple of this. */
874
+ var HEARTBEAT_INTERVAL_MS = 3e4;
875
+ var IMAGE_SIGNAL_PREFIX = "__image_updated__:";
876
+ /** Rate limit for the "HA is not answering" warning. */
877
+ var DROP_LOG_INTERVAL_MS = 3e4;
878
+ var PushClient = class {
879
+ transport;
880
+ logger;
881
+ brokerId;
882
+ onLinkRestored;
883
+ /** topic → last value SENT. Dropped whenever the link is lost. */
884
+ stateCache = /* @__PURE__ */ new Map();
885
+ buffer = [];
886
+ flushTimer = null;
887
+ heartbeatTimer = null;
888
+ available = true;
889
+ dropped = 0;
890
+ droppedLoggedAt = 0;
891
+ disposed = false;
892
+ constructor(options) {
893
+ this.transport = options.transport;
894
+ this.logger = options.logger;
895
+ this.brokerId = options.brokerId;
896
+ this.onLinkRestored = options.onLinkRestored;
897
+ }
898
+ /** `false` once a POST has failed, `true` again on the next success. */
899
+ get healthy() {
900
+ return this.available;
901
+ }
902
+ /** Messages discarded because HA did not answer. Never silent. */
903
+ get droppedCount() {
904
+ return this.dropped;
905
+ }
906
+ start() {
907
+ if (this.heartbeatTimer !== null) return;
908
+ this.heartbeatTimer = setInterval(() => {
909
+ this.send({
910
+ type: "heartbeat",
911
+ ts: Date.now()
912
+ });
913
+ }, HEARTBEAT_INTERVAL_MS);
914
+ }
915
+ async dispose() {
916
+ this.disposed = true;
917
+ if (this.heartbeatTimer !== null) clearInterval(this.heartbeatTimer);
918
+ this.heartbeatTimer = null;
919
+ if (this.flushTimer !== null) clearTimeout(this.flushTimer);
920
+ this.flushTimer = null;
921
+ this.buffer = [];
922
+ }
923
+ /**
924
+ * Queue one state value.
925
+ *
926
+ * Deduped per topic, so a camera reporting `online: true` every five
927
+ * seconds costs nothing after the first.
928
+ */
929
+ publishState(topic, value) {
930
+ if (this.disposed) return;
931
+ const payload = value.length > 2048 ? `${IMAGE_SIGNAL_PREFIX}${Date.now()}` : value;
932
+ if (this.stateCache.get(topic) === payload) return;
933
+ this.stateCache.set(topic, payload);
934
+ this.buffer.push({
935
+ type: "state_update",
936
+ topic,
937
+ value: payload
938
+ });
939
+ if (this.flushTimer === null) this.flushTimer = setTimeout(() => void this.flush(), BATCH_FLUSH_MS);
940
+ }
941
+ /**
942
+ * Announce a device's COMPLETE component set.
943
+ *
944
+ * Never a delta, and re-sent in full on reconnect: an event is
945
+ * telemetry and may be dropped (D8), so a lost structure message must
946
+ * not be able to leave HA with half a device. The pending state batch
947
+ * is flushed first — a value for an entity HA has not built yet is a
948
+ * value it discards.
949
+ */
950
+ async publishEntityChange(deviceKey, cmps, dev) {
951
+ if (this.disposed) return;
952
+ await this.flush();
953
+ await this.send({
954
+ type: "entity_change",
955
+ device_id: deviceKey,
956
+ cmps,
957
+ dev
958
+ });
959
+ }
960
+ /** Send everything buffered now. Called before any structure change. */
961
+ async flush() {
962
+ if (this.flushTimer !== null) {
963
+ clearTimeout(this.flushTimer);
964
+ this.flushTimer = null;
965
+ }
966
+ if (this.buffer.length === 0) return;
967
+ const items = this.buffer;
968
+ this.buffer = [];
969
+ await this.send({
970
+ type: "batch",
971
+ items
972
+ });
973
+ }
974
+ async send(message) {
975
+ try {
976
+ await this.transport(message);
977
+ this.onReachable();
978
+ } catch (err) {
979
+ this.onUnreachable(message, err);
980
+ }
981
+ }
982
+ onReachable() {
983
+ if (this.available) return;
984
+ this.available = true;
985
+ /**
986
+ * The link came back. Drop the dedup cache so the reconcile that
987
+ * follows re-pushes every value: HA may have restarted and lost its
988
+ * state, and a cache that still remembers the old value would
989
+ * suppress exactly the messages needed to repair it.
990
+ */
991
+ this.stateCache.clear();
992
+ this.logger.info("ha-export: Home Assistant reachable again, state cache cleared", { meta: {
993
+ brokerId: this.brokerId,
994
+ droppedWhileDown: this.dropped
995
+ } });
996
+ this.dropped = 0;
997
+ this.droppedLoggedAt = 0;
998
+ /**
999
+ * Signalled AFTER the cache is gone and BEFORE anything else is sent:
1000
+ * the handler re-announces from inside this call, and a cache that
1001
+ * still held the old values would dedup away exactly those messages.
1002
+ * The handler's own failure is its business — it can never take the
1003
+ * transport, or the runner, down with it (D29).
1004
+ */
1005
+ try {
1006
+ this.onLinkRestored?.(this.brokerId);
1007
+ } catch (err) {
1008
+ this.logger.warn("ha-export: the re-announce handler threw, link is up but not repaired", { meta: {
1009
+ brokerId: this.brokerId,
1010
+ error: err instanceof Error ? err.message : String(err)
1011
+ } });
1012
+ }
1013
+ }
1014
+ /**
1015
+ * A failed POST discards work, so it may not be silent — an operator
1016
+ * whose entities froze two days ago needs the log to say camstack is
1017
+ * pushing into a wall, not merely that the addon is "running". Rate
1018
+ * limited, because the alternative is a storm that buries the line.
1019
+ */
1020
+ onUnreachable(message, err) {
1021
+ this.available = false;
1022
+ this.dropped += message.type === "batch" ? message.items.length : 1;
1023
+ const now = Date.now();
1024
+ if (now - this.droppedLoggedAt < DROP_LOG_INTERVAL_MS) return;
1025
+ this.droppedLoggedAt = now;
1026
+ this.logger.warn("ha-export: push to Home Assistant failed, messages dropped", { meta: {
1027
+ brokerId: this.brokerId,
1028
+ dropped: this.dropped,
1029
+ messageType: message.type,
1030
+ error: err instanceof Error ? err.message : String(err)
1031
+ } });
1032
+ }
1033
+ };
1034
+ var ReachabilityReconciler = class {
1035
+ options;
1036
+ minIntervalMs;
1037
+ timer = null;
1038
+ lastRunAt = null;
1039
+ pending = /* @__PURE__ */ new Set();
1040
+ disposed = false;
1041
+ constructor(options) {
1042
+ this.options = options;
1043
+ this.minIntervalMs = options.minIntervalMs ?? 3e4;
1044
+ }
1045
+ /** One link came back. Ask for a full re-announce. */
1046
+ request(brokerId) {
1047
+ if (this.disposed) return;
1048
+ this.pending.add(brokerId);
1049
+ if (this.timer !== null) return;
1050
+ const sinceLast = this.lastRunAt === null ? Number.POSITIVE_INFINITY : Date.now() - this.lastRunAt;
1051
+ const delayMs = Math.max(0, this.minIntervalMs - sinceLast);
1052
+ this.options.onScheduled?.({
1053
+ brokerId,
1054
+ delayMs
1055
+ });
1056
+ this.timer = setTimeout(() => {
1057
+ this.timer = null;
1058
+ this.fire();
1059
+ }, delayMs);
1060
+ }
1061
+ dispose() {
1062
+ this.disposed = true;
1063
+ if (this.timer !== null) clearTimeout(this.timer);
1064
+ this.timer = null;
1065
+ this.pending.clear();
1066
+ }
1067
+ fire() {
1068
+ if (this.disposed) return;
1069
+ const brokerIds = [...this.pending].sort();
1070
+ this.pending.clear();
1071
+ if (brokerIds.length === 0) return;
1072
+ this.lastRunAt = Date.now();
1073
+ this.options.run({ brokerIds });
1074
+ }
1075
+ };
1076
+ //#endregion
1077
+ //#region src/ha-export/state-projector.ts
1078
+ /**
1079
+ * Pipeline / notification-center output → `{topic, value}`.
1080
+ *
1081
+ * Pure. This is where `last_detection`, `last_label` and the per-zone
1082
+ * counts become state, and it is the half of the export the operator's
1083
+ * requirement lands on: **every value has a listener in the addon that
1084
+ * publishes it.** Nothing in Home Assistant polls camstack — each
1085
+ * function here is the projection of ONE internal source, and the addon
1086
+ * subscribes to that source.
1087
+ *
1088
+ * Every value is a STRING. The component lowercases binary values
1089
+ * verbatim; a JSON number or boolean raises inside its state callback,
1090
+ * which it swallows — so the entity silently never updates again.
1091
+ */
1092
+ /**
1093
+ * A detection class → the macro an operator automates on.
1094
+ *
1095
+ * Returns `null` rather than a fallback: the executor drops classes with
1096
+ * no macro upstream (`preserveOriginal: false`), so one arriving here is
1097
+ * a contract break, and inventing a macro for it would put a toothbrush
1098
+ * on the person sensor.
1099
+ */
1100
+ function macroOf(className) {
1101
+ const lower = className.toLowerCase();
1102
+ if (EXPORTED_MACROS.includes(lower)) return lower;
1103
+ const mapped = COCO_TO_MACRO.mapping[lower];
1104
+ return typeof mapped === "string" ? mapped : null;
1105
+ }
1106
+ function iso(timestamp) {
1107
+ return new Date(timestamp).toISOString();
1108
+ }
1109
+ function bool(value) {
1110
+ return value ? "true" : "false";
1111
+ }
1112
+ /**
1113
+ * Reachability.
1114
+ *
1115
+ * `device.sleeping` deliberately does NOT feed this: a battery camera in
1116
+ * low-power mode is reachable on demand, and marking it unavailable
1117
+ * would break every automation that wakes it.
1118
+ */
1119
+ function projectDeviceStatus(deviceKey, online) {
1120
+ return [{
1121
+ topic: stateTopic(deviceKey, "online"),
1122
+ value: bool(online)
1123
+ }];
1124
+ }
1125
+ function projectMotion(deviceKey, input) {
1126
+ const values = [{
1127
+ topic: stateTopic(deviceKey, "motion_detected"),
1128
+ value: bool(input.detected)
1129
+ }, {
1130
+ topic: stateTopic(deviceKey, "triggered"),
1131
+ value: bool(input.detected)
1132
+ }];
1133
+ /**
1134
+ * A clearing edge carries no timestamp. `last_detection` answers "when
1135
+ * did something last happen", and restamping it when nothing happened
1136
+ * makes an idle camera look permanently busy.
1137
+ */
1138
+ if (input.detected) values.push({
1139
+ topic: stateTopic(deviceKey, "motion_last_detection"),
1140
+ value: iso(input.timestamp)
1141
+ });
1142
+ return values;
1143
+ }
1144
+ /**
1145
+ * @param mediaUrl a signed, expiring URL for this track's best crop, or
1146
+ * `null` when there is none yet. Images NEVER cross the transport —
1147
+ * camstack mints the URL and HA fetches the bytes on demand.
1148
+ */
1149
+ function projectTrackLifecycle(deviceKey, track, mediaUrl) {
1150
+ const className = track.bestClassName ?? track.classes[0];
1151
+ if (className === void 0) return [];
1152
+ const macro = macroOf(className);
1153
+ if (macro === null) return [];
1154
+ const active = track.phase !== "end";
1155
+ const values = [{
1156
+ topic: stateTopic(deviceKey, `${macro}_detected`),
1157
+ value: bool(active)
1158
+ }, {
1159
+ topic: stateTopic(deviceKey, "triggered"),
1160
+ value: bool(active)
1161
+ }];
1162
+ if (active) values.push({
1163
+ topic: stateTopic(deviceKey, `${macro}_last_detection`),
1164
+ value: iso(track.lastSeen)
1165
+ }, {
1166
+ topic: stateTopic(deviceKey, "last_detection"),
1167
+ value: iso(track.lastSeen)
1168
+ });
1169
+ /**
1170
+ * The identification: the recognised face for `person`, the plate for
1171
+ * `vehicle`, the sub-class for `animal`. `package`, `motion` and
1172
+ * `audio` have no label and the entity does not exist for them — so a
1173
+ * label arriving on one of those is dropped rather than published to a
1174
+ * topic nothing is subscribed to.
1175
+ */
1176
+ const label = track.plateText ?? track.label;
1177
+ if (label !== void 0 && label.length > 0 && LABELLED_MACROS.includes(macro)) values.push({
1178
+ topic: stateTopic(deviceKey, `${macro}_last_label`),
1179
+ value: label
1180
+ });
1181
+ if (mediaUrl !== null) values.push({
1182
+ topic: stateTopic(deviceKey, "last_image"),
1183
+ value: mediaUrl
1184
+ }, {
1185
+ topic: stateTopic(deviceKey, `${macro}_last_image`),
1186
+ value: mediaUrl
1187
+ });
1188
+ if (ZONE_MACROS.includes(macro)) for (const zoneId of track.zonesVisited ?? []) {
1189
+ const slug = toSlug(zoneId);
1190
+ values.push({
1191
+ topic: stateTopic(deviceKey, `${slug}_${macro}_detected`),
1192
+ value: bool(active)
1193
+ });
1194
+ if (active) values.push({
1195
+ topic: stateTopic(deviceKey, `${slug}_${macro}_last_detection`),
1196
+ value: iso(track.lastSeen)
1197
+ });
1198
+ if (mediaUrl !== null) values.push({
1199
+ topic: stateTopic(deviceKey, `${slug}_${macro}_last_image`),
1200
+ value: mediaUrl
1201
+ });
1202
+ }
1203
+ return values;
1204
+ }
1205
+ /**
1206
+ * Per-zone counts.
1207
+ *
1208
+ * A macro with nobody in the zone publishes `0`, not nothing: an entity
1209
+ * that goes silent renders in HA as its last value, so a driveway that
1210
+ * emptied would read as still occupied until someone walked through it
1211
+ * again.
1212
+ */
1213
+ function projectZoneOccupancy(deviceKey, input) {
1214
+ const values = [];
1215
+ for (const zone of input.zones) {
1216
+ const slug = toSlug(zone.zoneId);
1217
+ const totals = new Map(ZONE_MACROS.map((macro) => [macro, 0]));
1218
+ for (const [className, count] of Object.entries(zone.byClass)) {
1219
+ const macro = macroOf(className);
1220
+ if (macro === null || !totals.has(macro)) continue;
1221
+ totals.set(macro, (totals.get(macro) ?? 0) + count);
1222
+ }
1223
+ for (const [macro, count] of totals) values.push({
1224
+ topic: stateTopic(deviceKey, `${slug}_${macro}_objects`),
1225
+ value: String(count)
1226
+ });
1227
+ }
1228
+ return values;
1229
+ }
1230
+ function projectBatterySlice(deviceKey, slice) {
1231
+ const values = [{
1232
+ topic: stateTopic(deviceKey, "charger"),
1233
+ value: bool(slice.charging !== "none")
1234
+ }, {
1235
+ topic: stateTopic(deviceKey, "sleeping"),
1236
+ value: bool(slice.sleeping)
1237
+ }];
1238
+ /**
1239
+ * `binary: true` means the device reports "low" or "not low" and the
1240
+ * percentage is a stand-in. Publishing it would put a fabricated
1241
+ * number on a battery gauge, which is worse than an empty one.
1242
+ */
1243
+ if (slice.binary !== true) values.push({
1244
+ topic: stateTopic(deviceKey, "battery"),
1245
+ value: String(slice.percentage)
1246
+ });
1247
+ return values;
1248
+ }
1249
+ /**
1250
+ * The per-camera function switches, mirrored 1:1 from
1251
+ * `pipelineOrchestrator.getCameraSwitches`.
1252
+ *
1253
+ * An UNAVAILABLE switch publishes nothing, because `enabled` is
1254
+ * meaningless when `available` is false — a source that did not answer
1255
+ * must never become a control rendered `on`.
1256
+ */
1257
+ function projectCameraSwitches(deviceKey, switches) {
1258
+ return switches.filter((sw) => sw.available).map((sw) => ({
1259
+ topic: stateTopic(deviceKey, toSlug(sw.id)),
1260
+ value: bool(sw.enabled)
1261
+ }));
1262
+ }
1263
+ //#endregion
1264
+ //#region src/ha-export/ha-export.addon.ts
1265
+ /**
1266
+ * Native Home Assistant export — a custom component fed by HTTP push.
1267
+ *
1268
+ * Contract: `docs/superpowers/specs/2026-08-04-ha-native-export-design.md`.
1269
+ * Read it before changing the entity map or the transport.
1270
+ *
1271
+ * **This addon PUSHES. Home Assistant never polls camstack for a value.**
1272
+ * Every entity in the catalog is fed by a listener here, on the authority
1273
+ * that already owns the value — device slices, motion, the track
1274
+ * lifecycle, zone occupancy, the camera switches. The projector turns
1275
+ * each into `{topic, value}` and the push client batches, dedups and
1276
+ * heartbeats them out.
1277
+ *
1278
+ * Where it lives, and why it is not inside the provider's process: it
1279
+ * ships in the SAME bundle as `provider-homeassistant` (so the HA URL and
1280
+ * token are not duplicated — they are read through the `broker` cap, and
1281
+ * addons never import each other even in one bundle), but as a SEPARATE
1282
+ * manifest entry with its own runner. D29: a defect in export must not be
1283
+ * able to respawn the runner that owns the devices.
1284
+ *
1285
+ * Three things a future session will want to know:
1286
+ *
1287
+ * 1. **Push authenticates with Home Assistant's OWN token.** The
1288
+ * component registers its view with HA auth, so the long-lived token
1289
+ * already stored on the broker is the credential. There is no second
1290
+ * shared secret to configure, rotate or leak.
1291
+ * 2. **Events optimise; the reconcile is the contract.** Bus events are
1292
+ * telemetry and may be dropped (D8), so a full reconcile runs on
1293
+ * boot, on config change, on a timer, and whenever the link to HA
1294
+ * returns. A dropped event costs one interval of staleness rather
1295
+ * than permanent divergence.
1296
+ * 3. **Membership is per broker.** Two Home Assistant instances see
1297
+ * different sets of cameras, and the per-device Export panel shows
1298
+ * one switch per broker over the single membership store.
1299
+ */
1300
+ /** The addon that owns the import direction — never export back to it. */
1301
+ var HA_PROVIDER_ADDON_ID = "provider-homeassistant";
1302
+ var ADDON_ID = "homeassistant-export";
1303
+ /** Where the custom component registers its push view inside HA. */
1304
+ var PUSH_PATH = "/api/camstack/push";
1305
+ var MEDIA_ROUTE_PREFIX = `/addon/${ADDON_ID}/ha-media`;
1306
+ /** The hub's API port — where the data plane is reverse-proxied. */
1307
+ var HUB_API_PORT = 4443;
1308
+ /** One PTZ button press, as a relative move. */
1309
+ var PTZ_MOVE_BY_DIRECTION = {
1310
+ up: { tilt: .1 },
1311
+ down: { tilt: -.1 },
1312
+ left: { pan: -.1 },
1313
+ right: { pan: .1 },
1314
+ zoom_in: { zoom: .1 },
1315
+ zoom_out: { zoom: -.1 }
1316
+ };
1317
+ var DEFAULT_CONFIG = {
1318
+ reconcileIntervalSec: 300,
1319
+ membership: {},
1320
+ knownBrokers: [],
1321
+ publicBaseUrl: ""
1322
+ };
1323
+ var HaExportAddon = class extends BaseAddon {
1324
+ links = /* @__PURE__ */ new Map();
1325
+ /** deviceKey → the device it addresses. Rebuilt on every reconcile. */
1326
+ exported = /* @__PURE__ */ new Map();
1327
+ /** numeric deviceId → deviceKey, for the event path. */
1328
+ keyByDeviceId = /* @__PURE__ */ new Map();
1329
+ mediaSecret = "";
1330
+ mediaBaseUrl = null;
1331
+ reconcileTimer = null;
1332
+ reconcileInFlight = false;
1333
+ /** A reason asked for while one was running. Re-run, never dropped. */
1334
+ reconcilePending = null;
1335
+ lastError;
1336
+ entityCount = 0;
1337
+ unclassified = [];
1338
+ /**
1339
+ * A link that returned repairs NOW, not at the next periodic pass.
1340
+ *
1341
+ * `PushClient` drops its dedup cache the moment Home Assistant answers
1342
+ * again; without this, nothing re-sent anything and every entity sat
1343
+ * blank for up to `reconcileIntervalSec` (300s). Bounded and coalesced,
1344
+ * because the answer is the COMPLETE `cmps` set plus every value for
1345
+ * every exported device — see `reachability-reconcile.ts`.
1346
+ */
1347
+ reachability = new ReachabilityReconciler({
1348
+ onScheduled: ({ brokerId, delayMs }) => {
1349
+ this.ctx.logger.info("ha-export: Home Assistant link returned, re-announcing everything", { meta: {
1350
+ brokerId,
1351
+ inMs: delayMs
1352
+ } });
1353
+ },
1354
+ run: ({ brokerIds }) => {
1355
+ this.reconcile("link-restored");
1356
+ this.ctx.logger.debug("ha-export: re-announce triggered by a returning link", { meta: { brokers: [...brokerIds] } });
1357
+ }
1358
+ });
1359
+ constructor() {
1360
+ super({ ...DEFAULT_CONFIG });
1361
+ }
1362
+ async onInitialize() {
1363
+ const secret = this.state("haExportMediaSecret", string(), "");
1364
+ const existing = await secret.get();
1365
+ if (existing.length === 0) {
1366
+ const minted = randomSecret();
1367
+ await secret.set(minted);
1368
+ this.mediaSecret = minted;
1369
+ } else this.mediaSecret = existing;
1370
+ this.wireListeners();
1371
+ await this.serveMediaPlane();
1372
+ try {
1373
+ await this.reconcile("boot");
1374
+ } catch (err) {
1375
+ this.lastError = errMsg(err);
1376
+ this.ctx.logger.warn("ha-export: initial reconcile failed", { meta: { error: this.lastError } });
1377
+ }
1378
+ this.startTimer();
1379
+ this.ctx.addDisposer(async () => {
1380
+ this.stopTimer();
1381
+ this.reachability.dispose();
1382
+ for (const link of this.links.values()) await link.client.dispose();
1383
+ this.links.clear();
1384
+ });
1385
+ return [{
1386
+ capability: deviceExportCapability,
1387
+ provider: {
1388
+ getStatus: async () => this.buildStatus(),
1389
+ listSupportedDeviceKinds: async () => [...SUPPORTED_DEVICE_KINDS],
1390
+ listExposedDevices: async () => exposedDeviceIds(this.config.membership, this.enabledBrokerIds()).map((deviceId) => ({
1391
+ deviceId,
1392
+ exposedAs: brokersExposing(this.config.membership, deviceId).map((brokerId) => this.brokerName(brokerId)).join(", ")
1393
+ })),
1394
+ exposeDevice: async ({ deviceId }) => this.setMembershipEverywhere(deviceId, true),
1395
+ unexposeDevice: async ({ deviceId }) => this.setMembershipEverywhere(deviceId, false),
1396
+ getDeviceSettingsContribution: async ({ deviceId }) => this.buildContribution(deviceId),
1397
+ getDeviceLiveContribution: async () => null,
1398
+ applyDeviceSettingsPatch: async ({ deviceId, patch }) => this.applyDeviceSettingsPatch(deviceId, patch)
1399
+ }
1400
+ }, {
1401
+ capability: addonRoutesCapability,
1402
+ provider: buildAddonRouteProvider(ADDON_ID, [{
1403
+ method: "POST",
1404
+ path: "/command",
1405
+ access: "authenticated",
1406
+ description: "Home Assistant → CamStack command: {topic, value}",
1407
+ handler: async (request, reply) => {
1408
+ await this.handleCommandRoute(request.body, reply);
1409
+ }
1410
+ }])
1411
+ }];
1412
+ }
1413
+ async onConfigChanged() {
1414
+ await this.reconcile("config-changed");
1415
+ this.startTimer();
1416
+ }
1417
+ wireListeners() {
1418
+ this.subscribe({ category: EventCategory.DeviceStateChanged }, (event) => {
1419
+ this.onSliceChanged(event.data);
1420
+ });
1421
+ this.subscribe({ category: EventCategory.DeviceOnline }, (event) => {
1422
+ this.onOnlineChanged(event.data, true);
1423
+ });
1424
+ this.subscribe({ category: EventCategory.DeviceOffline }, (event) => {
1425
+ this.onOnlineChanged(event.data, false);
1426
+ });
1427
+ this.subscribe({ category: EventCategory.MotionOnMotionChanged }, (event) => {
1428
+ this.onMotion(event.data);
1429
+ });
1430
+ this.subscribe({ category: EventCategory.PipelineAnalyticsTrackLifecycle }, (event) => {
1431
+ this.onTrackLifecycle(event.data);
1432
+ });
1433
+ this.subscribe({ category: EventCategory.ZoneAnalyticsOccupancyChanged }, (event) => {
1434
+ this.onZoneOccupancy(event.data);
1435
+ });
1436
+ /**
1437
+ * Structure, not value. A new device, a lost one or a changed
1438
+ * binding changes the `cmps` set, so it needs a full reconcile
1439
+ * rather than a state push.
1440
+ */
1441
+ for (const category of [
1442
+ EventCategory.DeviceRegistered,
1443
+ EventCategory.DeviceUnregistered,
1444
+ EventCategory.DeviceBindingsChanged,
1445
+ EventCategory.DeviceMetaChanged,
1446
+ EventCategory.BrokerStatusChanged
1447
+ ]) this.subscribe({ category }, () => {
1448
+ this.reconcileSoon();
1449
+ });
1450
+ }
1451
+ onSliceChanged(data) {
1452
+ const deviceId = readNumber(data, "deviceId");
1453
+ const capName = readString(data, "capName");
1454
+ const slice = readRecord(data, "slice");
1455
+ if (deviceId === null || capName === null || slice === null) return;
1456
+ const deviceKey = this.keyByDeviceId.get(deviceId);
1457
+ if (deviceKey === void 0) return;
1458
+ if (capName === "device-status") {
1459
+ const online = slice["online"];
1460
+ if (typeof online === "boolean") this.push(deviceId, projectDeviceStatus(deviceKey, online));
1461
+ return;
1462
+ }
1463
+ if (capName === "battery") {
1464
+ const percentage = readNumber(slice, "percentage");
1465
+ const charging = readString(slice, "charging");
1466
+ const sleeping = slice["sleeping"];
1467
+ if (percentage === null || charging === null || typeof sleeping !== "boolean") return;
1468
+ const binary = slice["binary"];
1469
+ this.push(deviceId, projectBatterySlice(deviceKey, {
1470
+ percentage,
1471
+ charging,
1472
+ sleeping,
1473
+ ...typeof binary === "boolean" ? { binary } : {}
1474
+ }));
1475
+ return;
1476
+ }
1477
+ if (capName === "motion") {
1478
+ const detected = slice["detected"];
1479
+ const lastDetectedAt = readNumber(slice, "lastDetectedAt");
1480
+ if (typeof detected !== "boolean") return;
1481
+ this.push(deviceId, projectMotion(deviceKey, {
1482
+ detected,
1483
+ timestamp: lastDetectedAt ?? Date.now()
1484
+ }));
1485
+ }
1486
+ }
1487
+ onOnlineChanged(data, online) {
1488
+ const deviceId = readNumber(data, "deviceId");
1489
+ if (deviceId === null) return;
1490
+ const deviceKey = this.keyByDeviceId.get(deviceId);
1491
+ if (deviceKey === void 0) return;
1492
+ this.push(deviceId, projectDeviceStatus(deviceKey, online));
1493
+ }
1494
+ onMotion(data) {
1495
+ const deviceId = readNumber(data, "deviceId");
1496
+ const detected = readRecordField(data, "detected");
1497
+ if (deviceId === null || typeof detected !== "boolean") return;
1498
+ const deviceKey = this.keyByDeviceId.get(deviceId);
1499
+ if (deviceKey === void 0) return;
1500
+ const timestamp = readNumber(data, "timestamp") ?? Date.now();
1501
+ this.push(deviceId, projectMotion(deviceKey, {
1502
+ detected,
1503
+ timestamp
1504
+ }));
1505
+ }
1506
+ /**
1507
+ * The richest source: class, zone, identity, plate and the media key in
1508
+ * one payload. One subscription feeds most of a camera's catalog.
1509
+ */
1510
+ async onTrackLifecycle(data) {
1511
+ const deviceId = readNumber(data, "deviceId");
1512
+ const trackId = readString(data, "trackId");
1513
+ const phase = readString(data, "phase");
1514
+ if (deviceId === null || trackId === null) return;
1515
+ if (phase !== "start" && phase !== "update" && phase !== "end") return;
1516
+ const deviceKey = this.keyByDeviceId.get(deviceId);
1517
+ if (deviceKey === void 0) return;
1518
+ const classes = readStringArray(data, "classes");
1519
+ const bestClassName = readString(data, "bestClassName");
1520
+ const label = readString(data, "label");
1521
+ const plateText = readString(data, "plateText");
1522
+ const lastSeen = readNumber(data, "lastSeen") ?? Date.now();
1523
+ const zonesVisited = readStringArray(data, "zonesVisited");
1524
+ const mediaUrl = this.mintTrackMediaUrl(trackId);
1525
+ const values = projectTrackLifecycle(deviceKey, {
1526
+ deviceId,
1527
+ trackId,
1528
+ phase,
1529
+ classes,
1530
+ ...bestClassName !== null ? { bestClassName } : {},
1531
+ ...label !== null ? { label } : {},
1532
+ ...plateText !== null ? { plateText } : {},
1533
+ lastSeen,
1534
+ zonesVisited
1535
+ }, mediaUrl);
1536
+ if (values.length === 0) {
1537
+ this.ctx.logger.debug("ha-export: track produced no exportable state", {
1538
+ tags: { deviceId },
1539
+ meta: {
1540
+ trackId,
1541
+ bestClassName,
1542
+ phase
1543
+ }
1544
+ });
1545
+ return;
1546
+ }
1547
+ this.push(deviceId, values);
1548
+ }
1549
+ onZoneOccupancy(data) {
1550
+ const deviceId = readNumber(data, "deviceId");
1551
+ if (deviceId === null) return;
1552
+ const deviceKey = this.keyByDeviceId.get(deviceId);
1553
+ if (deviceKey === void 0) return;
1554
+ const zonesRaw = readRecordField(data, "zones");
1555
+ if (!Array.isArray(zonesRaw)) return;
1556
+ const zones = zonesRaw.flatMap((entry) => {
1557
+ const zoneId = readString(entry, "zoneId");
1558
+ const byClass = readRecord(entry, "byClass");
1559
+ if (zoneId === null) return [];
1560
+ return [{
1561
+ zoneId,
1562
+ byClass: toCountRecord(byClass)
1563
+ }];
1564
+ });
1565
+ if (zones.length === 0) return;
1566
+ this.push(deviceId, projectZoneOccupancy(deviceKey, { zones }));
1567
+ }
1568
+ /**
1569
+ * Send a device's values to every broker it is exported to.
1570
+ *
1571
+ * A failure on one broker's client cannot reach another: each has its
1572
+ * own dedup cache and its own availability, so one unreachable Home
1573
+ * Assistant degrades only its own entities.
1574
+ */
1575
+ push(deviceId, values) {
1576
+ const deviceKey = this.keyByDeviceId.get(deviceId);
1577
+ if (deviceKey === void 0) return;
1578
+ const exported = this.exported.get(deviceKey);
1579
+ if (exported === void 0) return;
1580
+ for (const brokerId of exported.brokerIds) {
1581
+ const link = this.links.get(brokerId);
1582
+ if (link === void 0) continue;
1583
+ for (const value of values) link.client.publishState(value.topic, value.value);
1584
+ }
1585
+ }
1586
+ reconcileQueued = false;
1587
+ async reconcileSoon() {
1588
+ if (this.reconcileQueued) return;
1589
+ this.reconcileQueued = true;
1590
+ setTimeout(() => {
1591
+ this.reconcileQueued = false;
1592
+ this.reconcile("structure-changed");
1593
+ }, 2e3);
1594
+ }
1595
+ /**
1596
+ * The contract.
1597
+ *
1598
+ * Re-resolves the brokers, rebuilds every plan, re-sends the COMPLETE
1599
+ * `cmps` set for every exported device, and re-pushes every value.
1600
+ * Events are an optimisation over this, never a substitute (D8/D11).
1601
+ *
1602
+ * A reconcile asked for while one is running is RE-RUN after it, never
1603
+ * dropped. It used to be dropped, and silently: a link that returned
1604
+ * mid-pass cleared its dedup cache and then lost the only re-announce
1605
+ * that would have refilled it, which is the exact defect this addon
1606
+ * just fixed, arriving through a different door.
1607
+ */
1608
+ async reconcile(reason) {
1609
+ if (this.reconcileInFlight) {
1610
+ this.reconcilePending = reason;
1611
+ this.ctx.logger.debug("ha-export: a reconcile is already running, re-running after it", { meta: { reason } });
1612
+ return;
1613
+ }
1614
+ this.reconcileInFlight = true;
1615
+ try {
1616
+ let next = reason;
1617
+ while (next !== null) {
1618
+ const current = next;
1619
+ this.reconcilePending = null;
1620
+ await this.runReconcile(current);
1621
+ next = this.reconcilePending;
1622
+ }
1623
+ } finally {
1624
+ this.reconcileInFlight = false;
1625
+ this.reconcilePending = null;
1626
+ }
1627
+ }
1628
+ /** One pass. Never throws: a failure degrades this pass and no more. */
1629
+ async runReconcile(reason) {
1630
+ const startedAt = Date.now();
1631
+ try {
1632
+ await this.refreshBrokers();
1633
+ await this.refreshMediaBaseUrl();
1634
+ const enabled = this.enabledBrokerIds();
1635
+ const devices = await this.ctx.api.deviceManager.listAll.query({});
1636
+ const snapshots = await this.ctx.api.deviceState.getAllSnapshots.query({});
1637
+ const byId = new Map(devices.map((device) => [device.id, device]));
1638
+ const exported = /* @__PURE__ */ new Map();
1639
+ const keyByDeviceId = /* @__PURE__ */ new Map();
1640
+ const allCaps = /* @__PURE__ */ new Set();
1641
+ let entityCount = 0;
1642
+ for (const deviceIdStr of exposedDeviceIds(this.config.membership, enabled)) {
1643
+ const numericId = Number(deviceIdStr);
1644
+ if (!Number.isFinite(numericId)) {
1645
+ this.ctx.logger.warn("ha-export: membership holds a non-numeric device id, skipping", { meta: { deviceId: deviceIdStr } });
1646
+ continue;
1647
+ }
1648
+ const device = byId.get(numericId);
1649
+ if (device === void 0) {
1650
+ this.ctx.logger.warn("ha-export: exported device is not in the registry, skipping", { tags: { deviceId: numericId } });
1651
+ continue;
1652
+ }
1653
+ /**
1654
+ * The echo guard. A device imported FROM Home Assistant is never
1655
+ * exported back to it: HA would mirror its own entity across the
1656
+ * bridge, and any automation touching it would round-trip.
1657
+ */
1658
+ if (device.addonId === HA_PROVIDER_ADDON_ID) {
1659
+ this.ctx.logger.warn("ha-export: refusing to export a device imported from Home Assistant", { tags: { deviceId: numericId } });
1660
+ continue;
1661
+ }
1662
+ const catalogDevice = await this.buildCatalogDevice(device, snapshots[String(numericId)]);
1663
+ for (const cap of catalogDevice.boundCaps) allCaps.add(cap);
1664
+ const plan = buildDevicePlan(catalogDevice);
1665
+ const brokerIds = brokersExposing(this.config.membership, deviceIdStr).filter((id) => enabled.includes(id));
1666
+ exported.set(plan.deviceKey, {
1667
+ plan,
1668
+ deviceId: numericId,
1669
+ target: {
1670
+ deviceId: numericId,
1671
+ type: device.type,
1672
+ boundCaps: catalogDevice.boundCaps
1673
+ },
1674
+ brokerIds
1675
+ });
1676
+ keyByDeviceId.set(numericId, plan.deviceKey);
1677
+ entityCount += Object.keys(plan.cmps).length;
1678
+ /**
1679
+ * The positive branch. Every other line in this loop describes a
1680
+ * device being DROPPED; without this one an operator can see why
1681
+ * a device is missing but cannot confirm which devices were
1682
+ * actually exported, to which Home Assistant, with which
1683
+ * entities. Silence reads as "never happened" both ways.
1684
+ */
1685
+ this.ctx.logger.info("ha-export: exporting device", {
1686
+ tags: { deviceId: numericId },
1687
+ meta: {
1688
+ deviceType: device.type,
1689
+ deviceKey: plan.deviceKey,
1690
+ brokers: brokerIds,
1691
+ entities: Object.keys(plan.cmps).length
1692
+ }
1693
+ });
1694
+ }
1695
+ this.exported = exported;
1696
+ this.keyByDeviceId = keyByDeviceId;
1697
+ this.entityCount = entityCount;
1698
+ this.unclassified = unclassifiedCaps([...allCaps]);
1699
+ await this.announce(exported);
1700
+ await this.pushFullState(exported, snapshots);
1701
+ this.ctx.logger.info("ha-export: reconciled", { meta: {
1702
+ reason,
1703
+ brokers: enabled.length,
1704
+ devices: exported.size,
1705
+ entities: entityCount,
1706
+ unclassifiedCaps: this.unclassified.length,
1707
+ durationMs: Date.now() - startedAt
1708
+ } });
1709
+ if (this.unclassified.length > 0) this.ctx.logger.warn("ha-export: capabilities in neither the map nor the exclusion list", { meta: { caps: this.unclassified } });
1710
+ this.lastError = void 0;
1711
+ } catch (err) {
1712
+ this.lastError = errMsg(err);
1713
+ this.ctx.logger.warn("ha-export: reconcile failed", { meta: { error: this.lastError } });
1714
+ }
1715
+ }
1716
+ /**
1717
+ * Send every exported device's COMPLETE component set, per broker.
1718
+ *
1719
+ * Wrapped per device: a device whose plan cannot be announced degrades
1720
+ * that device only and can never take the loop — or the runner — down
1721
+ * with it.
1722
+ */
1723
+ async announce(exported) {
1724
+ for (const entry of exported.values()) for (const brokerId of entry.brokerIds) {
1725
+ const link = this.links.get(brokerId);
1726
+ if (link === void 0) continue;
1727
+ try {
1728
+ await link.client.publishEntityChange(entry.plan.deviceKey, entry.plan.cmps, entry.plan.dev);
1729
+ } catch (err) {
1730
+ this.ctx.logger.warn("ha-export: could not announce a device, skipping it", {
1731
+ tags: { deviceId: entry.deviceId },
1732
+ meta: {
1733
+ brokerId,
1734
+ error: errMsg(err)
1735
+ }
1736
+ });
1737
+ }
1738
+ }
1739
+ }
1740
+ async pushFullState(exported, snapshots) {
1741
+ for (const entry of exported.values()) try {
1742
+ const values = await this.currentStateFor(entry, snapshots[String(entry.deviceId)]);
1743
+ this.push(entry.deviceId, values);
1744
+ } catch (err) {
1745
+ this.ctx.logger.warn("ha-export: could not read current state for a device", {
1746
+ tags: { deviceId: entry.deviceId },
1747
+ meta: { error: errMsg(err) }
1748
+ });
1749
+ }
1750
+ for (const link of this.links.values()) await link.client.flush();
1751
+ }
1752
+ /** Everything readable without waiting for an event. */
1753
+ async currentStateFor(entry, snapshotRaw) {
1754
+ const key = entry.plan.deviceKey;
1755
+ const values = [];
1756
+ const snapshot = toSnapshot(snapshotRaw);
1757
+ const online = snapshot["device-status"]?.["online"];
1758
+ if (typeof online === "boolean") values.push(...projectDeviceStatus(key, online));
1759
+ const battery = snapshot["battery"];
1760
+ if (battery !== void 0) {
1761
+ const percentage = readNumber(battery, "percentage");
1762
+ const charging = readString(battery, "charging");
1763
+ const sleeping = battery["sleeping"];
1764
+ if (percentage !== null && charging !== null && typeof sleeping === "boolean") {
1765
+ const binary = battery["binary"];
1766
+ values.push(...projectBatterySlice(key, {
1767
+ percentage,
1768
+ charging,
1769
+ sleeping,
1770
+ ...typeof binary === "boolean" ? { binary } : {}
1771
+ }));
1772
+ }
1773
+ }
1774
+ if (entry.target.type === "camera") {
1775
+ const group = await this.ctx.api.pipelineOrchestrator.getCameraSwitches.query({ deviceId: entry.deviceId });
1776
+ values.push(...projectCameraSwitches(key, group.switches.map((sw) => ({
1777
+ id: sw.id,
1778
+ label: sw.label,
1779
+ available: sw.available,
1780
+ enabled: sw.enabled
1781
+ }))));
1782
+ const occupancy = await this.ctx.api.zoneAnalytics.getCurrentSnapshot.query({ deviceId: entry.deviceId });
1783
+ if (occupancy !== null) values.push(...projectZoneOccupancy(key, { zones: occupancy.zones.map((zone) => ({
1784
+ zoneId: zone.zoneId,
1785
+ byClass: toCountRecord(zone.byClass)
1786
+ })) }));
1787
+ }
1788
+ return values;
1789
+ }
1790
+ async buildCatalogDevice(device, snapshotRaw) {
1791
+ const snapshot = toSnapshot(snapshotRaw);
1792
+ const boundCaps = await this.loadBoundCaps(device.id);
1793
+ const zones = device.type === "camera" ? await this.loadZones(device.id) : [];
1794
+ const switches = device.type === "camera" ? await this.loadSwitches(device.id) : [];
1795
+ const ptzPresets = device.type === "camera" && boundCaps.includes("ptz") ? await this.loadPresets(device.id) : [];
1796
+ const manufacturer = readString(device.metadata ?? {}, "manufacturer");
1797
+ const model = readString(device.metadata ?? {}, "model");
1798
+ return {
1799
+ id: device.id,
1800
+ stableId: device.stableId,
1801
+ name: device.name,
1802
+ type: device.type,
1803
+ ...manufacturer !== null ? { manufacturer } : {},
1804
+ ...model !== null ? { model } : {},
1805
+ features: device.features,
1806
+ boundCaps,
1807
+ zones,
1808
+ switches,
1809
+ ptzPresets,
1810
+ slices: Object.keys(snapshot)
1811
+ };
1812
+ }
1813
+ async loadBoundCaps(deviceId) {
1814
+ try {
1815
+ return (await this.ctx.api.deviceManager.getBindings.query({ deviceId })).entries.map((entry) => entry.capName);
1816
+ } catch (err) {
1817
+ this.ctx.logger.warn("ha-export: could not read bindings, exporting without them", {
1818
+ tags: { deviceId },
1819
+ meta: { error: errMsg(err) }
1820
+ });
1821
+ return [];
1822
+ }
1823
+ }
1824
+ async loadZones(deviceId) {
1825
+ try {
1826
+ const snapshot = await this.ctx.api.zoneAnalytics.getCurrentSnapshot.query({ deviceId });
1827
+ if (snapshot === null) return [];
1828
+ return snapshot.zones.map((zone) => ({
1829
+ id: zone.zoneId,
1830
+ name: zone.zoneName
1831
+ }));
1832
+ } catch (err) {
1833
+ this.ctx.logger.warn("ha-export: could not read zones, exporting no zone entities", {
1834
+ tags: { deviceId },
1835
+ meta: { error: errMsg(err) }
1836
+ });
1837
+ return [];
1838
+ }
1839
+ }
1840
+ async loadSwitches(deviceId) {
1841
+ try {
1842
+ return (await this.ctx.api.pipelineOrchestrator.getCameraSwitches.query({ deviceId })).switches.map((sw) => ({
1843
+ id: sw.id,
1844
+ label: sw.label,
1845
+ available: sw.available
1846
+ }));
1847
+ } catch (err) {
1848
+ /**
1849
+ * A source that did not answer produces NO control, never one
1850
+ * defaulted to on. This is the rule `CameraSwitch.available`
1851
+ * exists to enforce, and it has to survive the read failing.
1852
+ */
1853
+ this.ctx.logger.warn("ha-export: could not read camera switches, exporting no controls", {
1854
+ tags: { deviceId },
1855
+ meta: { error: errMsg(err) }
1856
+ });
1857
+ return [];
1858
+ }
1859
+ }
1860
+ async loadPresets(deviceId) {
1861
+ try {
1862
+ const device = await this.ctx.fetchDevice(deviceId);
1863
+ if (device.ptz === void 0) return [];
1864
+ return (await device.ptz.getPresets({})).map((preset) => preset.name);
1865
+ } catch (err) {
1866
+ this.ctx.logger.debug("ha-export: could not probe PTZ presets", {
1867
+ tags: { deviceId },
1868
+ meta: { error: errMsg(err) }
1869
+ });
1870
+ return [];
1871
+ }
1872
+ }
1873
+ /**
1874
+ * Resolve every Home Assistant broker, and open a push client for the
1875
+ * ones the operator ticked.
1876
+ *
1877
+ * Credentials come through the `broker` cap — the sanctioned no-import
1878
+ * path — and the owning `addonId` is sent with the read: `broker` is a
1879
+ * COLLECTION cap, so an unpinned id-keyed call routes to the
1880
+ * first-registered provider, which returns `null` for a broker it does
1881
+ * not own.
1882
+ */
1883
+ async refreshBrokers() {
1884
+ const haBrokers = (await this.ctx.api.broker.list.query({})).filter((broker) => broker.kind === "home-assistant");
1885
+ const known = haBrokers.map((broker) => ({
1886
+ id: broker.id,
1887
+ name: broker.name
1888
+ }));
1889
+ const pruned = pruneBrokers(this.config.membership, haBrokers.map((broker) => broker.id));
1890
+ if (pruned !== this.config.membership || !sameBrokers(known, this.config.knownBrokers)) await this.updateGlobalSettings({
1891
+ knownBrokers: known,
1892
+ membership: pruned
1893
+ });
1894
+ const wanted = new Set(this.enabledBrokerIds());
1895
+ for (const [brokerId, link] of this.links) {
1896
+ if (wanted.has(brokerId)) continue;
1897
+ await link.client.dispose();
1898
+ this.links.delete(brokerId);
1899
+ this.ctx.logger.info("ha-export: stopped exporting to a broker", { meta: { brokerId } });
1900
+ }
1901
+ for (const broker of haBrokers) {
1902
+ if (!wanted.has(broker.id)) continue;
1903
+ if (this.links.has(broker.id)) continue;
1904
+ const resolved = await this.resolveBrokerConnection(broker.id, broker.addonId);
1905
+ if (resolved === null) continue;
1906
+ const client = new PushClient({
1907
+ transport: (message) => postToHomeAssistant(resolved.baseUrl, resolved.token, message),
1908
+ logger: this.ctx.logger,
1909
+ brokerId: broker.id,
1910
+ onLinkRestored: (brokerId) => this.reachability.request(brokerId)
1911
+ });
1912
+ client.start();
1913
+ this.links.set(broker.id, {
1914
+ brokerId: broker.id,
1915
+ name: broker.name,
1916
+ baseUrl: resolved.baseUrl,
1917
+ token: resolved.token,
1918
+ client
1919
+ });
1920
+ this.ctx.logger.info("ha-export: exporting to Home Assistant", { meta: {
1921
+ brokerId: broker.id,
1922
+ baseUrl: resolved.baseUrl
1923
+ } });
1924
+ }
1925
+ }
1926
+ async resolveBrokerConnection(brokerId, addonId) {
1927
+ try {
1928
+ const raw = await this.ctx.api.broker.getBrokerConfig.query({
1929
+ id: brokerId,
1930
+ addonId
1931
+ });
1932
+ if (raw === null) {
1933
+ this.ctx.logger.warn("ha-export: broker returned no configuration, not exporting to it", { meta: { brokerId } });
1934
+ return null;
1935
+ }
1936
+ const supervisorToken = readString(raw, "supervisorToken");
1937
+ if (supervisorToken !== null)
1938
+ /**
1939
+ * A Home Assistant OS install has no `baseUrl` — the supervisor
1940
+ * proxies Core on a fixed internal address. Handling this branch
1941
+ * is the difference between working and pushing at `undefined`.
1942
+ */
1943
+ return {
1944
+ baseUrl: "http://supervisor/core",
1945
+ token: supervisorToken
1946
+ };
1947
+ const baseUrl = readString(raw, "baseUrl");
1948
+ const accessToken = readString(raw, "accessToken");
1949
+ if (baseUrl === null || accessToken === null) {
1950
+ this.ctx.logger.warn("ha-export: broker configuration has no URL or token, skipping it", { meta: { brokerId } });
1951
+ return null;
1952
+ }
1953
+ return {
1954
+ baseUrl,
1955
+ token: accessToken
1956
+ };
1957
+ } catch (err) {
1958
+ this.ctx.logger.warn("ha-export: could not read broker configuration", { meta: {
1959
+ brokerId,
1960
+ error: errMsg(err)
1961
+ } });
1962
+ return null;
1963
+ }
1964
+ }
1965
+ enabledBrokerIds() {
1966
+ return this.config.knownBrokers.filter((broker) => this.config[`exportTo:${broker.id}`] === true).map((broker) => broker.id);
1967
+ }
1968
+ brokerName(brokerId) {
1969
+ return this.config.knownBrokers.find((broker) => broker.id === brokerId)?.name ?? brokerId;
1970
+ }
1971
+ async handleCommandRoute(body, reply) {
1972
+ const topic = readString(body, "topic");
1973
+ const value = readString(body, "value");
1974
+ if (topic === null || value === null) {
1975
+ reply.status(422);
1976
+ reply.send({ error: "expected {topic, value}" });
1977
+ return;
1978
+ }
1979
+ const parsed = parseCommandTopic(topic);
1980
+ if (parsed === null) {
1981
+ this.ctx.logger.warn("ha-export: dropping a command on a malformed topic", { meta: { topic } });
1982
+ reply.status(422);
1983
+ reply.send({ error: "malformed topic" });
1984
+ return;
1985
+ }
1986
+ const exported = this.exported.get(parsed.deviceKey);
1987
+ if (exported === void 0) {
1988
+ this.ctx.logger.warn("ha-export: dropping a command for a device that is not exported", { meta: {
1989
+ topic,
1990
+ deviceKey: parsed.deviceKey
1991
+ } });
1992
+ reply.status(422);
1993
+ reply.send({ error: "device not exported" });
1994
+ return;
1995
+ }
1996
+ const command = resolveCommand(exported.target, parsed.entity, value);
1997
+ if (command === null) {
1998
+ this.ctx.logger.warn("ha-export: dropping an unroutable command", {
1999
+ tags: { deviceId: exported.deviceId },
2000
+ meta: {
2001
+ topic,
2002
+ entity: parsed.entity
2003
+ }
2004
+ });
2005
+ reply.status(422);
2006
+ reply.send({ error: "unroutable command" });
2007
+ return;
2008
+ }
2009
+ const applied = await this.dispatch(command);
2010
+ reply.status(applied ? 200 : 422);
2011
+ reply.send(applied ? {} : { error: "command not applied" });
2012
+ }
2013
+ async dispatch(command) {
2014
+ try {
2015
+ switch (command.kind) {
2016
+ case "camera-switch": {
2017
+ const parsed = CameraSwitchIdSchema.safeParse(command.switchId);
2018
+ if (!parsed.success) {
2019
+ this.ctx.logger.warn("ha-export: dropping a command for an unknown camera switch", {
2020
+ tags: { deviceId: command.deviceId },
2021
+ meta: { switchId: command.switchId }
2022
+ });
2023
+ return false;
2024
+ }
2025
+ await this.ctx.api.pipelineOrchestrator.setCameraSwitch.mutate({
2026
+ deviceId: command.deviceId,
2027
+ switchId: parsed.data,
2028
+ enabled: command.enabled
2029
+ });
2030
+ return true;
2031
+ }
2032
+ case "reboot": {
2033
+ const device = await this.ctx.fetchDevice(command.deviceId);
2034
+ if (device.reboot === void 0) return this.noProvider(command.deviceId, "reboot");
2035
+ await device.reboot.reboot({});
2036
+ return true;
2037
+ }
2038
+ case "ptz-move": {
2039
+ const device = await this.ctx.fetchDevice(command.deviceId);
2040
+ if (device.ptz === void 0) return this.noProvider(command.deviceId, "ptz");
2041
+ await device.ptz.move(PTZ_MOVE_BY_DIRECTION[command.direction] ?? {});
2042
+ return true;
2043
+ }
2044
+ case "ptz-preset": {
2045
+ const device = await this.ctx.fetchDevice(command.deviceId);
2046
+ if (device.ptz === void 0) return this.noProvider(command.deviceId, "ptz");
2047
+ const preset = (await device.ptz.getPresets({})).find((entry) => entry.name === command.preset);
2048
+ if (preset === void 0) {
2049
+ this.ctx.logger.warn("ha-export: dropping a preset command, no preset by that name", {
2050
+ tags: { deviceId: command.deviceId },
2051
+ meta: { preset: command.preset }
2052
+ });
2053
+ return false;
2054
+ }
2055
+ await device.ptz.goToPreset({ presetId: preset.id });
2056
+ return true;
2057
+ }
2058
+ case "snooze": return await this.applySnooze(command.deviceId, command.minutes);
2059
+ case "cap-switch": {
2060
+ const device = await this.ctx.fetchDevice(command.deviceId);
2061
+ if (device.switch === void 0) return this.noProvider(command.deviceId, "switch");
2062
+ await device.switch.setState({ on: command.on });
2063
+ return true;
2064
+ }
2065
+ case "cap-button": {
2066
+ const device = await this.ctx.fetchDevice(command.deviceId);
2067
+ if (device.button === void 0) return this.noProvider(command.deviceId, "button");
2068
+ await device.button.press({});
2069
+ return true;
2070
+ }
2071
+ }
2072
+ } catch (err) {
2073
+ /**
2074
+ * No optimistic ack. Every writable entity promises HA that the
2075
+ * state topic is reality; acking a failed call makes that a lie in
2076
+ * the only case that matters. The next reconcile corrects HA's UI.
2077
+ */
2078
+ this.ctx.logger.warn("ha-export: command not applied", {
2079
+ tags: { deviceId: command.deviceId },
2080
+ meta: {
2081
+ kind: command.kind,
2082
+ error: errMsg(err)
2083
+ }
2084
+ });
2085
+ return false;
2086
+ }
2087
+ }
2088
+ /** A cap the device does not provide. Refused, and never silently. */
2089
+ noProvider(deviceId, capName) {
2090
+ this.ctx.logger.warn("ha-export: dropping a command, the device has no provider for it", {
2091
+ tags: { deviceId },
2092
+ meta: { capName }
2093
+ });
2094
+ return false;
2095
+ }
2096
+ async applySnooze(deviceId, minutes) {
2097
+ if (minutes === null) {
2098
+ const { snoozes } = await this.ctx.api.notificationRules.listSnoozes.query({});
2099
+ const mine = snoozes.filter((snooze) => snooze.deviceId === deviceId);
2100
+ for (const snooze of mine) await this.ctx.api.notificationRules.cancelSnooze.mutate({ snoozeId: snooze.id });
2101
+ this.ctx.logger.info("ha-export: cancelled snoozes from Home Assistant", {
2102
+ tags: { deviceId },
2103
+ meta: { cancelled: mine.length }
2104
+ });
2105
+ return true;
2106
+ }
2107
+ await this.ctx.api.notificationRules.createSnooze.mutate({ snooze: {
2108
+ scope: "device",
2109
+ deviceId,
2110
+ durationMinutes: minutes
2111
+ } });
2112
+ this.ctx.logger.info("ha-export: snoozed from Home Assistant", {
2113
+ tags: { deviceId },
2114
+ meta: { minutes }
2115
+ });
2116
+ return true;
2117
+ }
2118
+ /**
2119
+ * A public, signed, expiring route Home Assistant can fetch bytes from.
2120
+ *
2121
+ * Public because the fetcher is HA's frontend or a phone, neither of
2122
+ * which holds a camstack token; signed and expiring so a leaked link
2123
+ * stops working on its own.
2124
+ */
2125
+ async serveMediaPlane() {
2126
+ const dataPlane = this.ctx.dataPlane;
2127
+ if (dataPlane === void 0) {
2128
+ this.ctx.logger.warn("ha-export: no data plane on this runner — image entities will carry no URL");
2129
+ return;
2130
+ }
2131
+ const served = await dataPlane.serve({
2132
+ prefix: "ha-media",
2133
+ access: "public",
2134
+ handler: async (req, res) => {
2135
+ await this.serveMedia(req, res);
2136
+ }
2137
+ });
2138
+ this.ctx.addDisposer(async () => {
2139
+ await served.dispose();
2140
+ });
2141
+ }
2142
+ async serveMedia(req, res) {
2143
+ const url = new URL(req.url ?? "/", "http://localhost");
2144
+ const segments = url.pathname.split("/").filter((part) => part.length > 0);
2145
+ const id = segments[segments.length - 1];
2146
+ if (id === void 0) {
2147
+ res.statusCode = 400;
2148
+ res.end();
2149
+ return;
2150
+ }
2151
+ const trackId = decodeURIComponent(id);
2152
+ if (!verifyMediaSignature({
2153
+ secret: this.mediaSecret,
2154
+ id: trackId,
2155
+ exp: url.searchParams.get("exp") ?? void 0,
2156
+ sig: url.searchParams.get("sig") ?? void 0,
2157
+ nowMs: Date.now()
2158
+ })) {
2159
+ res.statusCode = 403;
2160
+ res.end();
2161
+ return;
2162
+ }
2163
+ try {
2164
+ const first = (await this.ctx.api.pipelineAnalytics.getTrackMedia.query({
2165
+ trackId,
2166
+ kinds: [
2167
+ "thumbnailSmall",
2168
+ "thumbnail",
2169
+ "crop"
2170
+ ]
2171
+ }))[0];
2172
+ if (first === void 0) {
2173
+ res.statusCode = 404;
2174
+ res.end();
2175
+ return;
2176
+ }
2177
+ const bytes = Buffer.from(first.base64, "base64");
2178
+ res.statusCode = 200;
2179
+ res.setHeader("content-type", "image/jpeg");
2180
+ res.setHeader("cache-control", "public, max-age=3600");
2181
+ res.end(bytes);
2182
+ } catch (err) {
2183
+ this.ctx.logger.warn("ha-export: media fetch failed", { meta: {
2184
+ trackId,
2185
+ error: errMsg(err)
2186
+ } });
2187
+ res.statusCode = 502;
2188
+ res.end();
2189
+ }
2190
+ }
2191
+ /**
2192
+ * Mint a link, or `null`.
2193
+ *
2194
+ * `null` when the hub has no reachable base URL: a URL Home Assistant
2195
+ * cannot open is worse than no image, because it renders as a broken
2196
+ * entity rather than an empty one.
2197
+ */
2198
+ mintTrackMediaUrl(trackId) {
2199
+ if (this.mediaBaseUrl === null || this.mediaSecret.length === 0) return null;
2200
+ return buildMediaUrl({
2201
+ baseUrl: this.mediaBaseUrl,
2202
+ routePrefix: MEDIA_ROUTE_PREFIX,
2203
+ id: trackId,
2204
+ secret: this.mediaSecret,
2205
+ expMs: Date.now() + MEDIA_URL_TTL_MS
2206
+ });
2207
+ }
2208
+ async refreshMediaBaseUrl() {
2209
+ if (this.config.publicBaseUrl.length > 0) {
2210
+ this.mediaBaseUrl = this.config.publicBaseUrl;
2211
+ return;
2212
+ }
2213
+ try {
2214
+ const { endpoints } = await this.ctx.api.localNetwork.getConnectionEndpoints.query({ port: HUB_API_PORT });
2215
+ /**
2216
+ * Lowest priority wins, loopback never: a link Home Assistant
2217
+ * cannot open is worse than no image, because it renders as a
2218
+ * broken entity rather than an empty one.
2219
+ */
2220
+ const usable = [...endpoints].filter((endpoint) => endpoint.kind !== "loopback" && endpoint.plausible).sort((a, b) => a.priority - b.priority)[0];
2221
+ this.mediaBaseUrl = usable?.baseUrl ?? null;
2222
+ } catch (err) {
2223
+ this.mediaBaseUrl = null;
2224
+ this.ctx.logger.debug("ha-export: could not resolve a media base URL", { meta: { error: errMsg(err) } });
2225
+ }
2226
+ if (this.mediaBaseUrl === null) this.ctx.logger.warn("ha-export: no reachable hub URL — image entities will carry no value until one is set");
2227
+ }
2228
+ /**
2229
+ * The cap-level expose/unexpose applies to EVERY enabled broker.
2230
+ *
2231
+ * The `device-export` cap is the COMMON surface across export targets
2232
+ * and has no notion of a broker; per-broker granularity lives in the
2233
+ * derived Export panel, over the same store. There is exactly one
2234
+ * authority either way.
2235
+ */
2236
+ async setMembershipEverywhere(deviceId, exposed) {
2237
+ let next = this.config.membership;
2238
+ for (const brokerId of this.enabledBrokerIds()) next = setExposed(next, brokerId, deviceId, exposed);
2239
+ if (next === this.config.membership) {
2240
+ this.ctx.logger.debug("ha-export: membership already in that state, nothing to do", {
2241
+ tags: { deviceId: Number(deviceId) },
2242
+ meta: { exposed }
2243
+ });
2244
+ return;
2245
+ }
2246
+ await this.updateGlobalSettings({ membership: next });
2247
+ this.ctx.logger.info("ha-export: export membership changed", {
2248
+ tags: { deviceId: Number(deviceId) },
2249
+ meta: {
2250
+ exposed,
2251
+ brokers: this.enabledBrokerIds()
2252
+ }
2253
+ });
2254
+ await this.reconcile(exposed ? "expose" : "unexpose");
2255
+ }
2256
+ globalSettingsSchema() {
2257
+ const brokerFields = this.config.knownBrokers.map((broker) => ({
2258
+ type: "boolean",
2259
+ key: `exportTo:${broker.id}`,
2260
+ label: broker.name,
2261
+ description: `Push entities to this Home Assistant instance (${broker.id}).`,
2262
+ style: "switch",
2263
+ default: false
2264
+ }));
2265
+ return { sections: [{
2266
+ id: "ha-export-brokers",
2267
+ title: "Home Assistant instances",
2268
+ description: brokerFields.length > 0 ? "One switch per Home Assistant broker. An instance that is off receives nothing; the devices exported to it are remembered and resume when it is turned back on." : "No Home Assistant broker is registered yet. Add one under External systems → Brokers and it will appear here.",
2269
+ columns: 1,
2270
+ fields: brokerFields.length > 0 ? brokerFields : [{
2271
+ type: "info",
2272
+ key: "ha-export:__no-brokers",
2273
+ label: "No brokers",
2274
+ content: "Register a Home Assistant broker to enable the export.",
2275
+ variant: "warning"
2276
+ }]
2277
+ }, {
2278
+ id: "ha-export-transport",
2279
+ title: "Transport",
2280
+ columns: 1,
2281
+ fields: [{
2282
+ type: "text",
2283
+ key: "publicBaseUrl",
2284
+ label: "Hub URL Home Assistant can reach",
2285
+ description: "Used to build the signed image links Home Assistant fetches. Leave empty to derive one from the network capabilities; a link that cannot be built is never sent.",
2286
+ default: ""
2287
+ }, {
2288
+ type: "number",
2289
+ key: "reconcileIntervalSec",
2290
+ label: "Reconcile interval (s)",
2291
+ description: "Full re-announce and re-push cadence. Bus events are telemetry and may be dropped; this is the contract.",
2292
+ default: DEFAULT_CONFIG.reconcileIntervalSec
2293
+ }]
2294
+ }] };
2295
+ }
2296
+ /**
2297
+ * Per-device Export panel — one switch PER BROKER.
2298
+ *
2299
+ * Field keys are namespaced `haExport:<deviceId>:<brokerId>`: the
2300
+ * device-details form flattens fields by key across every contributing
2301
+ * section, so a bare `enabled` collides with the Alexa and HomeKit
2302
+ * exporters' identically-named fields and the operator sees another
2303
+ * exporter's value in this one's toggle.
2304
+ */
2305
+ buildContribution(deviceId) {
2306
+ const idStr = String(deviceId);
2307
+ const enabled = this.enabledBrokerIds();
2308
+ const fields = enabled.map((brokerId) => ({
2309
+ type: "boolean",
2310
+ key: `haExport:${deviceId}:${brokerId}`,
2311
+ label: `Export to ${this.brokerName(brokerId)}`,
2312
+ description: "Entities for this device are pushed to that Home Assistant instance.",
2313
+ style: "switch",
2314
+ value: isExposed(this.config.membership, brokerId, idStr),
2315
+ immediate: true
2316
+ }));
2317
+ return {
2318
+ tabs: [{
2319
+ id: "export",
2320
+ label: "Export",
2321
+ icon: "share-2",
2322
+ order: 80
2323
+ }],
2324
+ sections: [{
2325
+ id: "ha-export",
2326
+ title: "Home Assistant",
2327
+ description: enabled.length > 0 ? "Each Home Assistant instance receives only the devices switched on for it." : "No Home Assistant instance is enabled for export. Turn one on in the Home Assistant Export addon settings.",
2328
+ tab: "export",
2329
+ columns: 1,
2330
+ order: 30,
2331
+ fields: fields.length > 0 ? fields : [{
2332
+ type: "info",
2333
+ key: `haExport:${deviceId}:__none`,
2334
+ label: "Not configured",
2335
+ content: "Enable a Home Assistant instance in the addon settings first.",
2336
+ variant: "info"
2337
+ }]
2338
+ }]
2339
+ };
2340
+ }
2341
+ async applyDeviceSettingsPatch(deviceId, patch) {
2342
+ const idStr = String(deviceId);
2343
+ let next = this.config.membership;
2344
+ let touched = false;
2345
+ for (const brokerId of this.enabledBrokerIds()) {
2346
+ const key = `haExport:${deviceId}:${brokerId}`;
2347
+ if (!(key in patch)) continue;
2348
+ next = setExposed(next, brokerId, idStr, patch[key] === true);
2349
+ touched = true;
2350
+ }
2351
+ if (!touched) return { success: true };
2352
+ await this.updateGlobalSettings({ membership: next });
2353
+ this.ctx.logger.info("ha-export: per-broker export membership changed", {
2354
+ tags: { deviceId },
2355
+ meta: { brokers: brokersExposing(next, idStr) }
2356
+ });
2357
+ await this.reconcile("device-panel");
2358
+ return { success: true };
2359
+ }
2360
+ buildStatus() {
2361
+ const enabled = this.enabledBrokerIds();
2362
+ const healthy = [...this.links.values()].filter((link) => link.client.healthy).length;
2363
+ return {
2364
+ linkState: enabled.length === 0 ? "unlinked" : healthy === this.links.size ? "linked" : "error",
2365
+ exposedDeviceCount: exposedDeviceIds(this.config.membership, enabled).length,
2366
+ ...this.lastError !== void 0 ? { error: this.lastError } : {},
2367
+ setup: {
2368
+ fields: [
2369
+ {
2370
+ label: "Home Assistant instances",
2371
+ value: `${healthy} / ${this.links.size} reachable`
2372
+ },
2373
+ {
2374
+ label: "Entities pushed",
2375
+ value: String(this.entityCount)
2376
+ },
2377
+ {
2378
+ label: "Push endpoint",
2379
+ value: PUSH_PATH
2380
+ },
2381
+ {
2382
+ label: "Capabilities unclassified",
2383
+ value: this.unclassified.length === 0 ? "none" : this.unclassified.join(", ")
2384
+ },
2385
+ {
2386
+ label: "Snooze options",
2387
+ value: SNOOZE_OPTIONS.join(", ")
2388
+ }
2389
+ ],
2390
+ note: "CamStack pushes to the CamStack custom component in Home Assistant. Home Assistant never polls the hub for a value; when the heartbeat stops, every entity is marked unavailable."
2391
+ }
2392
+ };
2393
+ }
2394
+ startTimer() {
2395
+ this.stopTimer();
2396
+ const ms = Math.max(30, this.config.reconcileIntervalSec) * 1e3;
2397
+ this.reconcileTimer = setInterval(() => void this.reconcile("periodic"), ms);
2398
+ }
2399
+ stopTimer() {
2400
+ if (this.reconcileTimer !== null) clearInterval(this.reconcileTimer);
2401
+ this.reconcileTimer = null;
2402
+ }
2403
+ };
2404
+ /**
2405
+ * Device kinds the picker offers.
2406
+ *
2407
+ * Every kind camstack models: coverage is decided by a device's
2408
+ * CAPABILITIES, not its type, and restricting the picker by type is what
2409
+ * confined the previous exporter to cameras.
2410
+ */
2411
+ var SUPPORTED_DEVICE_KINDS = [
2412
+ "camera",
2413
+ "light",
2414
+ "siren",
2415
+ "switch",
2416
+ "sensor",
2417
+ "thermostat",
2418
+ "climate",
2419
+ "button",
2420
+ "event-emitter",
2421
+ "update",
2422
+ "generic",
2423
+ "lock",
2424
+ "cover",
2425
+ "valve",
2426
+ "humidifier",
2427
+ "water-heater",
2428
+ "fan",
2429
+ "media-player",
2430
+ "alarm-panel",
2431
+ "control",
2432
+ "presence",
2433
+ "weather",
2434
+ "vacuum",
2435
+ "lawn-mower",
2436
+ "container",
2437
+ "image",
2438
+ "pet-feeder",
2439
+ "hub"
2440
+ ];
2441
+ /**
2442
+ * POST one message to Home Assistant's push view.
2443
+ *
2444
+ * Authenticated with the broker's OWN long-lived token: the component
2445
+ * registers its view behind HA auth, so there is no second shared secret
2446
+ * to configure or rotate. A non-2xx throws, which is how the push client
2447
+ * learns the instance is unreachable.
2448
+ */
2449
+ async function postToHomeAssistant(baseUrl, token, message) {
2450
+ const response = await fetch(`${baseUrl.replace(/\/+$/, "")}${PUSH_PATH}`, {
2451
+ method: "POST",
2452
+ headers: {
2453
+ authorization: `Bearer ${token}`,
2454
+ "content-type": "application/json"
2455
+ },
2456
+ body: JSON.stringify(message),
2457
+ signal: AbortSignal.timeout(1e4)
2458
+ });
2459
+ if (!response.ok) throw new Error(`Home Assistant answered ${response.status}`);
2460
+ }
2461
+ function randomSecret() {
2462
+ const bytes = new Uint8Array(32);
2463
+ crypto.getRandomValues(bytes);
2464
+ return [...bytes].map((byte) => byte.toString(16).padStart(2, "0")).join("");
2465
+ }
2466
+ function sameBrokers(a, b) {
2467
+ if (a.length !== b.length) return false;
2468
+ return a.every((broker, index) => {
2469
+ const other = b[index];
2470
+ return other !== void 0 && other.id === broker.id && other.name === broker.name;
2471
+ });
2472
+ }
2473
+ function toSnapshot(raw) {
2474
+ if (raw === null || raw === void 0 || typeof raw !== "object") return {};
2475
+ const out = {};
2476
+ for (const [cap, slice] of Object.entries(raw)) if (slice !== null && typeof slice === "object") out[cap] = { ...slice };
2477
+ return out;
2478
+ }
2479
+ function toCountRecord(raw) {
2480
+ if (raw === null || typeof raw !== "object") return {};
2481
+ const out = {};
2482
+ for (const [key, value] of Object.entries(raw)) if (typeof value === "number") out[key] = value;
2483
+ return out;
2484
+ }
2485
+ function readRecordField(source, key) {
2486
+ if (source === null || typeof source !== "object") return void 0;
2487
+ return Reflect.get(source, key);
2488
+ }
2489
+ function readString(source, key) {
2490
+ const value = readRecordField(source, key);
2491
+ return typeof value === "string" && value.length > 0 ? value : null;
2492
+ }
2493
+ function readNumber(source, key) {
2494
+ const value = readRecordField(source, key);
2495
+ return typeof value === "number" && Number.isFinite(value) ? value : null;
2496
+ }
2497
+ function readRecord(source, key) {
2498
+ const value = readRecordField(source, key);
2499
+ if (value === null || typeof value !== "object") return null;
2500
+ return { ...value };
2501
+ }
2502
+ function readStringArray(source, key) {
2503
+ const value = readRecordField(source, key);
2504
+ if (!Array.isArray(value)) return [];
2505
+ return value.filter((entry) => typeof entry === "string");
2506
+ }
2507
+ function errMsg(err) {
2508
+ return err instanceof Error ? err.message : String(err);
2509
+ }
2510
+ //#endregion
2511
+ export { HaExportAddon, HaExportAddon as default };