@camstack/addon-provider-homeassistant 1.2.13 → 1.2.15

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