@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.
@@ -3,7 +3,7 @@ Object.defineProperties(exports, {
3
3
  [Symbol.toStringTag]: { value: "Module" }
4
4
  });
5
5
  //#endregion
6
- const require_dist = require("../dist-DuKJzmrJ.js");
6
+ const require_dist = require("../dist-Bz5ENMVc.js");
7
7
  let node_crypto = require("node:crypto");
8
8
  //#region src/ha-export/topics.ts
9
9
  /**
@@ -148,6 +148,16 @@ var ZONE_MACROS = [
148
148
  * So the flag is a required field on every spec. A new entity — or the
149
149
  * synthetic devices the design addendum still owes — cannot inherit a
150
150
  * camera's pressure valve by accident.
151
+ *
152
+ * **`last_image` is exempt, on every level of the fan-out** (operator,
153
+ * 2026-08-09). The valve exists to stop a fleet's worth of entities
154
+ * arriving switched on, but the picture IS the reason to look at a
155
+ * notification: an operator who wires `person_detected` into an
156
+ * automation wants the frame with it, and having to hunt through HA's
157
+ * disabled-entity list per camera per macro per zone is the opposite of
158
+ * what the export is for. The three `*_last_image` entities — camera,
159
+ * per macro, per zone × macro — therefore ship enabled. They cost one
160
+ * entity each and carry a signed URL, not bytes.
151
161
  */
152
162
  /**
153
163
  * The snooze surface, as a `select`.
@@ -218,7 +228,14 @@ function deviceBlock(device) {
218
228
  */
