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