@camstack/addon-provider-homeassistant 1.2.19 → 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.
@@ -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`,
@@ -1325,6 +1450,10 @@ function projectZoneOccupancy(deviceKey, input) {
1325
1450
  const values = [];
1326
1451
  for (const zone of input.zones) {
1327
1452
  const slug = toSlug(zone.zoneId);
1453
+ values.push({
1454
+ topic: stateTopic(deviceKey, `${slug}_objects`),
1455
+ value: String(zone.totalObjects)
1456
+ });
1328
1457
  const totals = new Map(ZONE_MACROS.map((macro) => [macro, 0]));
1329
1458
  for (const [className, count] of Object.entries(zone.byClass)) {
1330
1459
  const macro = macroOf(className);
@@ -1336,6 +1465,22 @@ function projectZoneOccupancy(deviceKey, input) {
1336
1465
  value: String(count)
1337
1466
  });
1338
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
+ }
1339
1484
  return values;
1340
1485
  }
1341
1486
  function projectBatterySlice(deviceKey, slice) {
@@ -1371,6 +1516,71 @@ function projectCameraSwitches(deviceKey, switches) {
1371
1516
  value: bool(sw.enabled)
1372
1517
  }));
1373
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
+ }
1374
1584
  /** The `BrokerInfo.kind` tag the Home Assistant provider stamps. */
1375
1585
  var HA_BROKER_KIND = "home-assistant";
1376
1586
  /**
@@ -1425,6 +1635,8 @@ var ADDON_ID = "homeassistant-export";
1425
1635
  /** Where the custom component registers its push view inside HA. */
1426
1636
  var PUSH_PATH = "/api/camstack/push";
1427
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;
1428
1640
  /** The hub's API port — where the data plane is reverse-proxied. */
1429
1641
  var HUB_API_PORT = 4443;
1430
1642
  /** One PTZ button press, as a relative move. */
@@ -1456,6 +1668,10 @@ var HaExportAddon = class extends require_dist.BaseAddon {
1456
1668
  reconcilePending = null;
1457
1669
  lastError;
1458
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();
1459
1675
  unclassified = [];
1460
1676
  /**
1461
1677
  * A link that returned repairs NOW, not at the next periodic pass.
@@ -1498,6 +1714,8 @@ var HaExportAddon = class extends require_dist.BaseAddon {
1498
1714
  this.startTimer();
1499
1715
  this.ctx.addDisposer(async () => {
1500
1716
  this.stopTimer();
1717
+ for (const timer of this.doorbellReleaseTimers.values()) clearTimeout(timer);
1718
+ this.doorbellReleaseTimers.clear();
1501
1719
  this.reachability.dispose();
1502
1720
  for (const link of this.links.values()) await link.client.dispose();
1503
1721
  this.links.clear();
@@ -1554,6 +1772,15 @@ var HaExportAddon = class extends require_dist.BaseAddon {
1554
1772
  this.subscribe({ category: require_dist.EventCategory.MotionOnMotionChanged }, (event) => {
1555
1773
  this.onMotion(event.data);
1556
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
+ });
1557
1784
  this.subscribe({ category: require_dist.EventCategory.PipelineAnalyticsTrackLifecycle }, (event) => {
1558
1785
  this.onTrackLifecycle(event.data);
1559
1786
  });
@@ -1601,6 +1828,40 @@ var HaExportAddon = class extends require_dist.BaseAddon {
1601
1828
  }));
1602
1829
  return;
1603
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
+ }
1604
1865
  if (capName === "motion") {
1605
1866
  const detected = slice["detected"];
1606
1867
  const lastDetectedAt = readNumber(slice, "lastDetectedAt");
@@ -1673,6 +1934,28 @@ var HaExportAddon = class extends require_dist.BaseAddon {
1673
1934
  }
1674
1935
  this.push(deviceId, values);
1675
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
+ }
1676
1959
  onZoneOccupancy(data) {
1677
1960
  const deviceId = readNumber(data, "deviceId");
1678
1961
  if (deviceId === null) return;
@@ -1686,11 +1969,20 @@ var HaExportAddon = class extends require_dist.BaseAddon {
1686
1969
  if (zoneId === null) return [];
1687
1970
  return [{
1688
1971
  zoneId,
1972
+ totalObjects: readNumber(entry, "totalObjects") ?? 0,
1689
1973
  byClass: toCountRecord(byClass)
1690
1974
  }];
1691
1975
  });
1692
- if (zones.length === 0) return;
1693
- 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
+ }));
1694
1986
  }
1695
1987
  /**
1696
1988
  * Send a device's values to every broker it is exported to.
@@ -1907,10 +2199,17 @@ var HaExportAddon = class extends require_dist.BaseAddon {
1907
2199
  enabled: sw.enabled
1908
2200
  }))));
1909
2201
  const occupancy = await this.ctx.api.zoneAnalytics.getCurrentSnapshot.query({ deviceId: entry.deviceId });
1910
- if (occupancy !== null) values.push(...projectZoneOccupancy(key, { zones: occupancy.zones.map((zone) => ({
1911
- zoneId: zone.zoneId,
1912
- byClass: toCountRecord(zone.byClass)
1913
- })) }));
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
+ }));
1914
2213
  }
1915
2214
  return values;
1916
2215
  }
@@ -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`,
@@ -1320,6 +1445,10 @@ function projectZoneOccupancy(deviceKey, input) {
1320
1445
  const values = [];
1321
1446
  for (const zone of input.zones) {
1322
1447
  const slug = toSlug(zone.zoneId);
1448
+ values.push({
1449
+ topic: stateTopic(deviceKey, `${slug}_objects`),
1450
+ value: String(zone.totalObjects)
1451
+ });
1323
1452
  const totals = new Map(ZONE_MACROS.map((macro) => [macro, 0]));
1324
1453
  for (const [className, count] of Object.entries(zone.byClass)) {
1325
1454
  const macro = macroOf(className);
@@ -1331,6 +1460,22 @@ function projectZoneOccupancy(deviceKey, input) {
1331
1460
  value: String(count)
1332
1461
  });
1333
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
+ }
1334
1479
  return values;
1335
1480
  }
