@camstack/addon-provider-homeassistant 1.2.19 → 1.2.21

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.
@@ -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,71 @@ 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: true
337
+ },
338
+ (
339
+ /**
340
+ * BOTH levels, because they answer different questions and neither
341
+ * derives from the other in a way an operator should have to do in a
342
+ * template. dBFS is logarithmic and negative-going — the one to threshold
343
+ * on ("louder than -30") — while RMS is the linear amplitude, which is
344
+ * what a graph of relative loudness wants. The analyzer computes both in
345
+ * the same window; publishing one and making the other a `log10` template
346
+ * in Home Assistant is work we already did.
347
+ */
348
+ {
349
+ entity: "audio_level_rms",
350
+ platform: "sensor",
351
+ label: "Audio level (RMS)",
352
+ icon: "mdi:waveform",
353
+ enabledByDefault: true
354
+ }),
273
355
  {
274
356
  entity: "last_detection",
275
357
  platform: "sensor",
@@ -345,6 +427,60 @@ function cameraSpecs(device) {
345
427
  enabledByDefault: true
346
428
  });
347
429
  }
430
+ /**
431
+ * The doorbell, when the operator has bound the cap to this camera.
432
+ *
433
+ * A press is not a detection macro — it is not something the pipeline
434
+ * classifies — so it is NOT in `EXPORTED_MACROS`, and `MACROS_NOT_EXPORTED`
435
+ * says a doorbell is an actuator on its own device. Both stay true for the
436
+ * BUTTON accessory. What was missing is the camera case: a camera with the
437
+ * `doorbell` cap bound rings, and the operator automates on the ring the same
438
+ * way they automate on `person_detected`. Reolink's own doorbell is exactly
439
+ * this shape (`ReolinkSimpleEvent.type === 'doorbell'` at the camera level),
440
+ * and so is any camera the `virtual-doorbell` wrapper is pointed at.
441
+ *
442
+ * Every entity here is fed by the `doorbell` runtime-state slice
443
+ * (`lastPressedAt`, `pressCountSinceStart`) on `device.state-changed` —
444
+ * the same road the `battery` slice already travels.
445
+ *
446
+ * **`doorbell_last_image` is owed, and the frame for it already exists.**
447
+ * A press DOES have a picture: `sensor-marker-projector.ts` materialises a
448
+ * synthetic marker track carrying a `keyFrameSmall` of the camera, taken
449
+ * before the track is persisted, and the notification names that track as
450
+ * its media owner. What is missing is only the road to THIS addon:
451
+ * `persistSyntheticTrack` writes the row directly and emits no
452
+ * `pipeline-analytics.track-lifecycle`, which is the single event the export
453
+ * listens to for frames. So the export never hears about the marker while
454
+ * the notification centre shows its photo — measured 2026-08-09.
455
+ *
456
+ * It is NOT fixed by taking a second snapshot here at push time: the marker
457
+ * projector's own record argues that out (one snapshot, one owner, one
458
+ * persistence point — the producer's), and a second photograph of a doorway
459
+ * seconds after the ring is a different picture from the one in the
460
+ * notification. The fix belongs upstream, at the point that already owns the
461
+ * frame. Named here rather than shipped as an entity nothing feeds.
462
+ */
463
+ if (device.boundCaps.includes("doorbell")) specs.push({
464
+ entity: "doorbell_pressed",
465
+ platform: "binary_sensor",
466
+ label: "Doorbell pressed",
467
+ deviceClass: "occupancy",
468
+ enabledByDefault: true
469
+ }, {
470
+ entity: "doorbell_last_pressed",
471
+ platform: "sensor",
472
+ label: "Doorbell last pressed",
473
+ deviceClass: "timestamp",
474
+ icon: "mdi:bell-ring",
475
+ enabledByDefault: true
476
+ }, {
477
+ entity: "doorbell_press_count",
478
+ platform: "sensor",
479
+ label: "Doorbell presses since restart",
480
+ icon: "mdi:counter",
481
+ entityCategory: "diagnostic",
482
+ enabledByDefault: false
483
+ });
348
484
  specs.push({
349
485
  entity: "snooze",
350
486
  platform: "select",
@@ -365,13 +501,19 @@ function cameraSpecs(device) {
365
501
  entity: `${macro}_last_image`,
366
502
  platform: "image",
367
503
  label: `${label} last image`,
368
- enabledByDefault: false
504
+ enabledByDefault: true
369
505
  }, {
370
506
  entity: `${macro}_last_detection`,
371
507
  platform: "sensor",
372
508
  label: `${label} last detection`,
373
509
  deviceClass: "timestamp",
374
510
  enabledByDefault: false
511
+ }, {
512
+ entity: `${macro}_objects`,
513
+ platform: "sensor",
514
+ label: `${label} objects`,
515
+ icon: "mdi:counter",
516
+ enabledByDefault: false
375
517
  });
376
518
  if (LABELLED_MACROS.includes(macro)) specs.push({
377
519
  entity: `${macro}_last_label`,
@@ -1320,6 +1462,10 @@ function projectZoneOccupancy(deviceKey, input) {
1320
1462
  const values = [];
1321
1463
  for (const zone of input.zones) {
1322
1464
  const slug = toSlug(zone.zoneId);
1465
+ values.push({
1466
+ topic: stateTopic(deviceKey, `${slug}_objects`),
1467
+ value: String(zone.totalObjects)
1468
+ });
1323
1469
  const totals = new Map(ZONE_MACROS.map((macro) => [macro, 0]));
1324
1470
  for (const [className, count] of Object.entries(zone.byClass)) {
1325
1471
  const macro = macroOf(className);
@@ -1331,6 +1477,22 @@ function projectZoneOccupancy(deviceKey, input) {
1331
1477
  value: String(count)
1332
1478
  });
1333
1479
  }
1480
+ if (input.scene !== void 0) {
1481
+ values.push({
1482
+ topic: stateTopic(deviceKey, "objects"),
1483
+ value: String(input.scene.totalObjects)
1484
+ });
1485
+ const totals = new Map(EXPORTED_MACROS.map((macro) => [macro, 0]));
1486
+ for (const [className, count] of Object.entries(input.scene.byClass)) {
1487
+ const macro = macroOf(className);
1488
+ if (macro === null || !totals.has(macro)) continue;
1489
+ totals.set(macro, (totals.get(macro) ?? 0) + count);
1490
+ }
1491
+ for (const [macro, count] of totals) values.push({
1492
+ topic: stateTopic(deviceKey, `${macro}_objects`),
1493
+ value: String(count)
1494
+ });
1495
+ }
1334
1496
  return values;
1335
1497
  }
1336
1498
  function projectBatterySlice(deviceKey, slice) {
@@ -1366,6 +1528,362 @@ function projectCameraSwitches(deviceKey, switches) {
1366
1528
  value: bool(sw.enabled)
1367
1529
  }));
1368
1530
  }
1531
+ /**
1532
+ * The doorbell slice → its three entities.
1533
+ *
1534
+ * A press is a PULSE: the slice carries `lastPressedAt`, never a
1535
+ * `pressed: true/false`. `doorbell_pressed` is therefore driven by the
1536
+ * caller, which knows whether this projection is a fresh press or a
1537
+ * reconcile re-read — the same shape `projectMotion` already uses, and the
1538
+ * reason the flag is a parameter rather than derived from a timestamp
1539
+ * comparison here (a pure function that reads the clock is a pure function
1540
+ * that behaves differently in a test).
1541
+ *
1542
+ * `lastPressedAt: null` publishes NOTHING for the timestamp: HA renders an
1543
+ * empty timestamp sensor as `unknown`, which is honest, whereas `0` would
1544
+ * render as 1 January 1970 on the operator's dashboard.
1545
+ */
1546
+ function projectDoorbellSlice(deviceKey, slice, pressed) {
1547
+ const values = [{
1548
+ topic: stateTopic(deviceKey, "doorbell_pressed"),
1549
+ value: bool(pressed)
1550
+ }, {
1551
+ topic: stateTopic(deviceKey, "doorbell_press_count"),
1552
+ value: String(slice.pressCountSinceStart)
1553
+ }];
1554
+ if (slice.lastPressedAt !== null) values.push({
1555
+ topic: stateTopic(deviceKey, "doorbell_last_pressed"),
1556
+ value: new Date(slice.lastPressedAt).toISOString()
1557
+ });
1558
+ return values;
1559
+ }
1560
+ /**
1561
+ * One audio window → the audio entities.
1562
+ *
1563
+ * This is the source the catalog's `audio_detected` was always missing.
1564
+ * It had only the track lifecycle, which rarely carries an audio macro, so
1565
+ * the entity existed and effectively never moved — the note in this
1566
+ * package's CLAUDE.md said so without naming the event. The event is
1567
+ * `pipeline.audio-inference-result`, and it carries both halves an
1568
+ * operator asks for: WHAT was heard and HOW LOUD.
1569
+ *
1570
+ * The label is the HIGHEST-CONFIDENCE detection of the window, not the
1571
+ * first: the analyzer's array order is its own business and depending on
1572
+ * it would make the exported label change with an unrelated refactor.
1573
+ *
1574
+ * A window with no detections publishes NO label. It does not publish an
1575
+ * empty string: HA renders that as a valid state, so a quiet minute would
1576
+ * blank the last thing heard instead of leaving it — and "what was that
1577
+ * noise" is asked after the noise has stopped.
1578
+ *
1579
+ * The level is published on EVERY window, silence included. That is the
1580
+ * point of a volume entity: a flat line is a reading, and dBFS is
1581
+ * negative-going (0 = full scale), so no value can be mistaken for one.
1582
+ */
1583
+ function projectAudioWindow(deviceKey, input) {
1584
+ const values = [];
1585
+ if (input.dbfs !== void 0 && Number.isFinite(input.dbfs)) values.push({
1586
+ topic: stateTopic(deviceKey, "audio_volume"),
1587
+ value: String(Math.round(input.dbfs * 10) / 10)
1588
+ });
1589
+ if (input.rms !== void 0 && Number.isFinite(input.rms)) values.push({
1590
+ topic: stateTopic(deviceKey, "audio_level_rms"),
1591
+ value: String(Math.round(input.rms * 1e4) / 1e4)
1592
+ });
1593
+ const best = input.detections.reduce((top, d) => top === null || d.confidence > top.confidence ? d : top, null);
1594
+ if (best !== null) values.push({
1595
+ topic: stateTopic(deviceKey, "audio_last_sound"),
1596
+ value: best.className
1597
+ });
1598
+ return values;
1599
+ }
1600
+ //#endregion
1601
+ //#region src/ha-export/synthetic-devices.ts
1602
+ /**
1603
+ * Synthetic devices — what belongs to the SERVER, exported as devices that
1604
+ * exist inside Home Assistant and nowhere else.
1605
+ *
1606
+ * They are **not camstack devices**, deliberately (operator, 2026-08-04 and
1607
+ * again 2026-08-09). They carry rule switches, cluster monitoring and addon
1608
+ * health, none of which is a device in camstack's model; putting them in the
1609
+ * device list would fill it with things that are not devices. So they are built
1610
+ * HERE, by the exporter, and they have no `deviceId`.
1611
+ *
1612
+ * A LIST, not two special cases. The spec is explicit that the concept is
1613
+ * repeatable and more will be added when something needs a home — a one-off
1614
+ * would have to be refactored the first time a third is wanted. Adding one is
1615
+ * adding an entry to {@link SYNTHETIC_DEVICES} plus its projection.
1616
+ *
1617
+ * **Their values are RECONCILE-ONLY, on purpose.** Cluster CPU, memory, addon
1618
+ * counts and rule state are polled statistics, not events: there is no
1619
+ * `nodes.cpu-changed` to subscribe to, and inventing one to make this look
1620
+ * event-driven would be a second authority for numbers the topology already
1621
+ * owns. They refresh on the reconcile cadence (`reconcileIntervalSec`, 300s by
1622
+ * default), which is also what makes them self-healing after a Home Assistant
1623
+ * restart.
1624
+ *
1625
+ * The identity rules are the same wire format as every other device
1626
+ * (`../CLAUDE.md`): `device_id` is `camstack-<stableId>` and `unique_id` is
1627
+ * `<stableId>_<entity>`. The stable ids here are prefixed `synthetic-` so they
1628
+ * can never collide with a real device's, whatever a provider mints.
1629
+ */
1630
+ /** The stable id of the notification-centre device. A WIRE FORMAT. */
1631
+ var NOTIFICATION_CENTER_STABLE_ID = "synthetic-notification-center";
1632
+ /** The stable id of the server device. A WIRE FORMAT. */
1633
+ var SERVER_STABLE_ID = "synthetic-server";
1634
+ var SYNTHETIC_STABLE_IDS = [NOTIFICATION_CENTER_STABLE_ID, SERVER_STABLE_ID];
1635
+ /** Is this device key one of ours? Used by the command route. */
1636
+ function isSyntheticDeviceKey(deviceKey) {
1637
+ return SYNTHETIC_STABLE_IDS.some((id) => deviceKeyFor(id) === deviceKey);
1638
+ }
1639
+ /**
1640
+ * Everything a synthetic device exports arrives ENABLED.
1641
+ *
1642
+ * The pressure valve exists for the camera fan-out — three zones is ~73
1643
+ * entities and the fleet is ~880. There is no fan-out here: the largest of
1644
+ * these is one switch per notification rule, and a rule the operator wrote is
1645
+ * a rule they want to reach. Shipping them disabled would repeat the bug that
1646
+ * exported a thermometer's temperature switched off.
1647
+ */
1648
+ function buildComponents(stableId, specs) {
1649
+ const deviceKey = deviceKeyFor(stableId);
1650
+ const cmps = {};
1651
+ for (const spec of specs) cmps[toComponentKey(spec.platform, spec.entity)] = {
1652
+ platform: spec.platform,
1653
+ unique_id: `${stableId}_${spec.entity}`,
1654
+ name: spec.label,
1655
+ ...spec.platform === "button" ? {} : { state_topic: stateTopic(deviceKey, spec.entity) },
1656
+ ...spec.writable === true ? { command_topic: commandTopic(deviceKey, spec.entity) } : {},
1657
+ ...spec.deviceClass !== void 0 ? { device_class: spec.deviceClass } : {},
1658
+ ...spec.unit !== void 0 ? { unit_of_measurement: spec.unit } : {},
1659
+ ...spec.icon !== void 0 ? { icon: spec.icon } : {},
1660
+ ...spec.options !== void 0 ? { options: spec.options } : {},
1661
+ ...spec.entityCategory !== void 0 ? { entity_category: spec.entityCategory } : {},
1662
+ ...spec.platform === "binary_sensor" || spec.platform === "switch" ? {
1663
+ payload_on: "true",
1664
+ payload_off: "false"
1665
+ } : {}
1666
+ };
1667
+ return cmps;
1668
+ }
1669
+ function plan(stableId, name, specs) {
1670
+ return {
1671
+ deviceKey: deviceKeyFor(stableId),
1672
+ deviceId: null,
1673
+ dev: {
1674
+ ids: [deviceKeyFor(stableId)],
1675
+ name,
1676
+ mf: "CamStack",
1677
+ mdl: "Synthetic"
1678
+ },
1679
+ cmps: buildComponents(stableId, specs)
1680
+ };
1681
+ }
1682
+ /**
1683
+ * The rule switch entity for a rule id.
1684
+ *
1685
+ * Slugged, because a rule id is free text and an entity segment is not. The
1686
+ * `unique_id` is derived from the rule ID and never its NAME, so a renamed rule
1687
+ * keeps every automation pointed at it — the friendly name changes in Home
1688
+ * Assistant and nothing else does.
1689
+ */
1690
+ function ruleEntity(ruleId) {
1691
+ return `rule_${toSlug(ruleId)}`;
1692
+ }
1693
+ /** `rule_<slug>` → the rule id it came from, or null. */
1694
+ function ruleIdFromEntity(entity, rules) {
1695
+ return rules.find((rule) => ruleEntity(rule.id) === entity)?.id ?? null;
1696
+ }
1697
+ function notificationCenterSpecs(input) {
1698
+ return [
1699
+ {
1700
+ entity: "snoozed",
1701
+ platform: "binary_sensor",
1702
+ label: "Notifications snoozed",
1703
+ icon: "mdi:bell-sleep"
1704
+ },
1705
+ {
1706
+ entity: "rules_enabled",
1707
+ platform: "sensor",
1708
+ label: "Rules enabled",
1709
+ icon: "mdi:counter",
1710
+ entityCategory: "diagnostic"
1711
+ },
1712
+ ...input.rules.map((rule) => ({
1713
+ entity: ruleEntity(rule.id),
1714
+ platform: "switch",
1715
+ label: rule.name,
1716
+ writable: true,
1717
+ icon: "mdi:bell-cog"
1718
+ }))
1719
+ ];
1720
+ }
1721
+ function serverSpecs(input) {
1722
+ return [
1723
+ {
1724
+ entity: "cluster_healthy",
1725
+ platform: "binary_sensor",
1726
+ label: "Cluster healthy",
1727
+ icon: "mdi:server-network"
1728
+ },
1729
+ {
1730
+ entity: "nodes_online",
1731
+ platform: "sensor",
1732
+ label: "Nodes online",
1733
+ icon: "mdi:server"
1734
+ },
1735
+ {
1736
+ entity: "nodes_total",
1737
+ platform: "sensor",
1738
+ label: "Nodes total",
1739
+ icon: "mdi:server"
1740
+ },
1741
+ {
1742
+ entity: "addons_healthy",
1743
+ platform: "binary_sensor",
1744
+ label: "Addons healthy",
1745
+ icon: "mdi:puzzle-check"
1746
+ },
1747
+ {
1748
+ entity: "addons_running",
1749
+ platform: "sensor",
1750
+ label: "Addons running",
1751
+ icon: "mdi:puzzle"
1752
+ },
1753
+ {
1754
+ entity: "addons_failed",
1755
+ platform: "sensor",
1756
+ label: "Addons failed",
1757
+ icon: "mdi:puzzle-remove"
1758
+ },
1759
+ {
1760
+ entity: "server_version",
1761
+ platform: "sensor",
1762
+ label: "Server version",
1763
+ icon: "mdi:tag",
1764
+ entityCategory: "diagnostic"
1765
+ },
1766
+ {
1767
+ entity: "update_available",
1768
+ platform: "binary_sensor",
1769
+ label: "Update available",
1770
+ deviceClass: "update",
1771
+ entityCategory: "diagnostic"
1772
+ },
1773
+ ...input.nodes.flatMap((node) => {
1774
+ const slug = toSlug(node.id);
1775
+ return [
1776
+ {
1777
+ entity: `${slug}_online`,
1778
+ platform: "binary_sensor",
1779
+ label: `${node.name} online`,
1780
+ deviceClass: "connectivity"
1781
+ },
1782
+ {
1783
+ entity: `${slug}_cpu`,
1784
+ platform: "sensor",
1785
+ label: `${node.name} CPU`,
1786
+ unit: "%",
1787
+ icon: "mdi:cpu-64-bit"
1788
+ },
1789
+ {
1790
+ entity: `${slug}_memory`,
1791
+ platform: "sensor",
1792
+ label: `${node.name} memory`,
1793
+ unit: "%",
1794
+ icon: "mdi:memory"
1795
+ },
1796
+ {
1797
+ entity: `${slug}_uptime`,
1798
+ platform: "sensor",
1799
+ label: `${node.name} uptime`,
1800
+ deviceClass: "timestamp"
1801
+ }
1802
+ ];
1803
+ })
1804
+ ];
1805
+ }
1806
+ /** Every synthetic device, in declaration order. */
1807
+ function syntheticPlans(input) {
1808
+ return [plan(NOTIFICATION_CENTER_STABLE_ID, "Notification Center", notificationCenterSpecs(input)), plan(SERVER_STABLE_ID, "CamStack Server", serverSpecs(input))];
1809
+ }
1810
+ /**
1811
+ * Every synthetic value, for the reconcile to push.
1812
+ *
1813
+ * Uptime is published as an ISO timestamp of the BOOT INSTANT, not as a
1814
+ * seconds counter: HA's `timestamp` device class renders "3 days ago" and keeps
1815
+ * doing so between reconciles, whereas a seconds number would sit frozen at
1816
+ * whatever it was 300 seconds ago and read as a stalled machine.
1817
+ */
1818
+ function projectSynthetic(input, nowMs) {
1819
+ const nc = deviceKeyFor(NOTIFICATION_CENTER_STABLE_ID);
1820
+ const srv = deviceKeyFor(SERVER_STABLE_ID);
1821
+ const online = input.nodes.filter((node) => node.online);
1822
+ const values = [
1823
+ {
1824
+ topic: stateTopic(nc, "snoozed"),
1825
+ value: String(input.snoozed)
1826
+ },
1827
+ {
1828
+ topic: stateTopic(nc, "rules_enabled"),
1829
+ value: String(input.rules.filter((rule) => rule.enabled).length)
1830
+ },
1831
+ ...input.rules.map((rule) => ({
1832
+ topic: stateTopic(nc, ruleEntity(rule.id)),
1833
+ value: String(rule.enabled)
1834
+ })),
1835
+ {
1836
+ topic: stateTopic(srv, "cluster_healthy"),
1837
+ value: String(online.length === input.nodes.length)
1838
+ },
1839
+ {
1840
+ topic: stateTopic(srv, "nodes_online"),
1841
+ value: String(online.length)
1842
+ },
1843
+ {
1844
+ topic: stateTopic(srv, "nodes_total"),
1845
+ value: String(input.nodes.length)
1846
+ },
1847
+ {
1848
+ topic: stateTopic(srv, "addons_healthy"),
1849
+ value: String(input.addonsFailed === 0)
1850
+ },
1851
+ {
1852
+ topic: stateTopic(srv, "addons_running"),
1853
+ value: String(input.addonsRunning)
1854
+ },
1855
+ {
1856
+ topic: stateTopic(srv, "addons_failed"),
1857
+ value: String(input.addonsFailed)
1858
+ },
1859
+ {
1860
+ topic: stateTopic(srv, "server_version"),
1861
+ value: input.serverVersion
1862
+ },
1863
+ {
1864
+ topic: stateTopic(srv, "update_available"),
1865
+ value: String(input.updateAvailable)
1866
+ }
1867
+ ];
1868
+ for (const node of input.nodes) {
1869
+ const slug = toSlug(node.id);
1870
+ values.push({
1871
+ topic: stateTopic(srv, `${slug}_online`),
1872
+ value: String(node.online)
1873
+ }, {
1874
+ topic: stateTopic(srv, `${slug}_cpu`),
1875
+ value: String(Math.round(node.cpuPercent))
1876
+ }, {
1877
+ topic: stateTopic(srv, `${slug}_memory`),
1878
+ value: String(Math.round(node.memoryPercent))
1879
+ });
1880
+ if (node.online && node.uptime > 0) values.push({
1881
+ topic: stateTopic(srv, `${slug}_uptime`),
1882
+ value: (/* @__PURE__ */ new Date(nowMs - node.uptime * 1e3)).toISOString()
1883
+ });
1884
+ }
1885
+ return values;
1886
+ }
1369
1887
  /** The `BrokerInfo.kind` tag the Home Assistant provider stamps. */
1370
1888
  var HA_BROKER_KIND = "home-assistant";
1371
1889
  /**
@@ -1420,6 +1938,8 @@ var ADDON_ID = "homeassistant-export";
1420
1938
  /** Where the custom component registers its push view inside HA. */
1421
1939
  var PUSH_PATH = "/api/camstack/push";
1422
1940
  var MEDIA_ROUTE_PREFIX = `/addon/${ADDON_ID}/ha-media`;
1941
+ /** How long `doorbell_pressed` stays on before its OFF edge is published. */
1942
+ var DOORBELL_PULSE_MS = 5e3;
1423
1943
  /** The hub's API port — where the data plane is reverse-proxied. */
1424
1944
  var HUB_API_PORT = 4443;
1425
1945
  /** One PTZ button press, as a relative move. */
@@ -1451,8 +1971,18 @@ var HaExportAddon = class extends BaseAddon {
1451
1971
  reconcilePending = null;
1452
1972
  lastError;
1453
1973
  entityCount = 0;
1974
+ /** deviceId → the `lastPressedAt` already rung, so a re-read does not ring again. */
1975
+ lastDoorbellPressAt = /* @__PURE__ */ new Map();
1976
+ /** deviceId → the pending OFF edge. HA releases nothing on its own. */
1977
+ doorbellReleaseTimers = /* @__PURE__ */ new Map();
1454
1978
  unclassified = [];
1455
1979
  /**
1980
+ * The rules as of the last reconcile, so a command arriving on
1981
+ * `rule_<slug>` can be turned back into the rule id it came from. Refreshed
1982
+ * whenever the synthetic devices are, never written to.
1983
+ */
1984
+ syntheticRules = [];
1985
+ /**
1456
1986
  * A link that returned repairs NOW, not at the next periodic pass.
1457
1987
  *
1458
1988
  * `PushClient` drops its dedup cache the moment Home Assistant answers
@@ -1493,6 +2023,8 @@ var HaExportAddon = class extends BaseAddon {
1493
2023
  this.startTimer();
1494
2024
  this.ctx.addDisposer(async () => {
1495
2025
  this.stopTimer();
2026
+ for (const timer of this.doorbellReleaseTimers.values()) clearTimeout(timer);
2027
+ this.doorbellReleaseTimers.clear();
1496
2028
  this.reachability.dispose();
1497
2029
  for (const link of this.links.values()) await link.client.dispose();
1498
2030
  this.links.clear();
@@ -1549,6 +2081,15 @@ var HaExportAddon = class extends BaseAddon {
1549
2081
  this.subscribe({ category: EventCategory.MotionOnMotionChanged }, (event) => {
1550
2082
  this.onMotion(event.data);
1551
2083
  });
2084
+ /**
2085
+ * The audio window. This is the source `audio_detected` never had: it was
2086
+ * fed only by the track lifecycle, which rarely carries an audio macro, so
2087
+ * the entity existed and effectively never moved. It also carries the
2088
+ * window LEVEL, which nothing else on the export path does.
2089
+ */
2090
+ this.subscribe({ category: EventCategory.PipelineAudioInferenceResult }, (event) => {
2091
+ this.onAudioWindow(event.data);
2092
+ });
1552
2093
  this.subscribe({ category: EventCategory.PipelineAnalyticsTrackLifecycle }, (event) => {
1553
2094
  this.onTrackLifecycle(event.data);
1554
2095
  });
@@ -1596,6 +2137,40 @@ var HaExportAddon = class extends BaseAddon {
1596
2137
  }));
