@camstack/addon-provider-homeassistant 1.2.18 → 1.2.20

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,4 +1,4 @@
1
- import { G as oauthIntegrationCapability, O as deviceExportCapability, bt as string, c as addonRoutesCapability, ct as BaseAddon, i as CameraSwitchIdSchema, r as COCO_TO_MACRO, v as buildAddonRouteProvider, wt as EventCategory } from "../dist-UfT_fD3-.mjs";
1
+ import { G as oauthIntegrationCapability, O as deviceExportCapability, bt as string, c as addonRoutesCapability, ct as BaseAddon, i as CameraSwitchIdSchema, r as COCO_TO_MACRO, v as buildAddonRouteProvider, wt as EventCategory } from "../dist-BSQpoIVg.mjs";
2
2
  import { createHmac, timingSafeEqual } from "node:crypto";
3
3
  //#region src/ha-export/topics.ts
4
4
  /**
@@ -143,6 +143,16 @@ var ZONE_MACROS = [
143
143
  * So the flag is a required field on every spec. A new entity — or the
144
144
  * synthetic devices the design addendum still owes — cannot inherit a
145
145
  * camera's pressure valve by accident.
146
+ *
147
+ * **`last_image` is exempt, on every level of the fan-out** (operator,
148
+ * 2026-08-09). The valve exists to stop a fleet's worth of entities
149
+ * arriving switched on, but the picture IS the reason to look at a
150
+ * notification: an operator who wires `person_detected` into an
151
+ * automation wants the frame with it, and having to hunt through HA's
152
+ * disabled-entity list per camera per macro per zone is the opposite of
153
+ * what the export is for. The three `*_last_image` entities — camera,
154
+ * per macro, per zone × macro — therefore ship enabled. They cost one
155
+ * entity each and carry a signed URL, not bytes.
146
156
  */
147
157
  /**
148
158
  * The snooze surface, as a `select`.
@@ -213,7 +223,14 @@ function deviceBlock(device) {
213
223
  */