1336
1481
  function projectBatterySlice(deviceKey, slice) {
@@ -1366,6 +1511,71 @@ function projectCameraSwitches(deviceKey, switches) {
1366
1511
  value: bool(sw.enabled)
1367
1512
  }));
1368
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
+ }
1369
1579
  /** The `BrokerInfo.kind` tag the Home Assistant provider stamps. */
1370
1580
  var HA_BROKER_KIND = "home-assistant";
1371
1581
  /**
@@ -1420,6 +1630,8 @@ var ADDON_ID = "homeassistant-export";
1420
1630
  /** Where the custom component registers its push view inside HA. */
1421
1631
  var PUSH_PATH = "/api/camstack/push";
1422
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;
1423
1635
  /** The hub's API port — where the data plane is reverse-proxied. */
1424
1636
  var HUB_API_PORT = 4443;
1425
1637
  /** One PTZ button press, as a relative move. */
@@ -1451,6 +1663,10 @@ var HaExportAddon = class extends BaseAddon {
1451
1663
  reconcilePending = null;
1452
1664
  lastError;
1453
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();
1454
1670
  unclassified = [];
1455
1671
  /**
1456
1672
  * A link that returned repairs NOW, not at the next periodic pass.
@@ -1493,6 +1709,8 @@ var HaExportAddon = class extends BaseAddon {
1493
1709
  this.startTimer();
1494
1710
  this.ctx.addDisposer(async () => {
1495
1711
  this.stopTimer();
1712
+ for (const timer of this.doorbellReleaseTimers.values()) clearTimeout(timer);
1713
+ this.doorbellReleaseTimers.clear();
1496
1714
  this.reachability.dispose();
1497
1715
  for (const link of this.links.values()) await link.client.dispose();
1498
1716
  this.links.clear();
@@ -1549,6 +1767,15 @@ var HaExportAddon = class extends BaseAddon {
1549
1767
  this.subscribe({ category: EventCategory.MotionOnMotionChanged }, (event) => {
1550
1768
  this.onMotion(event.data);
1551
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
+ });
1552
1779
  this.subscribe({ category: EventCategory.PipelineAnalyticsTrackLifecycle }, (event) => {
1553
1780
  this.onTrackLifecycle(event.data);
1554
1781
  });
@@ -1596,6 +1823,40 @@ var HaExportAddon = class extends BaseAddon {
1596
1823
  }));
1597
1824
  return;
1598
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
+ }
1599
1860
  if (capName === "motion") {
1600
1861
  const detected = slice["detected"];
1601
1862
  const lastDetectedAt = readNumber(slice, "lastDetectedAt");
@@ -1668,6 +1929,28 @@ var HaExportAddon = class extends BaseAddon {
1668
1929
  }
1669
1930
  this.push(deviceId, values);
1670
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
+ }
1671
1954
  onZoneOccupancy(data) {
1672
1955
  const deviceId = readNumber(data, "deviceId");
1673
1956
  if (deviceId === null) return;
@@ -1681,11 +1964,20 @@ var HaExportAddon = class extends BaseAddon {
1681
1964
  if (zoneId === null) return [];
1682
1965
  return [{
1683
1966
  zoneId,
1967
+ totalObjects: readNumber(entry, "totalObjects") ?? 0,
1684
1968
  byClass: toCountRecord(byClass)
1685
1969
  }];
1686
1970
  });
1687
- if (zones.length === 0) return;
1688
- 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
+ }));
1689
1981
  }
1690
1982
  /**
1691
1983
  * Send a device's values to every broker it is exported to.
@@ -1902,10 +2194,17 @@ var HaExportAddon = class extends BaseAddon {
1902
2194
  enabled: sw.enabled
1903
2195
  }))));
1904
2196
  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
- })) }));
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
+ }));
1909
2208
  }
1910
2209
  return values;
1911
2210
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@camstack/addon-provider-homeassistant",
3
- "version": "1.2.19",
3
+ "version": "1.2.20",
4
4
  "description": "Home Assistant device provider addon for CamStack",
5
5
  "keywords": [
6
6
  "camstack",