1597
2138
  return;
1598
2139
  }
2140
+ if (capName === "doorbell") {
2141
+ /**
2142
+ * A press is a PULSE — the slice carries `lastPressedAt`, never a
2143
+ * boolean. `doorbell_pressed` is therefore ON only when THIS delivery
2144
+ * advanced the timestamp, and the previous value is remembered per
2145
+ * device rather than compared against the clock: a reconcile re-read of
2146
+ * an unchanged slice must not ring the doorbell again in Home Assistant,
2147
+ * and a press that arrives while the export was restarting must still
2148
+ * ring once. HA auto-releases nothing, so the OFF edge is published on a
2149
+ * short timer — without it the binary sensor latches on for ever and the
2150
+ * next press produces no edge to automate on.
2151
+ */
2152
+ const lastPressedAt = readNumber(slice, "lastPressedAt");
2153
+ const pressCountSinceStart = readNumber(slice, "pressCountSinceStart") ?? 0;
2154
+ const previous = this.lastDoorbellPressAt.get(deviceId) ?? null;
2155
+ const pressed = lastPressedAt !== null && lastPressedAt !== previous;
2156
+ if (pressed) this.lastDoorbellPressAt.set(deviceId, lastPressedAt);
2157
+ this.push(deviceId, projectDoorbellSlice(deviceKey, {
2158
+ lastPressedAt,
2159
+ pressCountSinceStart
2160
+ }, pressed));
2161
+ if (pressed) {
2162
+ const existing = this.doorbellReleaseTimers.get(deviceId);
2163
+ if (existing !== void 0) clearTimeout(existing);
2164
+ this.doorbellReleaseTimers.set(deviceId, setTimeout(() => {
2165
+ this.doorbellReleaseTimers.delete(deviceId);
2166
+ this.push(deviceId, projectDoorbellSlice(deviceKey, {
2167
+ lastPressedAt,
2168
+ pressCountSinceStart
2169
+ }, false));
2170
+ }, DOORBELL_PULSE_MS));
2171
+ }
2172
+ return;
2173
+ }
1599
2174
  if (capName === "motion") {
1600
2175
  const detected = slice["detected"];
1601
2176
  const lastDetectedAt = readNumber(slice, "lastDetectedAt");
@@ -1668,6 +2243,54 @@ var HaExportAddon = class extends BaseAddon {
1668
2243
  }
1669
2244
  this.push(deviceId, values);
1670
2245
  }