214
224
  function zoneSpecs(zone) {
215
225
  const slug = toSlug(zone.id);
216
- const specs = [];
226
+ const specs = [{
227
+ entity: `${slug}_objects`,
228
+ platform: "sensor",
229
+ label: `${zone.name} objects`,
230
+ uniqueSuffix: `${zone.id}_objects`,
231
+ icon: "mdi:counter",
232
+ enabledByDefault: true
233
+ }];
217
234
  for (const macro of ZONE_MACROS) {
218
235
  const label = `${zone.name} ${macro}`;
219
236
  specs.push({
@@ -228,7 +245,7 @@ function zoneSpecs(zone) {
228
245
  platform: "image",
229
246
  label: `${label} last image`,
230
247
  uniqueSuffix: `${zone.id}_${macro}_last_image`,
231
- enabledByDefault: false
248
+ enabledByDefault: true
232
249
  }, {
233
250
  entity: `${slug}_${macro}_last_detection`,
234
251
  platform: "sensor",
@@ -270,6 +287,54 @@ function cameraSpecs(device) {
270
287
  label: "Last image",
271
288
  enabledByDefault: true
272
289
  },
290
+ (
291
+ /**
292
+ * The SCENE — everything on camera, in a zone or not, and including
293
+ * confirmed stationary objects (they are still there). The frame-wide
294
+ * scope of the same occupancy snapshot the per-zone counters come from,
295
+ * which was computed and simply never projected.
296
+ */
297
+ {
298
+ entity: "objects",
299
+ platform: "sensor",
300
+ label: "Objects",
301
+ icon: "mdi:counter",
302
+ enabledByDefault: true
303
+ }),
304
+ (
305
+ /**
306
+ * What was heard, and how loud. Fed by `pipeline.audio-inference-result`
307
+ * — the event this addon did not subscribe to at all, which is why
308
+ * `audio_detected` below has effectively never moved.
309
+ *
310
+ * `audio_last_sound`, NOT `audio_last_label`: `<macro>_last_label` is the
311
+ * TRACK path's naming, reserved for the macros that carry an
312
+ * identification (`LABELLED_MACROS`), and `audio` is deliberately not one
313
+ * of them because the track lifecycle has no label for it. Reusing that
314
+ * name would put two sources on one topic — the classifier here and the
315
+ * track projector there — which is the two-knobs failure this repo keeps
316
+ * paying for. Different source, different entity, different name.
317
+ *
318
+ * dBFS is negative-going (0 = full scale), so silence reads as a large
319
+ * negative number rather than as a missing value. There is no unit
320
+ * declared: HA has no dBFS device class, and mislabelling it `dB` would
321
+ * put a plausible wrong unit on a dashboard.
322
+ */
323
+ {
324
+ entity: "audio_last_sound",
325
+ platform: "sensor",
326
+ label: "Audio last sound",
327
+ icon: "mdi:ear-hearing",
328
+ enabledByDefault: true
329
+ }),
330
+ {
331
+ entity: "audio_volume",
332
+ platform: "sensor",
333
+ label: "Audio level",
334
+ unit: "dBFS",
335
+ icon: "mdi:volume-high",
336
+ enabledByDefault: false
337
+ },
273
338
  {
274
339
  entity: "last_detection",
275
340
  platform: "sensor",
@@ -345,6 +410,60 @@ function cameraSpecs(device) {
345
410
  enabledByDefault: true
346
411
  });
347
412
  }
413
+ /**
414
+ * The doorbell, when the operator has bound the cap to this camera.
415
+ *
416
+ * A press is not a detection macro — it is not something the pipeline
417
+ * classifies — so it is NOT in `EXPORTED_MACROS`, and `MACROS_NOT_EXPORTED`
418
+ * says a doorbell is an actuator on its own device. Both stay true for the
419
+ * BUTTON accessory. What was missing is the camera case: a camera with the
420
+ * `doorbell` cap bound rings, and the operator automates on the ring the same
421
+ * way they automate on `person_detected`. Reolink's own doorbell is exactly
422
+ * this shape (`ReolinkSimpleEvent.type === 'doorbell'` at the camera level),
423
+ * and so is any camera the `virtual-doorbell` wrapper is pointed at.
424
+ *
425
+ * Every entity here is fed by the `doorbell` runtime-state slice
426
+ * (`lastPressedAt`, `pressCountSinceStart`) on `device.state-changed` —
427
+ * the same road the `battery` slice already travels.
428
+ *
429
+ * **`doorbell_last_image` is owed, and the frame for it already exists.**
430
+ * A press DOES have a picture: `sensor-marker-projector.ts` materialises a
431
+ * synthetic marker track carrying a `keyFrameSmall` of the camera, taken
432
+ * before the track is persisted, and the notification names that track as
433
+ * its media owner. What is missing is only the road to THIS addon:
434
+ * `persistSyntheticTrack` writes the row directly and emits no
435
+ * `pipeline-analytics.track-lifecycle`, which is the single event the export
436
+ * listens to for frames. So the export never hears about the marker while
437
+ * the notification centre shows its photo — measured 2026-08-09.
438
+ *
439
+ * It is NOT fixed by taking a second snapshot here at push time: the marker
440
+ * projector's own record argues that out (one snapshot, one owner, one
441
+ * persistence point — the producer's), and a second photograph of a doorway
442
+ * seconds after the ring is a different picture from the one in the
443
+ * notification. The fix belongs upstream, at the point that already owns the
444
+ * frame. Named here rather than shipped as an entity nothing feeds.
445
+ */
446
+ if (device.boundCaps.includes("doorbell")) specs.push({
447
+ entity: "doorbell_pressed",
448
+ platform: "binary_sensor",
449
+ label: "Doorbell pressed",
450
+ deviceClass: "occupancy",
451
+ enabledByDefault: true
452
+ }, {
453
+ entity: "doorbell_last_pressed",
454
+ platform: "sensor",
455
+ label: "Doorbell last pressed",
456
+ deviceClass: "timestamp",
457
+ icon: "mdi:bell-ring",
458
+ enabledByDefault: true
459
+ }, {
460
+ entity: "doorbell_press_count",
461
+ platform: "sensor",
462
+ label: "Doorbell presses since restart",
463
+ icon: "mdi:counter",
464
+ entityCategory: "diagnostic",
465
+ enabledByDefault: false
466
+ });
348
467
  specs.push({
349
468
  entity: "snooze",
350
469
  platform: "select",
@@ -365,13 +484,19 @@ function cameraSpecs(device) {
365
484
  entity: `${macro}_last_image`,
366
485
  platform: "image",
367
486
  label: `${label} last image`,
368
- enabledByDefault: false
487
+ enabledByDefault: true
369
488
  }, {
370
489
  entity: `${macro}_last_detection`,
371
490
  platform: "sensor",
372
491
  label: `${label} last detection`,
373
492
  deviceClass: "timestamp",
374
493
  enabledByDefault: false
494
+ }, {
495
+ entity: `${macro}_objects`,
496
+ platform: "sensor",
497
+ label: `${label} objects`,
498
+ icon: "mdi:counter",
499
+ enabledByDefault: false
375
500
  });
376
501
  if (LABELLED_MACROS.includes(macro)) specs.push({
377
502
  entity: `${macro}_last_label`,
@@ -867,9 +992,38 @@ function pruneBrokers(membership, knownBrokerIds) {
867
992
  return next;
868
993
  }
869
994
  /**
870
- * Scopes granted to a linked Home Assistant, derived from the tRPC paths the
871
- * component actually calls. `category:system [create]` was rejected: it would
872
- * hand `addons.installPackage` to a home-automation bridge.
995
+ * The brokers the operator has switched ON, read from the RAW addon store.
996
+ *
997
+ * `exportTo:<brokerId>` is a **dynamic** settings key — one per broker the
998
+ * hub happens to know about — so it can never appear in the addon's
999
+ * `DEFAULT_CONFIG`. `BaseAddon.resolveConfig` copies only keys that are
1000
+ * present in the defaults ("the store can contain extra keys … without
1001
+ * polluting the typed config"), so every `exportTo:*` key is dropped on the
1002
+ * way into `this.config` and reading one there yields `undefined` for ever.
1003
+ *
1004
+ * That is not theoretical. On 2026-08-09 the switch was on
1005
+ * (`getGlobalSettings` returned `exportTo:ha_001 → value: true`, because the
1006
+ * settings FORM reads the store) and every reconcile still logged
1007
+ * `brokers=0`: nothing was ever exported, the per-device Export tab rendered
1008
+ * its "Not configured" card, and no operator action could have changed
1009
+ * either. The store is the single authority — read it, do not mirror it into
1010
+ * a typed field that would then be a second one (D62).
1011
+ *
1012
+ * A broker that is no longer known is not enabled, whatever the store says:
1013
+ * a stale `exportTo:` key for a deleted instance must not resurrect it.
1014
+ */
1015
+ function enabledBrokerIds(store, knownBrokerIds) {
1016
+ return knownBrokerIds.filter((brokerId) => store[`exportTo:${brokerId}`] === true);
1017
+ }
1018
+ /**
1019
+ * What a linked Home Assistant NEEDS to function — one entry per thing it
1020
+ * actually calls with this token, each named. Not a blast radius, not a
1021
+ * conservative under-declaration: `requestedScopes` has exactly one meaning
1022
+ * across every integration, and since D103 it is also the ENFORCEMENT input, so
1023
+ * an under-declaration is an integration that stops working.
1024
+ *
1025
+ * `category:system [create]` was rejected and stays rejected: it would hand
1026
+ * `addons.installPackage` to a home-automation bridge.
873
1027
  */
874
1028
  var HOME_ASSISTANT_OAUTH_INTEGRATION = {
875
1029
  integrationId: "homeassistant",
@@ -894,11 +1048,54 @@ var HOME_ASSISTANT_OAUTH_INTEGRATION = {
894
1048
  type: "capability",
895
1049
  target: "pipeline-orchestrator",
896
1050
  access: ["view", "create"]
897
- }
1051
+ },
1052
+ (
1053
+ /**
1054
+ * `deviceExport.listExposedDevices` — the membership read that decides WHICH
1055
+ * devices the component imports. It is pinned to this addon so the unpinned
1056
+ * aggregate (which merges Alexa's and HomeKit's exposed sets, D12) cannot
1057
+ * leak another exporter's devices into Home Assistant.
1058
+ *
1059
+ * Missing until 2026-08-09, which broke setup outright: the component's very
1060
+ * first authenticated call is this one, and the hub answered "rejected the
1061
+ * token" — reported by Home Assistant as `Configurazione non riuscita:
1062
+ * deviceExport.listExposedDevices: hub rejected the token`. The call was
1063
+ * introduced with the export filter and the descriptor was never widened for
1064
+ * it. `system` scope, so a capability grant is what it needs — the `addon:`
1065
+ * grant below covers the HTTP command route and does NOT cover this.
1066
+ */
1067
+ {
1068
+ type: "capability",
1069
+ target: "device-export",
1070
+ access: ["view"]
1071
+ }),
1072
+ (
1073
+ /**
1074
+ * `POST /addon/homeassistant-export/command` — the ONLY path by which an
1075
+ * actuated Home Assistant entity (a camera switch, a PTZ button, reboot,
1076
+ * snooze) reaches CamStack. Added when D103 made the addon-route gate check
1077
+ * the grant instead of accepting any valid hub JWT; without it every HA
1078
+ * control entity answers `403 Token scope mismatch`. `view` is here for the
1079
+ * same addon's future GET routes, so a read does not force a re-link.
1080
+ */
1081
+ {
1082
+ type: "addon",
1083
+ target: "homeassistant-export",
1084
+ access: ["view", "create"]
1085
+ })
898
1086
  ],
899
1087
  allowedRedirectPrefixes: ["https://my.home-assistant.io/redirect/oauth"],
900
1088
  allowedPrivateHostPaths: ["/auth/external/callback"],
901
- requiresPkce: true
1089
+ requiresPkce: true,
1090
+ /**
1091
+ * One year. A Home Assistant config entry is meant to survive indefinitely
1092
+ * and re-linking is a manual trip through the UI, so the 30-day default
1093
+ * silently unlinked a working integration a month after setup. Finite rather
1094
+ * than `'never'` (which Alexa uses): an HA instance refreshes on a timer
1095
+ * while it is running, so a year is far beyond any realistic downtime, and a
1096
+ * bounded token is the better default where nothing is lost by it.
1097
+ */
1098
+ refreshTokenTtlSec: 365 * 24 * 60 * 60
902
1099
  };
903
1100
  //#endregion
904
1101
  //#region src/ha-export/push-client.ts
@@ -1248,6 +1445,10 @@ function projectZoneOccupancy(deviceKey, input) {
1248
1445
  const values = [];
1249
1446
  for (const zone of input.zones) {
1250
1447
  const slug = toSlug(zone.zoneId);
1448
+ values.push({
1449
+ topic: stateTopic(deviceKey, `${slug}_objects`),
1450
+ value: String(zone.totalObjects)
1451
+ });
1251
1452
  const totals = new Map(ZONE_MACROS.map((macro) => [macro, 0]));
1252
1453
  for (const [className, count] of Object.entries(zone.byClass)) {
1253
1454
  const macro = macroOf(className);
@@ -1259,6 +1460,22 @@ function projectZoneOccupancy(deviceKey, input) {
1259
1460
  value: String(count)
1260
1461
  });
1261
1462
  }
1463
+ if (input.scene !== void 0) {
1464
+ values.push({
1465
+ topic: stateTopic(deviceKey, "objects"),
1466
+ value: String(input.scene.totalObjects)
1467
+ });
1468
+ const totals = new Map(EXPORTED_MACROS.map((macro) => [macro, 0]));
1469
+ for (const [className, count] of Object.entries(input.scene.byClass)) {
1470
+ const macro = macroOf(className);
1471
+ if (macro === null || !totals.has(macro)) continue;
1472
+ totals.set(macro, (totals.get(macro) ?? 0) + count);
1473
+ }
1474
+ for (const [macro, count] of totals) values.push({
1475
+ topic: stateTopic(deviceKey, `${macro}_objects`),
1476
+ value: String(count)
1477
+ });
1478
+ }
1262
1479
  return values;
1263
1480
  }
1264
1481
  function projectBatterySlice(deviceKey, slice) {
@@ -1294,6 +1511,84 @@ function projectCameraSwitches(deviceKey, switches) {
1294
1511
  value: bool(sw.enabled)
1295
1512
  }));
1296
1513
  }
1514
+ /**
1515
+ * The doorbell slice → its three entities.
1516
+ *
1517
+ * A press is a PULSE: the slice carries `lastPressedAt`, never a
1518
+ * `pressed: true/false`. `doorbell_pressed` is therefore driven by the
1519
+ * caller, which knows whether this projection is a fresh press or a
1520
+ * reconcile re-read — the same shape `projectMotion` already uses, and the
1521
+ * reason the flag is a parameter rather than derived from a timestamp
1522
+ * comparison here (a pure function that reads the clock is a pure function
1523
+ * that behaves differently in a test).
1524
+ *
1525
+ * `lastPressedAt: null` publishes NOTHING for the timestamp: HA renders an
1526
+ * empty timestamp sensor as `unknown`, which is honest, whereas `0` would
1527
+ * render as 1 January 1970 on the operator's dashboard.
1528
+ */
1529
+ function projectDoorbellSlice(deviceKey, slice, pressed) {
1530
+ const values = [{
1531
+ topic: stateTopic(deviceKey, "doorbell_pressed"),
1532
+ value: bool(pressed)
1533
+ }, {
1534
+ topic: stateTopic(deviceKey, "doorbell_press_count"),
1535
+ value: String(slice.pressCountSinceStart)
1536
+ }];
1537
+ if (slice.lastPressedAt !== null) values.push({
1538
+ topic: stateTopic(deviceKey, "doorbell_last_pressed"),
1539
+ value: new Date(slice.lastPressedAt).toISOString()
1540
+ });
1541
+ return values;
1542
+ }
1543
+ /**
1544
+ * One audio window → the audio entities.
1545
+ *
1546
+ * This is the source the catalog's `audio_detected` was always missing.
1547
+ * It had only the track lifecycle, which rarely carries an audio macro, so
1548
+ * the entity existed and effectively never moved — the note in this
1549
+ * package's CLAUDE.md said so without naming the event. The event is
1550
+ * `pipeline.audio-inference-result`, and it carries both halves an
1551
+ * operator asks for: WHAT was heard and HOW LOUD.
1552
+ *
1553
+ * The label is the HIGHEST-CONFIDENCE detection of the window, not the
1554
+ * first: the analyzer's array order is its own business and depending on
1555
+ * it would make the exported label change with an unrelated refactor.
1556
+ *
1557
+ * A window with no detections publishes NO label. It does not publish an
1558
+ * empty string: HA renders that as a valid state, so a quiet minute would
1559
+ * blank the last thing heard instead of leaving it — and "what was that
1560
+ * noise" is asked after the noise has stopped.
1561
+ *
1562
+ * The level is published on EVERY window, silence included. That is the
1563
+ * point of a volume entity: a flat line is a reading, and dBFS is
1564
+ * negative-going (0 = full scale), so no value can be mistaken for one.
1565
+ */
1566
+ function projectAudioWindow(deviceKey, input) {
1567
+ const values = [];
1568
+ if (input.dbfs !== void 0 && Number.isFinite(input.dbfs)) values.push({
1569
+ topic: stateTopic(deviceKey, "audio_volume"),
1570
+ value: String(Math.round(input.dbfs * 10) / 10)
1571
+ });
1572
+ const best = input.detections.reduce((top, d) => top === null || d.confidence > top.confidence ? d : top, null);
1573
+ if (best !== null) values.push({
1574
+ topic: stateTopic(deviceKey, "audio_last_sound"),
1575
+ value: best.className
1576
+ });
1577
+ return values;
1578
+ }
1579
+ /** The `BrokerInfo.kind` tag the Home Assistant provider stamps. */
1580
+ var HA_BROKER_KIND = "home-assistant";
1581
+ /**
1582
+ * Every Home Assistant broker currently registered, cluster-wide.
1583
+ *
1584
+ * Unpinned by design (see the module docblock) and filtered by `kind`, which is
1585
+ * the contract check that matters: whatever mix of providers answered the
1586
+ * union, only `home-assistant` brokers are ever exported to over Home
1587
+ * Assistant's transport.
1588
+ */
1589
+ async function listHomeAssistantBrokers(query) {
1590
+ return (await query({})).filter((broker) => broker.kind === HA_BROKER_KIND);
1591
+ }
1297
1592
  //#endregion
1298
1593
  //#region src/ha-export/ha-export.addon.ts
1299
1594
  /**
@@ -1331,12 +1626,12 @@ function projectCameraSwitches(deviceKey, switches) {
1331
1626
  * different sets of cameras, and the per-device Export panel shows
1332
1627
  * one switch per broker over the single membership store.
1333
1628
  */
1334
- /** The addon that owns the import direction — never export back to it. */
1335
- var HA_PROVIDER_ADDON_ID = "provider-homeassistant";
1336
1629
  var ADDON_ID = "homeassistant-export";
1337
1630
  /** Where the custom component registers its push view inside HA. */
1338
1631
  var PUSH_PATH = "/api/camstack/push";
1339
1632
  var MEDIA_ROUTE_PREFIX = `/addon/${ADDON_ID}/ha-media`;
1633
+ /** How long `doorbell_pressed` stays on before its OFF edge is published. */
1634
+ var DOORBELL_PULSE_MS = 5e3;
1340
1635
  /** The hub's API port — where the data plane is reverse-proxied. */
1341
1636
  var HUB_API_PORT = 4443;
1342
1637
  /** One PTZ button press, as a relative move. */
@@ -1368,6 +1663,10 @@ var HaExportAddon = class extends BaseAddon {
1368
1663
  reconcilePending = null;
1369
1664
  lastError;
1370
1665
  entityCount = 0;
1666
+ /** deviceId → the `lastPressedAt` already rung, so a re-read does not ring again. */
1667
+ lastDoorbellPressAt = /* @__PURE__ */ new Map();
1668
+ /** deviceId → the pending OFF edge. HA releases nothing on its own. */
1669
+ doorbellReleaseTimers = /* @__PURE__ */ new Map();
1371
1670
  unclassified = [];
1372
1671
  /**
1373
1672
  * A link that returned repairs NOW, not at the next periodic pass.
@@ -1410,6 +1709,8 @@ var HaExportAddon = class extends BaseAddon {
1410
1709
  this.startTimer();
1411
1710
  this.ctx.addDisposer(async () => {
1412
1711
  this.stopTimer();
1712
+ for (const timer of this.doorbellReleaseTimers.values()) clearTimeout(timer);
1713
+ this.doorbellReleaseTimers.clear();
1413
1714
  this.reachability.dispose();
1414
1715
  for (const link of this.links.values()) await link.client.dispose();
1415
1716
  this.links.clear();
@@ -1466,6 +1767,15 @@ var HaExportAddon = class extends BaseAddon {
1466
1767
  this.subscribe({ category: EventCategory.MotionOnMotionChanged }, (event) => {
1467
1768
  this.onMotion(event.data);
1468
1769
  });
1770
+ /**
1771
+ * The audio window. This is the source `audio_detected` never had: it was
1772
+ * fed only by the track lifecycle, which rarely carries an audio macro, so
1773
+ * the entity existed and effectively never moved. It also carries the
1774
+ * window LEVEL, which nothing else on the export path does.
1775
+ */
1776
+ this.subscribe({ category: EventCategory.PipelineAudioInferenceResult }, (event) => {
1777
+ this.onAudioWindow(event.data);
1778
+ });
1469
1779
  this.subscribe({ category: EventCategory.PipelineAnalyticsTrackLifecycle }, (event) => {
1470
1780
  this.onTrackLifecycle(event.data);
1471
1781
  });
@@ -1513,6 +1823,40 @@ var HaExportAddon = class extends BaseAddon {
1513
1823
  }));
1514
1824
  return;
1515
1825
  }
1826
+ if (capName === "doorbell") {
1827
+ /**
1828
+ * A press is a PULSE — the slice carries `lastPressedAt`, never a
1829
+ * boolean. `doorbell_pressed` is therefore ON only when THIS delivery
1830
+ * advanced the timestamp, and the previous value is remembered per
1831
+ * device rather than compared against the clock: a reconcile re-read of
1832
+ * an unchanged slice must not ring the doorbell again in Home Assistant,
1833
+ * and a press that arrives while the export was restarting must still
1834
+ * ring once. HA auto-releases nothing, so the OFF edge is published on a
1835
+ * short timer — without it the binary sensor latches on for ever and the
1836
+ * next press produces no edge to automate on.
1837
+ */
1838
+ const lastPressedAt = readNumber(slice, "lastPressedAt");
1839
+ const pressCountSinceStart = readNumber(slice, "pressCountSinceStart") ?? 0;
1840
+ const previous = this.lastDoorbellPressAt.get(deviceId) ?? null;
1841
+ const pressed = lastPressedAt !== null && lastPressedAt !== previous;
1842
+ if (pressed) this.lastDoorbellPressAt.set(deviceId, lastPressedAt);
1843
+ this.push(deviceId, projectDoorbellSlice(deviceKey, {
1844
+ lastPressedAt,
1845
+ pressCountSinceStart
1846
+ }, pressed));
1847
+ if (pressed) {
1848
+ const existing = this.doorbellReleaseTimers.get(deviceId);
1849
+ if (existing !== void 0) clearTimeout(existing);
1850
+ this.doorbellReleaseTimers.set(deviceId, setTimeout(() => {
1851
+ this.doorbellReleaseTimers.delete(deviceId);
1852
+ this.push(deviceId, projectDoorbellSlice(deviceKey, {
1853
+ lastPressedAt,
1854
+ pressCountSinceStart
1855
+ }, false));
1856
+ }, DOORBELL_PULSE_MS));
1857
+ }
1858
+ return;
1859
+ }
1516
1860
  if (capName === "motion") {
1517
1861
  const detected = slice["detected"];
1518
1862
  const lastDetectedAt = readNumber(slice, "lastDetectedAt");
@@ -1585,6 +1929,28 @@ var HaExportAddon = class extends BaseAddon {
1585
1929
  }
1586
1930
  this.push(deviceId, values);
1587
1931
  }
1932
+ onAudioWindow(data) {
1933
+ const deviceId = readNumber(data, "deviceId");
1934
+ if (deviceId === null) return;
1935
+ const deviceKey = this.keyByDeviceId.get(deviceId);
1936
+ if (deviceKey === void 0) return;
1937
+ const frame = readRecordField(data, "frame");
1938
+ if (frame === null || typeof frame !== "object" || Array.isArray(frame)) return;
1939
+ const dbfs = readNumber(readRecord(frame, "level"), "dbfs");
1940
+ const raw = readRecordField(frame, "detections");
1941
+ const detections = Array.isArray(raw) ? raw.flatMap((entry) => {
1942
+ const className = readString(entry, "className");
1943
+ const confidence = readNumber(entry, "confidence");
1944
+ return className === null || confidence === null ? [] : [{
1945
+ className,
1946
+ confidence
1947
+ }];
1948
+ }) : [];
1949
+ this.push(deviceId, projectAudioWindow(deviceKey, {
1950
+ detections,
1951
+ ...dbfs !== null ? { dbfs } : {}
1952
+ }));
1953
+ }
1588
1954
  onZoneOccupancy(data) {
1589
1955
  const deviceId = readNumber(data, "deviceId");
1590
1956
  if (deviceId === null) return;
@@ -1598,11 +1964,20 @@ var HaExportAddon = class extends BaseAddon {
1598
1964
  if (zoneId === null) return [];
1599
1965
  return [{
1600
1966
  zoneId,
1967
+ totalObjects: readNumber(entry, "totalObjects") ?? 0,
1601
1968
  byClass: toCountRecord(byClass)
1602
1969
  }];
1603
1970
  });
1604
- if (zones.length === 0) return;
1605
- this.push(deviceId, projectZoneOccupancy(deviceKey, { zones }));
1971
+ const frame = readRecordField(data, "frame");
1972
+ const scene = frame !== null && typeof frame === "object" && !Array.isArray(frame) ? {
1973
+ totalObjects: readNumber(frame, "totalObjects") ?? 0,
1974
+ byClass: toCountRecord(readRecord(frame, "byClass"))
1975
+ } : void 0;
1976
+ if (zones.length === 0 && scene === void 0) return;
1977
+ this.push(deviceId, projectZoneOccupancy(deviceKey, {
1978
+ zones,
1979
+ ...scene !== void 0 ? { scene } : {}
1980
+ }));
1606
1981
  }