219
229
  function zoneSpecs(zone) {
220
230
  const slug = toSlug(zone.id);
221
- const specs = [];
231
+ const specs = [{
232
+ entity: `${slug}_objects`,
233
+ platform: "sensor",
234
+ label: `${zone.name} objects`,
235
+ uniqueSuffix: `${zone.id}_objects`,
236
+ icon: "mdi:counter",
237
+ enabledByDefault: true
238
+ }];
222
239
  for (const macro of ZONE_MACROS) {
223
240
  const label = `${zone.name} ${macro}`;
224
241
  specs.push({
@@ -233,7 +250,7 @@ function zoneSpecs(zone) {
233
250
  platform: "image",
234
251
  label: `${label} last image`,
235
252
  uniqueSuffix: `${zone.id}_${macro}_last_image`,
236
- enabledByDefault: false
253
+ enabledByDefault: true
237
254
  }, {
238
255
  entity: `${slug}_${macro}_last_detection`,
239
256
  platform: "sensor",
@@ -275,6 +292,54 @@ function cameraSpecs(device) {
275
292
  label: "Last image",
276
293
  enabledByDefault: true
277
294
  },
295
+ (
296
+ /**
297
+ * The SCENE — everything on camera, in a zone or not, and including
298
+ * confirmed stationary objects (they are still there). The frame-wide
299
+ * scope of the same occupancy snapshot the per-zone counters come from,
300
+ * which was computed and simply never projected.
301
+ */
302
+ {
303
+ entity: "objects",
304
+ platform: "sensor",
305
+ label: "Objects",
306
+ icon: "mdi:counter",
307
+ enabledByDefault: true
308
+ }),
309
+ (
310
+ /**
311
+ * What was heard, and how loud. Fed by `pipeline.audio-inference-result`
312
+ * — the event this addon did not subscribe to at all, which is why
313
+ * `audio_detected` below has effectively never moved.
314
+ *
315
+ * `audio_last_sound`, NOT `audio_last_label`: `<macro>_last_label` is the
316
+ * TRACK path's naming, reserved for the macros that carry an
317
+ * identification (`LABELLED_MACROS`), and `audio` is deliberately not one
318
+ * of them because the track lifecycle has no label for it. Reusing that
319
+ * name would put two sources on one topic — the classifier here and the
320
+ * track projector there — which is the two-knobs failure this repo keeps
321
+ * paying for. Different source, different entity, different name.
322
+ *
323
+ * dBFS is negative-going (0 = full scale), so silence reads as a large
324
+ * negative number rather than as a missing value. There is no unit
325
+ * declared: HA has no dBFS device class, and mislabelling it `dB` would
326
+ * put a plausible wrong unit on a dashboard.
327
+ */
328
+ {
329
+ entity: "audio_last_sound",
330
+ platform: "sensor",
331
+ label: "Audio last sound",
332
+ icon: "mdi:ear-hearing",
333
+ enabledByDefault: true
334
+ }),
335
+ {
336
+ entity: "audio_volume",
337
+ platform: "sensor",
338
+ label: "Audio level",
339
+ unit: "dBFS",
340
+ icon: "mdi:volume-high",
341
+ enabledByDefault: false
342
+ },
278
343
  {
279
344
  entity: "last_detection",
280
345
  platform: "sensor",
@@ -350,6 +415,60 @@ function cameraSpecs(device) {
350
415
  enabledByDefault: true
351
416
  });
352
417
  }
418
+ /**
419
+ * The doorbell, when the operator has bound the cap to this camera.
420
+ *
421
+ * A press is not a detection macro — it is not something the pipeline
422
+ * classifies — so it is NOT in `EXPORTED_MACROS`, and `MACROS_NOT_EXPORTED`
423
+ * says a doorbell is an actuator on its own device. Both stay true for the
424
+ * BUTTON accessory. What was missing is the camera case: a camera with the
425
+ * `doorbell` cap bound rings, and the operator automates on the ring the same
426
+ * way they automate on `person_detected`. Reolink's own doorbell is exactly
427
+ * this shape (`ReolinkSimpleEvent.type === 'doorbell'` at the camera level),
428
+ * and so is any camera the `virtual-doorbell` wrapper is pointed at.
429
+ *
430
+ * Every entity here is fed by the `doorbell` runtime-state slice
431
+ * (`lastPressedAt`, `pressCountSinceStart`) on `device.state-changed` —
432
+ * the same road the `battery` slice already travels.
433
+ *
434
+ * **`doorbell_last_image` is owed, and the frame for it already exists.**
435
+ * A press DOES have a picture: `sensor-marker-projector.ts` materialises a
436
+ * synthetic marker track carrying a `keyFrameSmall` of the camera, taken
437
+ * before the track is persisted, and the notification names that track as
438
+ * its media owner. What is missing is only the road to THIS addon:
439
+ * `persistSyntheticTrack` writes the row directly and emits no
440
+ * `pipeline-analytics.track-lifecycle`, which is the single event the export
441
+ * listens to for frames. So the export never hears about the marker while
442
+ * the notification centre shows its photo — measured 2026-08-09.
443
+ *
444
+ * It is NOT fixed by taking a second snapshot here at push time: the marker
445
+ * projector's own record argues that out (one snapshot, one owner, one
446
+ * persistence point — the producer's), and a second photograph of a doorway
447
+ * seconds after the ring is a different picture from the one in the
448
+ * notification. The fix belongs upstream, at the point that already owns the
449
+ * frame. Named here rather than shipped as an entity nothing feeds.
450
+ */
451
+ if (device.boundCaps.includes("doorbell")) specs.push({
452
+ entity: "doorbell_pressed",
453
+ platform: "binary_sensor",
454
+ label: "Doorbell pressed",
455
+ deviceClass: "occupancy",
456
+ enabledByDefault: true
457
+ }, {
458
+ entity: "doorbell_last_pressed",
459
+ platform: "sensor",
460
+ label: "Doorbell last pressed",
461
+ deviceClass: "timestamp",
462
+ icon: "mdi:bell-ring",
463
+ enabledByDefault: true
464
+ }, {
465
+ entity: "doorbell_press_count",
466
+ platform: "sensor",
467
+ label: "Doorbell presses since restart",
468
+ icon: "mdi:counter",
469
+ entityCategory: "diagnostic",
470
+ enabledByDefault: false
471
+ });
353
472
  specs.push({
354
473
  entity: "snooze",
355
474
  platform: "select",
@@ -370,13 +489,19 @@ function cameraSpecs(device) {
370
489
  entity: `${macro}_last_image`,
371
490
  platform: "image",
372
491
  label: `${label} last image`,
373
- enabledByDefault: false
492
+ enabledByDefault: true
374
493
  }, {
375
494
  entity: `${macro}_last_detection`,
376
495
  platform: "sensor",
377
496
  label: `${label} last detection`,
378
497
  deviceClass: "timestamp",
379
498
  enabledByDefault: false
499
+ }, {
500
+ entity: `${macro}_objects`,
501
+ platform: "sensor",
502
+ label: `${label} objects`,
503
+ icon: "mdi:counter",
504
+ enabledByDefault: false
380
505
  });
381
506
  if (LABELLED_MACROS.includes(macro)) specs.push({
382
507
  entity: `${macro}_last_label`,
@@ -872,9 +997,38 @@ function pruneBrokers(membership, knownBrokerIds) {
872
997
  return next;
873
998
  }
874
999
  /**
875
- * Scopes granted to a linked Home Assistant, derived from the tRPC paths the
876
- * component actually calls. `category:system [create]` was rejected: it would
877
- * hand `addons.installPackage` to a home-automation bridge.
1000
+ * The brokers the operator has switched ON, read from the RAW addon store.
1001
+ *
1002
+ * `exportTo:<brokerId>` is a **dynamic** settings key — one per broker the
1003
+ * hub happens to know about — so it can never appear in the addon's
1004
+ * `DEFAULT_CONFIG`. `BaseAddon.resolveConfig` copies only keys that are
1005
+ * present in the defaults ("the store can contain extra keys … without
1006
+ * polluting the typed config"), so every `exportTo:*` key is dropped on the
1007
+ * way into `this.config` and reading one there yields `undefined` for ever.
1008
+ *
1009
+ * That is not theoretical. On 2026-08-09 the switch was on
1010
+ * (`getGlobalSettings` returned `exportTo:ha_001 → value: true`, because the
1011
+ * settings FORM reads the store) and every reconcile still logged
1012
+ * `brokers=0`: nothing was ever exported, the per-device Export tab rendered
1013
+ * its "Not configured" card, and no operator action could have changed
1014
+ * either. The store is the single authority — read it, do not mirror it into
1015
+ * a typed field that would then be a second one (D62).
1016
+ *
1017
+ * A broker that is no longer known is not enabled, whatever the store says:
1018
+ * a stale `exportTo:` key for a deleted instance must not resurrect it.
1019
+ */
1020
+ function enabledBrokerIds(store, knownBrokerIds) {
1021
+ return knownBrokerIds.filter((brokerId) => store[`exportTo:${brokerId}`] === true);
1022
+ }
1023
+ /**
1024
+ * What a linked Home Assistant NEEDS to function — one entry per thing it
1025
+ * actually calls with this token, each named. Not a blast radius, not a
1026
+ * conservative under-declaration: `requestedScopes` has exactly one meaning
1027
+ * across every integration, and since D103 it is also the ENFORCEMENT input, so
1028
+ * an under-declaration is an integration that stops working.
1029
+ *
1030
+ * `category:system [create]` was rejected and stays rejected: it would hand
1031
+ * `addons.installPackage` to a home-automation bridge.
878
1032
  */
879
1033
  var HOME_ASSISTANT_OAUTH_INTEGRATION = {
880
1034
  integrationId: "homeassistant",
@@ -899,11 +1053,54 @@ var HOME_ASSISTANT_OAUTH_INTEGRATION = {
899
1053
  type: "capability",
900
1054
  target: "pipeline-orchestrator",
901
1055
  access: ["view", "create"]
902
- }
1056
+ },
1057
+ (
1058
+ /**
1059
+ * `deviceExport.listExposedDevices` — the membership read that decides WHICH
1060
+ * devices the component imports. It is pinned to this addon so the unpinned
1061
+ * aggregate (which merges Alexa's and HomeKit's exposed sets, D12) cannot
1062
+ * leak another exporter's devices into Home Assistant.
1063
+ *
1064
+ * Missing until 2026-08-09, which broke setup outright: the component's very
1065
+ * first authenticated call is this one, and the hub answered "rejected the
1066
+ * token" — reported by Home Assistant as `Configurazione non riuscita:
1067
+ * deviceExport.listExposedDevices: hub rejected the token`. The call was
1068
+ * introduced with the export filter and the descriptor was never widened for
1069
+ * it. `system` scope, so a capability grant is what it needs — the `addon:`
1070
+ * grant below covers the HTTP command route and does NOT cover this.
1071
+ */
1072
+ {
1073
+ type: "capability",
1074
+ target: "device-export",
1075
+ access: ["view"]
1076
+ }),
1077
+ (
1078
+ /**
1079
+ * `POST /addon/homeassistant-export/command` — the ONLY path by which an
1080
+ * actuated Home Assistant entity (a camera switch, a PTZ button, reboot,
1081
+ * snooze) reaches CamStack. Added when D103 made the addon-route gate check
1082
+ * the grant instead of accepting any valid hub JWT; without it every HA
1083
+ * control entity answers `403 Token scope mismatch`. `view` is here for the
1084
+ * same addon's future GET routes, so a read does not force a re-link.
1085
+ */
1086
+ {
1087
+ type: "addon",
1088
+ target: "homeassistant-export",
1089
+ access: ["view", "create"]
1090
+ })
903
1091
  ],
904
1092
  allowedRedirectPrefixes: ["https://my.home-assistant.io/redirect/oauth"],
905
1093
  allowedPrivateHostPaths: ["/auth/external/callback"],
906
- requiresPkce: true
1094
+ requiresPkce: true,
1095
+ /**
1096
+ * One year. A Home Assistant config entry is meant to survive indefinitely
1097
+ * and re-linking is a manual trip through the UI, so the 30-day default
1098
+ * silently unlinked a working integration a month after setup. Finite rather
1099
+ * than `'never'` (which Alexa uses): an HA instance refreshes on a timer
1100
+ * while it is running, so a year is far beyond any realistic downtime, and a
1101
+ * bounded token is the better default where nothing is lost by it.
1102
+ */
1103
+ refreshTokenTtlSec: 365 * 24 * 60 * 60
907
1104
  };
908
1105
  //#endregion
909
1106
  //#region src/ha-export/push-client.ts
@@ -1253,6 +1450,10 @@ function projectZoneOccupancy(deviceKey, input) {
1253
1450
  const values = [];
1254
1451
  for (const zone of input.zones) {
1255
1452
  const slug = toSlug(zone.zoneId);
1453
+ values.push({
1454
+ topic: stateTopic(deviceKey, `${slug}_objects`),
1455
+ value: String(zone.totalObjects)
1456
+ });
1256
1457
  const totals = new Map(ZONE_MACROS.map((macro) => [macro, 0]));
1257
1458
  for (const [className, count] of Object.entries(zone.byClass)) {
1258
1459
  const macro = macroOf(className);
@@ -1264,6 +1465,22 @@ function projectZoneOccupancy(deviceKey, input) {
1264
1465
  value: String(count)
1265
1466
  });
1266
1467
  }
1468
+ if (input.scene !== void 0) {
1469
+ values.push({
1470
+ topic: stateTopic(deviceKey, "objects"),
1471
+ value: String(input.scene.totalObjects)
1472
+ });
1473
+ const totals = new Map(EXPORTED_MACROS.map((macro) => [macro, 0]));
1474
+ for (const [className, count] of Object.entries(input.scene.byClass)) {
1475
+ const macro = macroOf(className);
1476
+ if (macro === null || !totals.has(macro)) continue;
1477
+ totals.set(macro, (totals.get(macro) ?? 0) + count);
1478
+ }
1479
+ for (const [macro, count] of totals) values.push({
1480
+ topic: stateTopic(deviceKey, `${macro}_objects`),
1481
+ value: String(count)
1482
+ });
1483
+ }
1267
1484
  return values;
1268
1485
  }
1269
1486
  function projectBatterySlice(deviceKey, slice) {
@@ -1299,6 +1516,84 @@ function projectCameraSwitches(deviceKey, switches) {
1299
1516
  value: bool(sw.enabled)
1300
1517
  }));
1301
1518
  }
1519
+ /**
1520
+ * The doorbell slice → its three entities.
1521
+ *
1522
+ * A press is a PULSE: the slice carries `lastPressedAt`, never a
1523
+ * `pressed: true/false`. `doorbell_pressed` is therefore driven by the
1524
+ * caller, which knows whether this projection is a fresh press or a
1525
+ * reconcile re-read — the same shape `projectMotion` already uses, and the
1526
+ * reason the flag is a parameter rather than derived from a timestamp
1527
+ * comparison here (a pure function that reads the clock is a pure function
1528
+ * that behaves differently in a test).
1529
+ *
1530
+ * `lastPressedAt: null` publishes NOTHING for the timestamp: HA renders an
1531
+ * empty timestamp sensor as `unknown`, which is honest, whereas `0` would
1532
+ * render as 1 January 1970 on the operator's dashboard.
1533
+ */
1534
+ function projectDoorbellSlice(deviceKey, slice, pressed) {
1535
+ const values = [{
1536
+ topic: stateTopic(deviceKey, "doorbell_pressed"),
1537
+ value: bool(pressed)
1538
+ }, {
1539
+ topic: stateTopic(deviceKey, "doorbell_press_count"),
1540
+ value: String(slice.pressCountSinceStart)
1541
+ }];
1542
+ if (slice.lastPressedAt !== null) values.push({
1543
+ topic: stateTopic(deviceKey, "doorbell_last_pressed"),
1544
+ value: new Date(slice.lastPressedAt).toISOString()
1545
+ });
1546
+ return values;
1547
+ }
1548
+ /**
1549
+ * One audio window → the audio entities.
1550
+ *
1551
+ * This is the source the catalog's `audio_detected` was always missing.
1552
+ * It had only the track lifecycle, which rarely carries an audio macro, so
1553
+ * the entity existed and effectively never moved — the note in this
1554
+ * package's CLAUDE.md said so without naming the event. The event is
1555
+ * `pipeline.audio-inference-result`, and it carries both halves an
1556
+ * operator asks for: WHAT was heard and HOW LOUD.
1557
+ *
1558
+ * The label is the HIGHEST-CONFIDENCE detection of the window, not the
1559
+ * first: the analyzer's array order is its own business and depending on
1560
+ * it would make the exported label change with an unrelated refactor.
1561
+ *
1562
+ * A window with no detections publishes NO label. It does not publish an
1563
+ * empty string: HA renders that as a valid state, so a quiet minute would
1564
+ * blank the last thing heard instead of leaving it — and "what was that
1565
+ * noise" is asked after the noise has stopped.
1566
+ *
1567
+ * The level is published on EVERY window, silence included. That is the
1568
+ * point of a volume entity: a flat line is a reading, and dBFS is
1569
+ * negative-going (0 = full scale), so no value can be mistaken for one.
1570
+ */
1571
+ function projectAudioWindow(deviceKey, input) {
1572
+ const values = [];
1573
+ if (input.dbfs !== void 0 && Number.isFinite(input.dbfs)) values.push({
1574
+ topic: stateTopic(deviceKey, "audio_volume"),
1575
+ value: String(Math.round(input.dbfs * 10) / 10)
1576
+ });
1577
+ const best = input.detections.reduce((top, d) => top === null || d.confidence > top.confidence ? d : top, null);
1578
+ if (best !== null) values.push({
1579
+ topic: stateTopic(deviceKey, "audio_last_sound"),
1580
+ value: best.className
1581
+ });
1582
+ return values;
1583
+ }
1584
+ /** The `BrokerInfo.kind` tag the Home Assistant provider stamps. */
1585
+ var HA_BROKER_KIND = "home-assistant";
1586
+ /**
1587
+ * Every Home Assistant broker currently registered, cluster-wide.
1588
+ *
1589
+ * Unpinned by design (see the module docblock) and filtered by `kind`, which is
1590
+ * the contract check that matters: whatever mix of providers answered the
1591
+ * union, only `home-assistant` brokers are ever exported to over Home
1592
+ * Assistant's transport.
1593
+ */
1594
+ async function listHomeAssistantBrokers(query) {
1595
+ return (await query({})).filter((broker) => broker.kind === HA_BROKER_KIND);
1596
+ }
1302
1597
  //#endregion
1303
1598
  //#region src/ha-export/ha-export.addon.ts
1304
1599
  /**
@@ -1336,12 +1631,12 @@ function projectCameraSwitches(deviceKey, switches) {
1336
1631
  * different sets of cameras, and the per-device Export panel shows
1337
1632
  * one switch per broker over the single membership store.
1338
1633
  */
1339
- /** The addon that owns the import direction — never export back to it. */
1340
- var HA_PROVIDER_ADDON_ID = "provider-homeassistant";
1341
1634
  var ADDON_ID = "homeassistant-export";
1342
1635
  /** Where the custom component registers its push view inside HA. */
1343
1636
  var PUSH_PATH = "/api/camstack/push";
1344
1637
  var MEDIA_ROUTE_PREFIX = `/addon/${ADDON_ID}/ha-media`;
1638
+ /** How long `doorbell_pressed` stays on before its OFF edge is published. */
1639
+ var DOORBELL_PULSE_MS = 5e3;
1345
1640
  /** The hub's API port — where the data plane is reverse-proxied. */
1346
1641
  var HUB_API_PORT = 4443;
1347
1642
  /** One PTZ button press, as a relative move. */
@@ -1373,6 +1668,10 @@ var HaExportAddon = class extends require_dist.BaseAddon {
1373
1668
  reconcilePending = null;
1374
1669
  lastError;
1375
1670
  entityCount = 0;
1671
+ /** deviceId → the `lastPressedAt` already rung, so a re-read does not ring again. */
1672
+ lastDoorbellPressAt = /* @__PURE__ */ new Map();
1673
+ /** deviceId → the pending OFF edge. HA releases nothing on its own. */
1674
+ doorbellReleaseTimers = /* @__PURE__ */ new Map();
1376
1675
  unclassified = [];
1377
1676
  /**
1378
1677
  * A link that returned repairs NOW, not at the next periodic pass.
@@ -1415,6 +1714,8 @@ var HaExportAddon = class extends require_dist.BaseAddon {
1415
1714
  this.startTimer();
1416
1715
  this.ctx.addDisposer(async () => {
1417
1716
  this.stopTimer();
1717
+ for (const timer of this.doorbellReleaseTimers.values()) clearTimeout(timer);
1718
+ this.doorbellReleaseTimers.clear();
1418
1719
  this.reachability.dispose();
1419
1720
  for (const link of this.links.values()) await link.client.dispose();
1420
1721
  this.links.clear();
@@ -1471,6 +1772,15 @@ var HaExportAddon = class extends require_dist.BaseAddon {
1471
1772
  this.subscribe({ category: require_dist.EventCategory.MotionOnMotionChanged }, (event) => {
1472
1773
  this.onMotion(event.data);
1473
1774
  });
1775
+ /**
1776
+ * The audio window. This is the source `audio_detected` never had: it was
1777
+ * fed only by the track lifecycle, which rarely carries an audio macro, so
1778
+ * the entity existed and effectively never moved. It also carries the
1779
+ * window LEVEL, which nothing else on the export path does.
1780
+ */
1781
+ this.subscribe({ category: require_dist.EventCategory.PipelineAudioInferenceResult }, (event) => {
1782
+ this.onAudioWindow(event.data);
1783
+ });
1474
1784
  this.subscribe({ category: require_dist.EventCategory.PipelineAnalyticsTrackLifecycle }, (event) => {
1475
1785
  this.onTrackLifecycle(event.data);
1476
1786
  });
@@ -1518,6 +1828,40 @@ var HaExportAddon = class extends require_dist.BaseAddon {
1518
1828
  }));
1519
1829
  return;
1520
1830
  }
1831
+ if (capName === "doorbell") {
1832
+ /**
1833
+ * A press is a PULSE — the slice carries `lastPressedAt`, never a
1834
+ * boolean. `doorbell_pressed` is therefore ON only when THIS delivery
1835
+ * advanced the timestamp, and the previous value is remembered per
1836
+ * device rather than compared against the clock: a reconcile re-read of
1837
+ * an unchanged slice must not ring the doorbell again in Home Assistant,
1838
+ * and a press that arrives while the export was restarting must still
1839
+ * ring once. HA auto-releases nothing, so the OFF edge is published on a
1840
+ * short timer — without it the binary sensor latches on for ever and the
1841
+ * next press produces no edge to automate on.
1842
+ */
1843
+ const lastPressedAt = readNumber(slice, "lastPressedAt");
1844
+ const pressCountSinceStart = readNumber(slice, "pressCountSinceStart") ?? 0;
1845
+ const previous = this.lastDoorbellPressAt.get(deviceId) ?? null;
1846
+ const pressed = lastPressedAt !== null && lastPressedAt !== previous;
1847
+ if (pressed) this.lastDoorbellPressAt.set(deviceId, lastPressedAt);
1848
+ this.push(deviceId, projectDoorbellSlice(deviceKey, {
1849
+ lastPressedAt,
1850
+ pressCountSinceStart
1851
+ }, pressed));
1852
+ if (pressed) {
1853
+ const existing = this.doorbellReleaseTimers.get(deviceId);
1854
+ if (existing !== void 0) clearTimeout(existing);
1855
+ this.doorbellReleaseTimers.set(deviceId, setTimeout(() => {
1856
+ this.doorbellReleaseTimers.delete(deviceId);
1857
+ this.push(deviceId, projectDoorbellSlice(deviceKey, {
1858
+ lastPressedAt,
1859
+ pressCountSinceStart
1860
+ }, false));
1861
+ }, DOORBELL_PULSE_MS));
1862
+ }
1863
+ return;
1864
+ }
1521
1865
  if (capName === "motion") {
1522
1866
  const detected = slice["detected"];
1523
1867
  const lastDetectedAt = readNumber(slice, "lastDetectedAt");
@@ -1590,6 +1934,28 @@ var HaExportAddon = class extends require_dist.BaseAddon {
1590
1934
  }
1591
1935
  this.push(deviceId, values);
1592
1936
  }
1937
+ onAudioWindow(data) {
1938
+ const deviceId = readNumber(data, "deviceId");
1939
+ if (deviceId === null) return;
1940
+ const deviceKey = this.keyByDeviceId.get(deviceId);
1941
+ if (deviceKey === void 0) return;
1942
+ const frame = readRecordField(data, "frame");
1943
+ if (frame === null || typeof frame !== "object" || Array.isArray(frame)) return;
1944
+ const dbfs = readNumber(readRecord(frame, "level"), "dbfs");
1945
+ const raw = readRecordField(frame, "detections");
1946
+ const detections = Array.isArray(raw) ? raw.flatMap((entry) => {
1947
+ const className = readString(entry, "className");
1948
+ const confidence = readNumber(entry, "confidence");
1949
+ return className === null || confidence === null ? [] : [{
1950
+ className,
1951
+ confidence
1952
+ }];
1953
+ }) : [];
1954
+ this.push(deviceId, projectAudioWindow(deviceKey, {
1955
+ detections,
1956
+ ...dbfs !== null ? { dbfs } : {}
1957
+ }));
1958
+ }
1593
1959
  onZoneOccupancy(data) {
1594
1960
  const deviceId = readNumber(data, "deviceId");
1595
1961
  if (deviceId === null) return;
@@ -1603,11 +1969,20 @@ var HaExportAddon = class extends require_dist.BaseAddon {
1603
1969
  if (zoneId === null) return [];
1604
1970
  return [{
1605
1971
  zoneId,
1972
+ totalObjects: readNumber(entry, "totalObjects") ?? 0,
1606
1973
  byClass: toCountRecord(byClass)
1607
1974
  }];
1608
1975
  });
1609
- if (zones.length === 0) return;
1610
- this.push(deviceId, projectZoneOccupancy(deviceKey, { zones }));
1976
+ const frame = readRecordField(data, "frame");
1977
+ const scene = frame !== null && typeof frame === "object" && !Array.isArray(frame) ? {
1978
+ totalObjects: readNumber(frame, "totalObjects") ?? 0,
1979
+ byClass: toCountRecord(readRecord(frame, "byClass"))
1980
+ } : void 0;
1981
+ if (zones.length === 0 && scene === void 0) return;
1982
+ this.push(deviceId, projectZoneOccupancy(deviceKey, {
1983
+ zones,
1984
+ ...scene !== void 0 ? { scene } : {}
1985
+ }));
1611
1986
  }