2246
+ onAudioWindow(data) {
2247
+ const deviceId = readNumber(data, "deviceId");
2248
+ if (deviceId === null) return;
2249
+ const deviceKey = this.keyByDeviceId.get(deviceId);
2250
+ if (deviceKey === void 0) return;
2251
+ const frame = readRecordField(data, "frame");
2252
+ if (frame === null || typeof frame !== "object" || Array.isArray(frame)) return;
2253
+ const level = readRecord(frame, "level");
2254
+ const dbfs = readNumber(level, "dbfs");
2255
+ const rms = readNumber(level, "rms");
2256
+ const raw = readRecordField(frame, "detections");
2257
+ /**
2258
+ * An `AudioDetection` carries `macroClass` + `score` and its real sound
2259
+ * class in `labels: ScoredLabel[]` — the enrichment chain. It has NO
2260
+ * `className` and no `confidence`; reading those invented names mapped
2261
+ * every window to `[]`, so `audio_last_sound` sat at `unknown` for ever
2262
+ * while the analyzer was classifying happily (found 2026-08-09, symptom:
2263
+ * "le audio labels sembrano non arrivare").
2264
+ *
2265
+ * The best LABEL wins over the macro: `audio` as a value tells an operator
2266
+ * nothing, `dog` or `crying` is the whole point. Falls back to the macro
2267
+ * class only when a detection carries no label at all.
2268
+ */
2269
+ const detections = Array.isArray(raw) ? raw.flatMap((entry) => {
2270
+ const labels = readRecordField(entry, "labels");
2271
+ const best = Array.isArray(labels) ? labels.reduce((top, l) => {
2272
+ const label = readString(l, "label");
2273
+ const score = readNumber(l, "score") ?? 0;
2274
+ if (label === null) return top;
2275
+ return top === null || score > top.confidence ? {
2276
+ className: label,
2277
+ confidence: score
2278
+ } : top;
2279
+ }, null) : null;
2280
+ if (best !== null) return [best];
2281
+ const macro = readString(entry, "macroClass");
2282
+ const score = readNumber(entry, "score");
2283
+ return macro === null || score === null ? [] : [{
2284
+ className: macro,
2285
+ confidence: score
2286
+ }];
2287
+ }) : [];
2288
+ this.push(deviceId, projectAudioWindow(deviceKey, {
2289
+ detections,
2290
+ ...dbfs !== null ? { dbfs } : {},
2291
+ ...rms !== null ? { rms } : {}
2292
+ }));
2293
+ }
1671
2294
  onZoneOccupancy(data) {
1672
2295
  const deviceId = readNumber(data, "deviceId");
1673
2296
  if (deviceId === null) return;
@@ -1681,11 +2304,20 @@ var HaExportAddon = class extends BaseAddon {
1681
2304
  if (zoneId === null) return [];
1682
2305
  return [{
1683
2306
  zoneId,
2307
+ totalObjects: readNumber(entry, "totalObjects") ?? 0,
1684
2308
  byClass: toCountRecord(byClass)
1685
2309
  }];
1686
2310
  });