1607
1982
  /**
1608
1983
  * Send a device's values to every broker it is exported to.
@@ -1694,7 +2069,7 @@ var HaExportAddon = class extends BaseAddon {
1694
2069
  * exported back to it: HA would mirror its own entity across the
1695
2070
  * bridge, and any automation touching it would round-trip.
1696
2071
  */
1697
- if (device.addonId === HA_PROVIDER_ADDON_ID) {
2072
+ if (device.addonId === "provider-homeassistant") {
1698
2073
  this.ctx.logger.warn("ha-export: refusing to export a device imported from Home Assistant", { tags: { deviceId: numericId } });
1699
2074
  continue;
1700
2075
  }
@@ -1819,10 +2194,17 @@ var HaExportAddon = class extends BaseAddon {
1819
2194
  enabled: sw.enabled
1820
2195
  }))));
1821
2196
  const occupancy = await this.ctx.api.zoneAnalytics.getCurrentSnapshot.query({ deviceId: entry.deviceId });
1822
- if (occupancy !== null) values.push(...projectZoneOccupancy(key, { zones: occupancy.zones.map((zone) => ({
1823
- zoneId: zone.zoneId,
1824
- byClass: toCountRecord(zone.byClass)
1825
- })) }));
2197
+ if (occupancy !== null) values.push(...projectZoneOccupancy(key, {
2198
+ zones: occupancy.zones.map((zone) => ({
2199
+ zoneId: zone.zoneId,
2200
+ totalObjects: zone.totalObjects,
2201
+ byClass: toCountRecord(zone.byClass)
2202
+ })),
2203
+ scene: {
2204
+ totalObjects: occupancy.frame.totalObjects,
2205
+ byClass: toCountRecord(occupancy.frame.byClass)
2206
+ }
2207
+ }));
1826
2208
  }