1612
1987
  /**
1613
1988
  * Send a device's values to every broker it is exported to.
@@ -1699,7 +2074,7 @@ var HaExportAddon = class extends require_dist.BaseAddon {
1699
2074
  * exported back to it: HA would mirror its own entity across the
1700
2075
  * bridge, and any automation touching it would round-trip.
1701
2076
  */
1702
- if (device.addonId === HA_PROVIDER_ADDON_ID) {
2077
+ if (device.addonId === "provider-homeassistant") {
1703
2078
  this.ctx.logger.warn("ha-export: refusing to export a device imported from Home Assistant", { tags: { deviceId: numericId } });
1704
2079
  continue;
1705
2080
  }
@@ -1824,10 +2199,17 @@ var HaExportAddon = class extends require_dist.BaseAddon {
1824
2199
  enabled: sw.enabled
1825
2200
  }))));
1826
2201
  const occupancy = await this.ctx.api.zoneAnalytics.getCurrentSnapshot.query({ deviceId: entry.deviceId });
1827
- if (occupancy !== null) values.push(...projectZoneOccupancy(key, { zones: occupancy.zones.map((zone) => ({
1828
- zoneId: zone.zoneId,
1829
- byClass: toCountRecord(zone.byClass)
1830
- })) }));
2202
+ if (occupancy !== null) values.push(...projectZoneOccupancy(key, {
2203
+ zones: occupancy.zones.map((zone) => ({
2204
+ zoneId: zone.zoneId,
2205
+ totalObjects: zone.totalObjects,
2206
+ byClass: toCountRecord(zone.byClass)
2207
+ })),
2208
+ scene: {
2209
+ totalObjects: occupancy.frame.totalObjects,
2210
+ byClass: toCountRecord(occupancy.frame.byClass)
2211
+ }
2212
+ }));
1831
2213
  }