1687
- if (zones.length === 0) return;
1688
- this.push(deviceId, projectZoneOccupancy(deviceKey, { zones }));
2311
+ const frame = readRecordField(data, "frame");
2312
+ const scene = frame !== null && typeof frame === "object" && !Array.isArray(frame) ? {
2313
+ totalObjects: readNumber(frame, "totalObjects") ?? 0,
2314
+ byClass: toCountRecord(readRecord(frame, "byClass"))
2315
+ } : void 0;
2316
+ if (zones.length === 0 && scene === void 0) return;
2317
+ this.push(deviceId, projectZoneOccupancy(deviceKey, {
2318
+ zones,
2319
+ ...scene !== void 0 ? { scene } : {}
2320
+ }));
1689
2321
  }
1690
2322
  /**
1691
2323
  * Send a device's values to every broker it is exported to.
@@ -1814,12 +2446,35 @@ var HaExportAddon = class extends BaseAddon {
1814
2446
  }
1815
2447
  });
1816
2448
  }
2449
+ /**
2450
+ * The synthetic devices — server-owned surfaces that are not camstack
2451
+ * devices. They are appended to the SAME `exported` map so they travel
2452
+ * the ordinary announce + push path: one code path for identity,
2453
+ * batching and dedup, rather than a parallel one that would drift.
2454
+ * `brokerIds` is every enabled broker, because they belong to the server
2455
+ * and not to a membership the operator picks per camera.
2456
+ */
2457
+ const synthetic = await this.buildSynthetic();
2458
+ if (synthetic !== null) for (const plan of syntheticPlans(synthetic)) {
2459
+ exported.set(plan.deviceKey, {
2460
+ plan,
2461
+ deviceId: -1,
2462
+ target: {
2463
+ deviceId: -1,
2464
+ type: "synthetic",
2465
+ boundCaps: []
2466
+ },
2467
+ brokerIds: [...enabled]
2468
+ });
2469
+ entityCount += Object.keys(plan.cmps).length;
2470
+ }
1817
2471
  this.exported = exported;
