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