1827
2209
  return values;
1828
2210
  }
@@ -1914,13 +2296,14 @@ var HaExportAddon = class extends BaseAddon {
1914
2296
  * ones the operator ticked.
1915
2297
  *
1916
2298
  * Credentials come through the `broker` cap — the sanctioned no-import
1917
- * path — and the owning `addonId` is sent with the read: `broker` is a
1918
- * COLLECTION cap, so an unpinned id-keyed call routes to the
1919
- * first-registered provider, which returns `null` for a broker it does
1920
- * not own.
2299
+ * path — and EVERY call carries the owning `addonId`: `broker` is a
2300
+ * COLLECTION cap, so an unpinned call is answered by the
2301
+ * first-registered provider (Homematic, on the operator's hub), which
2302
+ * knows nothing about `ha_*`. `broker-source.ts` holds the pin and the
2303
+ * reasoning.
1921
2304
  */
1922
2305
  async refreshBrokers() {
1923
- const haBrokers = (await this.ctx.api.broker.list.query({})).filter((broker) => broker.kind === "home-assistant");
2306
+ const haBrokers = await listHomeAssistantBrokers((input) => this.ctx.api.broker.list.query(input));
1924
2307
  const known = haBrokers.map((broker) => ({
1925
2308
  id: broker.id,
1926
2309
  name: broker.name
@@ -1930,6 +2313,7 @@ var HaExportAddon = class extends BaseAddon {
1930
2313
  knownBrokers: known,
1931
2314
  membership: pruned
1932
2315
  });
2316
+ await this.refreshEnabledBrokers(known);
1933
2317
  const wanted = new Set(this.enabledBrokerIds());
1934
2318
  for (const [brokerId, link] of this.links) {
1935
2319
  if (wanted.has(brokerId)) continue;
@@ -2001,8 +2385,23 @@ var HaExportAddon = class extends BaseAddon {
2001
2385
  return null;
2002
2386
  }
2003
2387
  }
2388
+ /**
2389
+ * The brokers switched on, as of the last {@link refreshEnabledBrokers}.
2390
+ *
2391
+ * NOT read from `this.config`: `exportTo:<brokerId>` is a dynamic key and
2392
+ * `BaseAddon.resolveConfig` copies only keys present in `DEFAULT_CONFIG`, so
2393
+ * it is `undefined` there for ever — see `enabledBrokerIds` in
2394
+ * `membership.ts` for what that cost. The store is the authority; this is a
2395
+ * read-through cache of it, refreshed at the top of every reconcile pass and
2396
+ * before the per-device panel renders, never written to.
2397
+ */
2398
+ enabled = [];
2399
+ async refreshEnabledBrokers(known) {
2400
+ const store = await this.resolveGlobalStore();
2401
+ this.enabled = enabledBrokerIds(store, known.map((broker) => broker.id));
2402
+ }
2004
2403
  enabledBrokerIds() {
2005
- return this.config.knownBrokers.filter((broker) => this.config[`exportTo:${broker.id}`] === true).map((broker) => broker.id);
2404
+ return this.enabled;
2006
2405
  }
2007
2406
  brokerName(brokerId) {
2008
2407
  return this.config.knownBrokers.find((broker) => broker.id === brokerId)?.name ?? brokerId;
@@ -2341,7 +2740,8 @@ var HaExportAddon = class extends BaseAddon {
2341
2740
  * exporters' identically-named fields and the operator sees another
2342
2741
  * exporter's value in this one's toggle.
2343
2742
  */
2344
- buildContribution(deviceId) {
2743
+ async buildContribution(deviceId) {
2744
+ await this.refreshEnabledBrokers(this.config.knownBrokers);
2345
2745
  const idStr = String(deviceId);
2346
2746
  const enabled = this.enabledBrokerIds();
2347
2747
  const fields = enabled.map((brokerId) => ({
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@camstack/addon-provider-homeassistant",
3
- "version": "1.2.18",
3
+ "version": "1.2.20",
4
4
  "description": "Home Assistant device provider addon for CamStack",
5
5
  "keywords": [
6
6
  "camstack",
@@ -46,8 +46,7 @@
46
46
  "brokerKind": "home-assistant",
47
47
  "supportsLocationImport": true,
48
48
  "execution": {
49
- "placement": "hub-only",
50
- "group": "notifiers"
49
+ "placement": "hub-only"
51
50
  },
52
51
  "capabilities": [
53
52
  {