1818
2472
  this.keyByDeviceId = keyByDeviceId;
1819
2473
  this.entityCount = entityCount;
1820
2474
  this.unclassified = unclassifiedCaps([...allCaps]);
1821
2475
  await this.announce(exported);
1822
2476
  await this.pushFullState(exported, snapshots);
2477
+ if (synthetic !== null) await this.pushSynthetic(projectSynthetic(synthetic, Date.now()), [...enabled]);
1823
2478
  this.ctx.logger.info("ha-export: reconciled", { meta: {
1824
2479
  reason,
1825
2480
  brokers: enabled.length,
@@ -1842,6 +2497,69 @@ var HaExportAddon = class extends BaseAddon {
1842
2497
  * that device only and can never take the loop — or the runner — down
1843
2498
  * with it.
1844
2499
  */
2500
+ /**
2501
+ * Gather the synthetic devices' statistics.
2502
+ *
2503
+ * Best-effort per source: one API that does not answer costs its own
2504
+ * entities for this pass, never the whole set — a hub whose
2505
+ * `notificationRules` provider is restarting must still export its cluster
2506
+ * health, which is exactly when an operator wants it.
2507
+ *
2508
+ * Returns `null` only when NOTHING answered, so the devices are not
2509
+ * announced empty on a hub that is still booting.
2510
+ */
2511
+ async buildSynthetic() {
2512
+ const rules = await this.ctx.api.notificationRules.listRules.query({}).then((r) => r.rules.map((rule) => ({
2513
+ id: rule.id,
2514
+ name: rule.name,
2515
+ enabled: rule.enabled
2516
+ }))).catch((err) => {
2517
+ this.ctx.logger.warn("ha-export: could not read notification rules", { meta: { error: errMsg(err) } });
2518
+ return null;
2519
+ });
2520
+ const snoozes = await this.ctx.api.notificationRules.listSnoozes.query({}).then((r) => r.snoozes.length > 0).catch(() => null);
2521
+ const nodes = await this.ctx.api.nodes.topology.query().then((list) => list.map((node) => ({
2522
+ id: node.id,
2523
+ name: node.name,
2524
+ online: node.isOnline,
2525
+ cpuPercent: node.cpuPercent,
2526
+ memoryPercent: node.memoryPercent,
2527
+ uptime: node.uptime
2528
+ }))).catch((err) => {
2529
+ this.ctx.logger.warn("ha-export: could not read the cluster topology", { meta: { error: errMsg(err) } });
2530
+ return null;
2531
+ });
2532
+ const addons = await this.ctx.api.addons.list.query().then((list) => {
2533
+ let running = 0;
2534
+ let failed = 0;
2535
+ for (const addon of list) if (addon.health?.phase === "failed") failed += 1;
2536
+ else running += 1;
2537
+ return {
2538
+ running,
2539
+ failed
2540
+ };
2541
+ }).catch(() => null);
2542
+ const server = await this.ctx.api.serverManagement.getServerPackageStatus.query().catch(() => null);
2543
+ if (rules === null && nodes === null && addons === null && server === null) return null;
2544
+ this.syntheticRules = rules ?? this.syntheticRules;
2545
+ return {
2546
+ rules: rules ?? this.syntheticRules,
2547
+ snoozed: snoozes ?? false,
2548
+ nodes: nodes ?? [],
2549
+ addonsRunning: addons?.running ?? 0,
2550
+ addonsFailed: addons?.failed ?? 0,
2551
+ serverVersion: server?.runningVersion ?? server?.activeVersion ?? "unknown",
2552
+ updateAvailable: server?.updateAvailable ?? false
2553
+ };
2554
+ }
2555
+ /** Push the synthetic values to every enabled broker. */
2556
+ async pushSynthetic(values, brokerIds) {
2557
+ for (const brokerId of brokerIds) {
2558
+ const link = this.links.get(brokerId);
2559
+ if (link === void 0) continue;
2560
+ for (const value of values) link.client.publishState(value.topic, value.value);
2561
+ }
2562
+ }
1845
2563
  async announce(exported) {
1846
2564
  for (const entry of exported.values()) for (const brokerId of entry.brokerIds) {
1847
2565
  const link = this.links.get(brokerId);
@@ -1902,10 +2620,17 @@ var HaExportAddon = class extends BaseAddon {
1902
2620
  enabled: sw.enabled
1903
2621
  }))));
1904
2622
  const occupancy = await this.ctx.api.zoneAnalytics.getCurrentSnapshot.query({ deviceId: entry.deviceId });
1905
- if (occupancy !== null) values.push(...projectZoneOccupancy(key, { zones: occupancy.zones.map((zone) => ({
1906
- zoneId: zone.zoneId,
1907
- byClass: toCountRecord(zone.byClass)
1908
- })) }));
2623
+ if (occupancy !== null) values.push(...projectZoneOccupancy(key, {
2624
+ zones: occupancy.zones.map((zone) => ({
2625
+ zoneId: zone.zoneId,
2626
+ totalObjects: zone.totalObjects,
2627
+ byClass: toCountRecord(zone.byClass)
2628
+ })),
2629
+ scene: {
2630
+ totalObjects: occupancy.frame.totalObjects,
2631
+ byClass: toCountRecord(occupancy.frame.byClass)
2632
+ }
2633
+ }));
1909
2634
  }
1910
2635
  return values;
1911
2636
  }
