@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:
|
|
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:
|
|
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
|
-
|
|
1693
|
-
|
|
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, {
|
|
1911
|
-
|
|
1912
|
-
|
|
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:
|
|
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:
|
|
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
|
-
|
|
1688
|
-
|
|
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, {
|
|
1906
|
-
|
|
1907
|
-
|
|
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
|
}
|