1832
2214
  return values;
1833
2215
  }
@@ -1919,13 +2301,14 @@ var HaExportAddon = class extends require_dist.BaseAddon {
1919
2301
  * ones the operator ticked.
1920
2302
  *
1921
2303
  * Credentials come through the `broker` cap — the sanctioned no-import
1922
- * path — and the owning `addonId` is sent with the read: `broker` is a
1923
- * COLLECTION cap, so an unpinned id-keyed call routes to the
1924
- * first-registered provider, which returns `null` for a broker it does
1925
- * not own.
2304
+ * path — and EVERY call carries the owning `addonId`: `broker` is a
2305
+ * COLLECTION cap, so an unpinned call is answered by the
2306
+ * first-registered provider (Homematic, on the operator's hub), which
2307
+ * knows nothing about `ha_*`. `broker-source.ts` holds the pin and the
2308
+ * reasoning.
1926
2309
  */
1927
2310
  async refreshBrokers() {
1928
- const haBrokers = (await this.ctx.api.broker.list.query({})).filter((broker) => broker.kind === "home-assistant");
2311
+ const haBrokers = await listHomeAssistantBrokers((input) => this.ctx.api.broker.list.query(input));
1929
2312
  const known = haBrokers.map((broker) => ({
1930
2313
  id: broker.id,
1931
2314
  name: broker.name
@@ -1935,6 +2318,7 @@ var HaExportAddon = class extends require_dist.BaseAddon {
1935
2318
  knownBrokers: known,
1936
2319
  membership: pruned
1937
2320
  });
2321
+ await this.refreshEnabledBrokers(known);
1938
2322
  const wanted = new Set(this.enabledBrokerIds());
1939
2323
  for (const [brokerId, link] of this.links) {
1940
2324
  if (wanted.has(brokerId)) continue;
@@ -2006,8 +2390,23 @@ var HaExportAddon = class extends require_dist.BaseAddon {
2006
2390
  return null;
2007
2391
  }
2008
2392
  }
2393
+ /**
2394
+ * The brokers switched on, as of the last {@link refreshEnabledBrokers}.
2395
+ *
2396
+ * NOT read from `this.config`: `exportTo:<brokerId>` is a dynamic key and
2397
+ * `BaseAddon.resolveConfig` copies only keys present in `DEFAULT_CONFIG`, so
2398
+ * it is `undefined` there for ever — see `enabledBrokerIds` in
2399
+ * `membership.ts` for what that cost. The store is the authority; this is a
2400
+ * read-through cache of it, refreshed at the top of every reconcile pass and
2401
+ * before the per-device panel renders, never written to.
2402
+ */
2403
+ enabled = [];
2404
+ async refreshEnabledBrokers(known) {
2405
+ const store = await this.resolveGlobalStore();
2406
+ this.enabled = enabledBrokerIds(store, known.map((broker) => broker.id));
2407
+ }
2009
2408
  enabledBrokerIds() {
2010
- return this.config.knownBrokers.filter((broker) => this.config[`exportTo:${broker.id}`] === true).map((broker) => broker.id);
2409
+ return this.enabled;
2011
2410
  }
2012
2411
  brokerName(brokerId) {
2013
2412
  return this.config.knownBrokers.find((broker) => broker.id === brokerId)?.name ?? brokerId;
@@ -2346,7 +2745,8 @@ var HaExportAddon = class extends require_dist.BaseAddon {
2346
2745
  * exporters' identically-named fields and the operator sees another
2347
2746
  * exporter's value in this one's toggle.
2348
2747
  */
2349
- buildContribution(deviceId) {
2748
+ async buildContribution(deviceId) {
2749
+ await this.refreshEnabledBrokers(this.config.knownBrokers);
2350
2750
  const idStr = String(deviceId);
2351
2751
  const enabled = this.enabledBrokerIds();
2352
2752
  const fields = enabled.map((brokerId) => ({