@@ -2009,7 +2734,26 @@ var HaExportAddon = class extends BaseAddon {
2009
2734
  id: broker.id,
2010
2735
  name: broker.name
2011
2736
  }));
2012
- const pruned = pruneBrokers(this.config.membership, haBrokers.map((broker) => broker.id));
2737
+ /**
2738
+ * NEVER prune on an empty enumeration.
2739
+ *
2740
+ * `broker.list` is a fan-out union, so it answers `[]` for the whole window
2741
+ * in which `provider-homeassistant` has not finished registering — and it
2742
+ * registers its caps only at the END of an `initialize()` that restores 239
2743
+ * devices. An empty list there means "I do not know yet", never "the
2744
+ * operator deleted their instance", and the two were indistinguishable to
2745
+ * this code: every boot pruned the membership to nothing and PERSISTED it,
2746
+ * so the operator's exported cameras silently switched themselves off. Seen
2747
+ * live on 2026-08-09 — two cameras exposed, `devices=4`, addon redeployed,
2748
+ * `devices=2` and both switches back to `false` with no line anywhere.
2749
+ *
2750
+ * This is D49 exactly: a destructive step gated on a fallible read must not
2751
+ * fire on the reading that destroys work. A broker that really was deleted
2752
+ * is pruned on the next pass that returns a NON-empty list without it,
2753
+ * which costs one interval and cannot cost the operator their config.
2754
+ */
2755
+ const pruned = haBrokers.length === 0 ? this.config.membership : pruneBrokers(this.config.membership, haBrokers.map((broker) => broker.id));
2756
+ if (haBrokers.length === 0 && Object.keys(this.config.membership).length > 0) this.ctx.logger.warn("ha-export: no Home Assistant broker answered — keeping the export membership rather than pruning it", { meta: { brokers: Object.keys(this.config.membership) } });
2013
2757
  if (pruned !== this.config.membership || !sameBrokers(known, this.config.knownBrokers)) await this.updateGlobalSettings({
2014
2758
  knownBrokers: known,
2015
2759
  membership: pruned
@@ -2132,6 +2876,49 @@ var HaExportAddon = class extends BaseAddon {
2132
2876
  reply.send({ error: "device not exported" });
2133
2877
  return;
2134
2878
  }
2879
+ /**
2880
+ * A synthetic device has no camstack device behind it, so
2881
+ * `resolveCommand` — which routes by device type and bound caps — cannot
2882
+ * speak for it. Its commands go straight to the authority that already
2883
+ * owns the function, exactly as a camera switch does: the export writes
2884
+ * `setRuleEnabled`, never a store of its own. A second knob that can
2885
+ * disagree with the admin UI is worse than no knob (D62).
2886
+ */
2887
+ if (isSyntheticDeviceKey(parsed.deviceKey)) {
2888
+ const ruleId = ruleIdFromEntity(parsed.entity, this.syntheticRules);
2889
+ if (ruleId === null) {
2890
+ this.ctx.logger.warn("ha-export: dropping a synthetic command with no authority behind it", { meta: {
2891
+ topic,
2892
+ entity: parsed.entity
2893
+ } });
2894
+ reply.status(422);
2895
+ reply.send({ error: "unroutable command" });
2896
+ return;
2897
+ }
2898
+ const enabled = value.toLowerCase() === "true";
2899
+ try {
2900
+ await this.ctx.api.notificationRules.setRuleEnabled.mutate({
2901
+ ruleId,
2902
+ enabled
2903
+ });
2904
+ this.ctx.logger.info("ha-export: notification rule toggled from Home Assistant", { meta: {
2905
+ ruleId,
2906
+ enabled
2907
+ } });
2908
+ await this.reconcile("synthetic-command");
2909
+ reply.status(200);
2910
+ reply.send({});
2911
+ } catch (err) {
2912
+ this.ctx.logger.warn("ha-export: could not apply a notification-rule toggle", { meta: {
2913
+ ruleId,
2914
+ enabled,
2915
+ error: errMsg(err)
2916
+ } });
2917
+ reply.status(422);
2918
+ reply.send({ error: "command not applied" });
2919
+ }
2920
+ return;
2921
+ }
2135
2922
  const command = resolveCommand(exported.target, parsed.entity, value);
2136
2923
  if (command === null) {
2137
2924
  this.ctx.logger.warn("ha-export: dropping an unroutable command", {
@@ -2482,13 +3269,34 @@ var HaExportAddon = class extends BaseAddon {
2482
3269
  const idStr = String(deviceId);
2483
3270
  let next = this.config.membership;
2484
3271
  let touched = false;
2485
- for (const brokerId of this.enabledBrokerIds()) {
2486
- const key = `haExport:${deviceId}:${brokerId}`;
2487
- if (!(key in patch)) continue;
2488
- next = setExposed(next, brokerId, idStr, patch[key] === true);
3272
+ /**
3273
+ * Driven by the PATCH KEYS, not by the enabled-broker list.
3274
+ *
3275
+ * The key already carries the broker (`haExport:<deviceId>:<brokerId>`)
3276
+ * the operator could not have been shown the switch otherwise — so reading
3277
+ * the broker from it needs no other state to be warm. Iterating
3278
+ * `enabledBrokerIds()` instead made this depend on a read-through cache,
3279
+ * and when that cache was cold the loop matched nothing, `touched` stayed
3280
+ * false, and the method returned `{success: true}` HAVING DONE NOTHING.
3281
+ * The admin UI showed the switch flip and it silently did not persist;
3282
+ * measured live on 2026-08-09 with two cameras that would not stay
3283
+ * exported. A no-op that reports success is worse than an error.
3284
+ */
3285
+ const prefix = `haExport:${deviceId}:`;
3286
+ for (const [key, value] of Object.entries(patch)) {
3287
+ if (!key.startsWith(prefix)) continue;
3288
+ const brokerId = key.slice(prefix.length);
3289
+ if (brokerId.length === 0) continue;
3290
+ next = setExposed(next, brokerId, idStr, value === true);
2489
3291
  touched = true;
2490
3292
  }
2491
- if (!touched) return { success: true };
3293
+ if (!touched) {
3294
+ this.ctx.logger.warn("ha-export: a device patch carried no export key — nothing applied", {
3295
+ tags: { deviceId },
3296
+ meta: { keys: Object.keys(patch) }
3297
+ });
3298
+ return { success: true };
3299
+ }
2492
3300
  await this.updateGlobalSettings({ membership: next });
2493
3301
  this.ctx.logger.info("ha-export: per-broker export membership changed", {
2494
3302
  tags: { deviceId },