@camstack/addon-provider-homeassistant 1.2.42 → 1.2.44
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-Cef7D9kq.js → dist-B1grYZFh.js} +2060 -1815
- package/dist/{dist-DolpU5X1.mjs → dist-BjB1J9mO.mjs} +2049 -1816
- package/dist/ha-export/ha-export.addon.js +1054 -88
- package/dist/ha-export/ha-export.addon.mjs +1054 -88
- package/package.json +1 -1
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { At as EventCategory, C as buildAddonRouteProvider, Et as string, N as deviceExportCapability, X as oauthIntegrationCapability, a as COCO_TO_MACRO, c as FanDirectionSchema, i as CAMERA_SWITCH_ORDER, l as HvacModeSchema, o as CameraSwitchIdSchema, p as addonRoutesCapability, pt as BaseAddon, t as AlarmArmModeSchema, u as MediaPlayerRepeatSchema, vt as nodePin } from "../dist-BjB1J9mO.mjs";
|
|
2
2
|
import { createHmac, timingSafeEqual } from "node:crypto";
|
|
3
3
|
//#region src/ha-export/topics.ts
|
|
4
4
|
/**
|
|
@@ -1252,9 +1252,14 @@ function cameraSpecs(device) {
|
|
|
1252
1252
|
}),
|
|
1253
1253
|
(
|
|
1254
1254
|
/**
|
|
1255
|
-
* What was heard, and how loud.
|
|
1256
|
-
*
|
|
1257
|
-
* `
|
|
1255
|
+
* What was heard, and how loud. Both fed by `pipeline.audio-inference-result`
|
|
1256
|
+
* via `projectAudioWindow` — which is also where `audio_detected` (the
|
|
1257
|
+
* `audio` row of the macro loop below) gets its value now. Until this was
|
|
1258
|
+
* fixed, `audio_detected` was fed only by the track lifecycle, which
|
|
1259
|
+
* almost never carries an audio macro, so the entity existed and
|
|
1260
|
+
* effectively never moved: a comment right here used to claim this window
|
|
1261
|
+
* was already its source, and it was not — an avanzo that described the
|
|
1262
|
+
* right design while being false.
|
|
1258
1263
|
*
|
|
1259
1264
|
* `audio_last_sound`, NOT `audio_last_label`: `<macro>_last_label` is the
|
|
1260
1265
|
* TRACK path's naming, reserved for the macros that carry an
|
|
@@ -1333,8 +1338,10 @@ function cameraSpecs(device) {
|
|
|
1333
1338
|
enabledByDefault: false
|
|
1334
1339
|
});
|
|
1335
1340
|
/**
|
|
1336
|
-
* One HA switch per AVAILABLE camera
|
|
1337
|
-
* `
|
|
1341
|
+
* One HA switch per AVAILABLE camera function, rendered from
|
|
1342
|
+
* `deriveCameraFunctions` and commanded back through
|
|
1343
|
+
* `resolveCameraFunctionWrite` + `applyCameraFunctionWrite` — each straight
|
|
1344
|
+
* onto the authority that already owns it (D62, D113 phase 4).
|
|
1338
1345
|
*
|
|
1339
1346
|
* The export never writes an authority itself: a second knob that can
|
|
1340
1347
|
* disagree with the admin UI is worse than no knob (D62). An
|
|
@@ -1430,6 +1437,30 @@ function cameraSpecs(device) {
|
|
|
1430
1437
|
entityCategory: "diagnostic",
|
|
1431
1438
|
enabledByDefault: false
|
|
1432
1439
|
});
|
|
1440
|
+
/**
|
|
1441
|
+
* `status` — the entity D62 exists for outside this repo. A camera an
|
|
1442
|
+
* operator switched off must never paint the same picture in Home
|
|
1443
|
+
* Assistant as one that is genuinely broken (`projectCameraStatus`).
|
|
1444
|
+
*
|
|
1445
|
+
* No `device_class`: HA's `enum` device class demands `options`, and a
|
|
1446
|
+
* `select` here would be a control that cannot be written — the dead
|
|
1447
|
+
* control this whole export refuses to ship.
|
|
1448
|
+
*/
|
|
1449
|
+
specs.push({
|
|
1450
|
+
entity: "status",
|
|
1451
|
+
platform: "sensor",
|
|
1452
|
+
label: "Status",
|
|
1453
|
+
icon: "mdi:cctv",
|
|
1454
|
+
enabledByDefault: true
|
|
1455
|
+
});
|
|
1456
|
+
/** «Is it recording right now?» — `CameraStatus.recording.active`. */
|
|
1457
|
+
specs.push({
|
|
1458
|
+
entity: "recording_active",
|
|
1459
|
+
platform: "binary_sensor",
|
|
1460
|
+
label: "Recording",
|
|
1461
|
+
deviceClass: "running",
|
|
1462
|
+
enabledByDefault: true
|
|
1463
|
+
});
|
|
1433
1464
|
specs.push({
|
|
1434
1465
|
entity: "snooze",
|
|
1435
1466
|
platform: "select",
|
|
@@ -2687,7 +2718,7 @@ var CAPS_NOT_EXPORTED = [
|
|
|
2687
2718
|
},
|
|
2688
2719
|
{
|
|
2689
2720
|
cap: "privacy-mask",
|
|
2690
|
-
reason: "Rendered as
|
|
2721
|
+
reason: "Rendered as two camera function switches (privacy-mask, device-audio) through deriveCameraFunctions, never written directly (D62, D113 phase 4)."
|
|
2691
2722
|
},
|
|
2692
2723
|
{
|
|
2693
2724
|
cap: "zone-analytics",
|
|
@@ -2943,17 +2974,8 @@ function buildDevicePlan(device, options = UNNEGOTIATED_PLAN_OPTIONS) {
|
|
|
2943
2974
|
* refusal carries a named reason so the log line says which of those it
|
|
2944
2975
|
* was.
|
|
2945
2976
|
*/
|
|
2946
|
-
/** The camera
|
|
2947
|
-
var CAMERA_SWITCH_BY_SLUG = Object.fromEntries([
|
|
2948
|
-
"stream-broker",
|
|
2949
|
-
"object-detection",
|
|
2950
|
-
"privacy-mask",
|
|
2951
|
-
"device-audio",
|
|
2952
|
-
"broker-audio",
|
|
2953
|
-
"audio-analysis",
|
|
2954
|
-
"recording",
|
|
2955
|
-
"notifications"
|
|
2956
|
-
].map((id) => [toSlug(id), id]));
|
|
2977
|
+
/** The camera function ids, keyed by the entity slug the catalog emits. */
|
|
2978
|
+
var CAMERA_SWITCH_BY_SLUG = Object.fromEntries(CAMERA_SWITCH_ORDER.map((id) => [toSlug(id), id]));
|
|
2957
2979
|
var PTZ_DIRECTION_BY_ENTITY = Object.fromEntries(PTZ_BUTTONS.map((entity) => [entity, entity.slice(4)]));
|
|
2958
2980
|
function parseBool(value) {
|
|
2959
2981
|
const lower = value.trim().toLowerCase();
|
|
@@ -3538,7 +3560,7 @@ function resolveCommand(target, entity, value) {
|
|
|
3538
3560
|
return {
|
|
3539
3561
|
ok: true,
|
|
3540
3562
|
command: {
|
|
3541
|
-
kind: "camera-
|
|
3563
|
+
kind: "camera-function",
|
|
3542
3564
|
deviceId: target.deviceId,
|
|
3543
3565
|
switchId,
|
|
3544
3566
|
enabled
|
|
@@ -3698,7 +3720,7 @@ function build(builder, deviceId, value, capName) {
|
|
|
3698
3720
|
*/
|
|
3699
3721
|
function isCameraCommand(command) {
|
|
3700
3722
|
switch (command.kind) {
|
|
3701
|
-
case "camera-
|
|
3723
|
+
case "camera-function":
|
|
3702
3724
|
case "reboot":
|
|
3703
3725
|
case "ptz-move":
|
|
3704
3726
|
case "ptz-preset":
|
|
@@ -3937,6 +3959,187 @@ async function call(slice, capName, make) {
|
|
|
3937
3959
|
}
|
|
3938
3960
|
return { ok: true };
|
|
3939
3961
|
}
|
|
3962
|
+
//#endregion
|
|
3963
|
+
//#region src/ha-export/camera-function-writes.ts
|
|
3964
|
+
/**
|
|
3965
|
+
* Esegue la SCRITTURA di una funzione camera sull'autorità che la possiede.
|
|
3966
|
+
*
|
|
3967
|
+
* `deriveCameraFunctions`/`resolveCameraFunctionWrite` (`camera-functions.ts`)
|
|
3968
|
+
* decidono; questo modulo agisce. È il file che
|
|
3969
|
+
* `scripts/check-switch-successor-surfaces.ts` punta come successore della
|
|
3970
|
+
* coppia deprecata `getCameraSwitches` / `setCameraSwitch` (D113 fase 4).
|
|
3971
|
+
*/
|
|
3972
|
+
async function applyCameraFunctionWrite(api, deviceId, write, sourceNodeId) {
|
|
3973
|
+
switch (write.kind) {
|
|
3974
|
+
case "device-disabled":
|
|
3975
|
+
await api.deviceManager.setDisabled.mutate({
|
|
3976
|
+
deviceId,
|
|
3977
|
+
disabled: write.disabled
|
|
3978
|
+
});
|
|
3979
|
+
return;
|
|
3980
|
+
case "wrapper-binding":
|
|
3981
|
+
await api.deviceManager.setWrapperActive.mutate({
|
|
3982
|
+
deviceId,
|
|
3983
|
+
capName: write.capName,
|
|
3984
|
+
wrapperAddonId: write.wrapperAddonId,
|
|
3985
|
+
active: write.active
|
|
3986
|
+
});
|
|
3987
|
+
return;
|
|
3988
|
+
case "recording-enabled": {
|
|
3989
|
+
const config = await api.recording.getDeviceConfig.query({ deviceId });
|
|
3990
|
+
await api.recording.setDeviceConfig.mutate({
|
|
3991
|
+
deviceId,
|
|
3992
|
+
config: {
|
|
3993
|
+
...config,
|
|
3994
|
+
enabled: write.enabled
|
|
3995
|
+
}
|
|
3996
|
+
});
|
|
3997
|
+
return;
|
|
3998
|
+
}
|
|
3999
|
+
case "notification-mute":
|
|
4000
|
+
await api.notificationRules.setDeviceMuted.mutate({
|
|
4001
|
+
deviceId,
|
|
4002
|
+
muted: write.muted
|
|
4003
|
+
});
|
|
4004
|
+
return;
|
|
4005
|
+
case "camera-mask":
|
|
4006
|
+
await api.privacyMask.setMask.mutate({
|
|
4007
|
+
deviceId,
|
|
4008
|
+
patch: { enabled: write.enabled }
|
|
4009
|
+
});
|
|
4010
|
+
return;
|
|
4011
|
+
case "camera-audio":
|
|
4012
|
+
await api.privacyMask.setAudioEnabled.mutate({
|
|
4013
|
+
deviceId,
|
|
4014
|
+
enabled: write.enabled
|
|
4015
|
+
});
|
|
4016
|
+
return;
|
|
4017
|
+
case "broker-audio-mute":
|
|
4018
|
+
if (sourceNodeId === null) throw new Error(`refusing an unrouted broker audio write for device ${String(deviceId)}: no source node`);
|
|
4019
|
+
await api.streamBroker.setDeviceAudioMute.mutate({
|
|
4020
|
+
deviceId,
|
|
4021
|
+
muted: write.muted
|
|
4022
|
+
}, nodePin(sourceNodeId));
|
|
4023
|
+
return;
|
|
4024
|
+
}
|
|
4025
|
+
}
|
|
4026
|
+
//#endregion
|
|
4027
|
+
//#region src/ha-export/camera-functions.ts
|
|
4028
|
+
var DETECTION_CAP = "detection-pipeline";
|
|
4029
|
+
var AUDIO_ANALYSIS_CAP = "audio-analysis";
|
|
4030
|
+
/** Ordine di resa: raggio d'azione più ampio prima, sorgente prima del consumatore. */
|
|
4031
|
+
var ORDER = [
|
|
4032
|
+
"stream-broker",
|
|
4033
|
+
"object-detection",
|
|
4034
|
+
"privacy-mask",
|
|
4035
|
+
"device-audio",
|
|
4036
|
+
"broker-audio",
|
|
4037
|
+
"audio-analysis",
|
|
4038
|
+
"recording",
|
|
4039
|
+
"notifications"
|
|
4040
|
+
];
|
|
4041
|
+
var LABELS = {
|
|
4042
|
+
"stream-broker": "Camera",
|
|
4043
|
+
"object-detection": "Object detection & tracking",
|
|
4044
|
+
"privacy-mask": "Privacy mask",
|
|
4045
|
+
"device-audio": "Camera microphone",
|
|
4046
|
+
"broker-audio": "Audio distribution",
|
|
4047
|
+
"audio-analysis": "Audio detection & classification",
|
|
4048
|
+
recording: "Recording",
|
|
4049
|
+
notifications: "Notifications"
|
|
4050
|
+
};
|
|
4051
|
+
var UNAVAILABLE = {
|
|
4052
|
+
available: false,
|
|
4053
|
+
enabled: true
|
|
4054
|
+
};
|
|
4055
|
+
function resolveWrapper(capName, reads) {
|
|
4056
|
+
if (reads.bindableCapNames === null || reads.wrappedCapNames === null) return UNAVAILABLE;
|
|
4057
|
+
if (!reads.bindableCapNames.includes(capName)) return UNAVAILABLE;
|
|
4058
|
+
if (reads.wrapperAddonIdByCap.get(capName) === void 0) return UNAVAILABLE;
|
|
4059
|
+
return {
|
|
4060
|
+
available: true,
|
|
4061
|
+
enabled: reads.wrappedCapNames.includes(capName)
|
|
4062
|
+
};
|
|
4063
|
+
}
|
|
4064
|
+
function resolveOne(id, reads) {
|
|
4065
|
+
switch (id) {
|
|
4066
|
+
case "stream-broker": return reads.deviceDisabled === null ? UNAVAILABLE : {
|
|
4067
|
+
available: true,
|
|
4068
|
+
enabled: !reads.deviceDisabled
|
|
4069
|
+
};
|
|
4070
|
+
case "object-detection": return resolveWrapper(DETECTION_CAP, reads);
|
|
4071
|
+
case "audio-analysis": return resolveWrapper(AUDIO_ANALYSIS_CAP, reads);
|
|
4072
|
+
case "recording": return reads.recordingEnabled === null ? UNAVAILABLE : {
|
|
4073
|
+
available: true,
|
|
4074
|
+
enabled: reads.recordingEnabled
|
|
4075
|
+
};
|
|
4076
|
+
case "notifications": return reads.notificationsMuted === null ? UNAVAILABLE : {
|
|
4077
|
+
available: true,
|
|
4078
|
+
enabled: !reads.notificationsMuted
|
|
4079
|
+
};
|
|
4080
|
+
case "privacy-mask": return reads.privacy === null ? UNAVAILABLE : {
|
|
4081
|
+
available: true,
|
|
4082
|
+
enabled: reads.privacy.maskEnabled
|
|
4083
|
+
};
|
|
4084
|
+
case "device-audio": return reads.privacy === null || reads.privacy.audioEnabled === null ? UNAVAILABLE : {
|
|
4085
|
+
available: true,
|
|
4086
|
+
enabled: reads.privacy.audioEnabled
|
|
4087
|
+
};
|
|
4088
|
+
case "broker-audio": return reads.brokerAudioMuted === null ? UNAVAILABLE : {
|
|
4089
|
+
available: true,
|
|
4090
|
+
enabled: !reads.brokerAudioMuted
|
|
4091
|
+
};
|
|
4092
|
+
}
|
|
4093
|
+
}
|
|
4094
|
+
function deriveCameraFunctions(reads) {
|
|
4095
|
+
return ORDER.map((id) => ({
|
|
4096
|
+
id,
|
|
4097
|
+
label: LABELS[id],
|
|
4098
|
+
...resolveOne(id, reads)
|
|
4099
|
+
}));
|
|
4100
|
+
}
|
|
4101
|
+
/** `null` = non disponibile: la scrittura va RIFIUTATA, non approssimata. */
|
|
4102
|
+
function resolveCameraFunctionWrite(id, on, reads) {
|
|
4103
|
+
if (!resolveOne(id, reads).available) return null;
|
|
4104
|
+
switch (id) {
|
|
4105
|
+
case "stream-broker": return {
|
|
4106
|
+
kind: "device-disabled",
|
|
4107
|
+
disabled: !on
|
|
4108
|
+
};
|
|
4109
|
+
case "object-detection":
|
|
4110
|
+
case "audio-analysis": {
|
|
4111
|
+
const capName = id === "object-detection" ? DETECTION_CAP : AUDIO_ANALYSIS_CAP;
|
|
4112
|
+
const wrapperAddonId = reads.wrapperAddonIdByCap.get(capName);
|
|
4113
|
+
if (wrapperAddonId === void 0) return null;
|
|
4114
|
+
return {
|
|
4115
|
+
kind: "wrapper-binding",
|
|
4116
|
+
capName,
|
|
4117
|
+
wrapperAddonId,
|
|
4118
|
+
active: on
|
|
4119
|
+
};
|
|
4120
|
+
}
|
|
4121
|
+
case "recording": return {
|
|
4122
|
+
kind: "recording-enabled",
|
|
4123
|
+
enabled: on
|
|
4124
|
+
};
|
|
4125
|
+
case "notifications": return {
|
|
4126
|
+
kind: "notification-mute",
|
|
4127
|
+
muted: !on
|
|
4128
|
+
};
|
|
4129
|
+
case "privacy-mask": return {
|
|
4130
|
+
kind: "camera-mask",
|
|
4131
|
+
enabled: on
|
|
4132
|
+
};
|
|
4133
|
+
case "device-audio": return {
|
|
4134
|
+
kind: "camera-audio",
|
|
4135
|
+
enabled: on
|
|
4136
|
+
};
|
|
4137
|
+
case "broker-audio": return {
|
|
4138
|
+
kind: "broker-audio-mute",
|
|
4139
|
+
muted: !on
|
|
4140
|
+
};
|
|
4141
|
+
}
|
|
4142
|
+
}
|
|
3940
4143
|
/**
|
|
3941
4144
|
* The ONE derivation of "a signed, expiring URL".
|
|
3942
4145
|
*
|
|
@@ -4682,16 +4885,18 @@ function projectBatterySlice(deviceKey, slice) {
|
|
|
4682
4885
|
}
|
|
4683
4886
|
/**
|
|
4684
4887
|
* The per-camera function switches, mirrored 1:1 from
|
|
4685
|
-
* `
|
|
4888
|
+
* `deriveCameraFunctions` (`camera-functions.ts`) — the pure derivation over
|
|
4889
|
+
* the AUTHORITIES that own each function, replacing the deprecated
|
|
4890
|
+
* `pipelineOrchestrator.getCameraSwitches` group (D113 phase 4).
|
|
4686
4891
|
*
|
|
4687
|
-
* An UNAVAILABLE
|
|
4892
|
+
* An UNAVAILABLE function publishes nothing, because `enabled` is
|
|
4688
4893
|
* meaningless when `available` is false — a source that did not answer
|
|
4689
4894
|
* must never become a control rendered `on`.
|
|
4690
4895
|
*/
|
|
4691
|
-
function
|
|
4692
|
-
return
|
|
4693
|
-
topic: stateTopic(deviceKey, toSlug(
|
|
4694
|
-
value: bool(
|
|
4896
|
+
function projectCameraFunctions(deviceKey, functions) {
|
|
4897
|
+
return functions.filter((fn) => fn.available).map((fn) => ({
|
|
4898
|
+
topic: stateTopic(deviceKey, toSlug(fn.id)),
|
|
4899
|
+
value: bool(fn.enabled)
|
|
4695
4900
|
}));
|
|
4696
4901
|
}
|
|
4697
4902
|
/**
|
|
@@ -4757,12 +4962,88 @@ function projectAudioWindow(deviceKey, input) {
|
|
|
4757
4962
|
value: String(Math.round(input.rms * 1e4) / 1e4)
|
|
4758
4963
|
});
|
|
4759
4964
|
const best = input.detections.reduce((top, d) => top === null || d.confidence > top.confidence ? d : top, null);
|
|
4965
|
+
/**
|
|
4966
|
+
* `audio_detected` — the source it never had. The catalog's comment used
|
|
4967
|
+
* to claim this window was already feeding it; it was not (grep found no
|
|
4968
|
+
* publisher), and that comment was a leftover describing the right design
|
|
4969
|
+
* while being false. This window is the fix: it raises on any window that
|
|
4970
|
+
* carries a classification and lowers on a quiet one, WITHOUT clearing
|
|
4971
|
+
* `audio_last_sound` — "what was that noise" is asked after the noise has
|
|
4972
|
+
* stopped.
|
|
4973
|
+
*/
|
|
4974
|
+
values.push({
|
|
4975
|
+
topic: stateTopic(deviceKey, "audio_detected"),
|
|
4976
|
+
value: bool(best !== null)
|
|
4977
|
+
});
|
|
4760
4978
|
if (best !== null) values.push({
|
|
4761
4979
|
topic: stateTopic(deviceKey, "audio_last_sound"),
|
|
4762
4980
|
value: best.className
|
|
4763
4981
|
});
|
|
4764
4982
|
return values;
|
|
4765
4983
|
}
|
|
4984
|
+
/** Inverse of `SNOOZE_MINUTES` — a minute count back to the option label. */
|
|
4985
|
+
var SNOOZE_OPTION_BY_MINUTES = new Map(Object.entries(SNOOZE_MINUTES).map(([option, minutes]) => [minutes, option]));
|
|
4986
|
+
/**
|
|
4987
|
+
* The `snooze` select's STATE. The authority is
|
|
4988
|
+
* `notificationRules.listSnoozes`, which carries `startedAt`/`expiresAt` but
|
|
4989
|
+
* not the preset label an operator picked — there is nowhere to store that
|
|
4990
|
+
* label, and there must not be: it is reconstructed from the two timestamps
|
|
4991
|
+
* every time, so it can never drift from the authority.
|
|
4992
|
+
*
|
|
4993
|
+
* No active snooze publishes `Off`, never silence: Home Assistant reads a
|
|
4994
|
+
* missing state update as "the last value still holds", so a select gone
|
|
4995
|
+
* quiet after a snooze expires would keep showing the expired duration
|
|
4996
|
+
* forever.
|
|
4997
|
+
*
|
|
4998
|
+
* A duration the select's closed vocabulary cannot express — a snooze the
|
|
4999
|
+
* viewer created with an arbitrary minute count — publishes NOTHING.
|
|
5000
|
+
* Rounding it to the nearest preset would let Home Assistant re-send a
|
|
5001
|
+
* duration the operator never chose; `unknown` is the honest state.
|
|
5002
|
+
*/
|
|
5003
|
+
function projectSnooze(deviceKey, window) {
|
|
5004
|
+
if (window === null) return [{
|
|
5005
|
+
topic: stateTopic(deviceKey, "snooze"),
|
|
5006
|
+
value: "Off"
|
|
5007
|
+
}];
|
|
5008
|
+
const minutes = Math.round((window.expiresAt - window.startedAt) / 6e4);
|
|
5009
|
+
const option = SNOOZE_OPTION_BY_MINUTES.get(minutes);
|
|
5010
|
+
if (option === void 0) return [];
|
|
5011
|
+
return [{
|
|
5012
|
+
topic: stateTopic(deviceKey, "snooze"),
|
|
5013
|
+
value: option
|
|
5014
|
+
}];
|
|
5015
|
+
}
|
|
5016
|
+
/**
|
|
5017
|
+
* `status` — the entity D62 exists for. A camera an operator switched off and
|
|
5018
|
+
* a camera that is genuinely broken must never paint the same picture in Home
|
|
5019
|
+
* Assistant: this is what tells the two apart.
|
|
5020
|
+
*
|
|
5021
|
+
* Order matters: the `switches` stage failing to answer makes the WHOLE
|
|
5022
|
+
* badge unknown, because an empty `switchedOff` is only a positive claim when
|
|
5023
|
+
* `degraded` does not name `switches` — reporting `ok` here would be the D62
|
|
5024
|
+
* failure landing inside the very field written to prevent it.
|
|
5025
|
+
*/
|
|
5026
|
+
function projectCameraStatus(deviceKey, input) {
|
|
5027
|
+
const values = [{
|
|
5028
|
+
topic: stateTopic(deviceKey, "status"),
|
|
5029
|
+
value: cameraStatusToken(input)
|
|
5030
|
+
}];
|
|
5031
|
+
if (input.recording !== null) values.push({
|
|
5032
|
+
topic: stateTopic(deviceKey, "recording_active"),
|
|
5033
|
+
value: bool(input.recording.active)
|
|
5034
|
+
});
|
|
5035
|
+
else if (!input.degradedStages.includes("recording")) values.push({
|
|
5036
|
+
topic: stateTopic(deviceKey, "recording_active"),
|
|
5037
|
+
value: bool(false)
|
|
5038
|
+
});
|
|
5039
|
+
return values;
|
|
5040
|
+
}
|
|
5041
|
+
function cameraStatusToken(input) {
|
|
5042
|
+
if (input.degradedStages.includes("switches")) return "unknown";
|
|
5043
|
+
if (input.switchedOff.length > 0) return "switched_off";
|
|
5044
|
+
if (input.degradedStages.length > 0) return "degraded";
|
|
5045
|
+
return "ok";
|
|
5046
|
+
}
|
|
4766
5047
|
//#endregion
|
|
4767
5048
|
//#region src/ha-export/synthetic-devices.ts
|
|
4768
5049
|
/**
|
|
@@ -4803,6 +5084,25 @@ function isSyntheticDeviceKey(deviceKey) {
|
|
|
4803
5084
|
return SYNTHETIC_STABLE_IDS.some((id) => deviceKeyFor(id) === deviceKey);
|
|
4804
5085
|
}
|
|
4805
5086
|
/**
|
|
5087
|
+
* Pure derivation of the three alert entities (Task D1) — the conditions
|
|
5088
|
+
* nobody said, surfaced from `alerts.list`.
|
|
5089
|
+
*
|
|
5090
|
+
* Counts and raises the badge ONLY on `status: 'active'` alerts whose
|
|
5091
|
+
* `severity` is `warning` or `error`. `info` moves neither: a badge that
|
|
5092
|
+
* lights up on `info` teaches an operator to ignore it, which is how the
|
|
5093
|
+
* 2026-08-08 four-hour blackout produced not one visible alert despite the
|
|
5094
|
+
* liveness monitor's findings running the whole time.
|
|
5095
|
+
*/
|
|
5096
|
+
function deriveAlertSummary(alerts) {
|
|
5097
|
+
const relevant = alerts.filter((a) => a.status === "active" && (a.severity === "warning" || a.severity === "error"));
|
|
5098
|
+
const last = relevant.reduce((top, a) => top === null || a.updatedAt > top.updatedAt ? a : top, null);
|
|
5099
|
+
return {
|
|
5100
|
+
active: relevant.length > 0,
|
|
5101
|
+
count: relevant.length,
|
|
5102
|
+
lastTitle: last?.title ?? null
|
|
5103
|
+
};
|
|
5104
|
+
}
|
|
5105
|
+
/**
|
|
4806
5106
|
* Everything a synthetic device exports arrives ENABLED.
|
|
4807
5107
|
*
|
|
4808
5108
|
* The pressure valve exists for the camera fan-out — three zones is ~73
|
|
@@ -4875,6 +5175,79 @@ function notificationCenterSpecs(input) {
|
|
|
4875
5175
|
icon: "mdi:counter",
|
|
4876
5176
|
entityCategory: "diagnostic"
|
|
4877
5177
|
},
|
|
5178
|
+
(
|
|
5179
|
+
/**
|
|
5180
|
+
* The GLOBAL snooze, as a control (Task D4) — the half `snoozed` (above)
|
|
5181
|
+
* never had. Its state is NOT projected here: `ha-export.addon.ts` pushes
|
|
5182
|
+
* it through the SAME `projectSnooze` a camera's snooze uses, so the rule
|
|
5183
|
+
* lives in one place — two expressions of it in two files is how
|
|
5184
|
+
* `audio_detected` went mute for a year.
|
|
5185
|
+
*/
|
|
5186
|
+
{
|
|
5187
|
+
entity: "snooze",
|
|
5188
|
+
platform: "select",
|
|
5189
|
+
label: "Snooze all notifications",
|
|
5190
|
+
writable: true,
|
|
5191
|
+
options: SNOOZE_OPTIONS
|
|
5192
|
+
}),
|
|
5193
|
+
(
|
|
5194
|
+
/**
|
|
5195
|
+
* The last trigger, six entities: five facts plus its picture — "conoscere
|
|
5196
|
+
* gli ultimi trigger, le ultime immagini... e da quale label" (operator,
|
|
5197
|
+
* 2026-08-25). ENABLED by default like every other entity on this device
|
|
5198
|
+
* (see {@link buildComponents}'s docblock): a rule the operator wrote is a
|
|
5199
|
+
* rule they want to watch fire.
|
|
5200
|
+
*
|
|
5201
|
+
* Fixed count regardless of camera fleet size — the fan-out this design
|
|
5202
|
+
* explicitly avoids (960 entities at 30 cameras × 32 rules) lives ONLY in
|
|
5203
|
+
* the per-rule switches below, which already existed. These six are on
|
|
5204
|
+
* ONE synthetic device and never repeat per camera or per rule.
|
|
5205
|
+
*/
|
|
5206
|
+
{
|
|
5207
|
+
entity: "last_trigger_at",
|
|
5208
|
+
platform: "sensor",
|
|
5209
|
+
label: "Last trigger",
|
|
5210
|
+
deviceClass: "timestamp"
|
|
5211
|
+
}),
|
|
5212
|
+
{
|
|
5213
|
+
entity: "last_trigger_rule",
|
|
5214
|
+
platform: "sensor",
|
|
5215
|
+
label: "Last trigger rule",
|
|
5216
|
+
icon: "mdi:bell-ring-outline"
|
|
5217
|
+
},
|
|
5218
|
+
{
|
|
5219
|
+
entity: "last_trigger_camera",
|
|
5220
|
+
platform: "sensor",
|
|
5221
|
+
label: "Last trigger camera",
|
|
5222
|
+
icon: "mdi:cctv"
|
|
5223
|
+
},
|
|
5224
|
+
(
|
|
5225
|
+
/** The identified label, or the detected class when none was resolved —
|
|
5226
|
+
* see {@link SyntheticLastTrigger.label}. Never blank. */
|
|
5227
|
+
{
|
|
5228
|
+
entity: "last_trigger_label",
|
|
5229
|
+
platform: "sensor",
|
|
5230
|
+
label: "Last trigger label",
|
|
5231
|
+
icon: "mdi:tag-outline"
|
|
5232
|
+
}),
|
|
5233
|
+
(
|
|
5234
|
+
/** `pending` / `sent` / `dead` — the outbox row's own status, straight
|
|
5235
|
+
* from `getHistory`, never a second delivery ledger. */
|
|
5236
|
+
{
|
|
5237
|
+
entity: "last_trigger_status",
|
|
5238
|
+
platform: "sensor",
|
|
5239
|
+
label: "Last trigger delivery status",
|
|
5240
|
+
icon: "mdi:send-check-outline",
|
|
5241
|
+
entityCategory: "diagnostic"
|
|
5242
|
+
}),
|
|
5243
|
+
(
|
|
5244
|
+
/** See {@link SyntheticLastTrigger.imageUrl} for the expired/unreachable
|
|
5245
|
+
* degrade: no value is published rather than a link that 404s. */
|
|
5246
|
+
{
|
|
5247
|
+
entity: "last_trigger_image",
|
|
5248
|
+
platform: "image",
|
|
5249
|
+
label: "Last trigger image"
|
|
5250
|
+
}),
|
|
4878
5251
|
...input.rules.map((rule) => ({
|
|
4879
5252
|
entity: ruleEntity(rule.id),
|
|
4880
5253
|
platform: "switch",
|
|
@@ -4936,6 +5309,56 @@ function serverSpecs(input) {
|
|
|
4936
5309
|
deviceClass: "update",
|
|
4937
5310
|
entityCategory: "diagnostic"
|
|
4938
5311
|
},
|
|
5312
|
+
(
|
|
5313
|
+
/**
|
|
5314
|
+
* The conditions nobody said (Task D1) — `alerts.list`'s active,
|
|
5315
|
+
* warning-or-error findings. `info` never raises `alerts_active` or
|
|
5316
|
+
* inflates the count: a badge that lights up on `info` teaches an
|
|
5317
|
+
* operator to ignore it, which is how the four-hour blackout stayed
|
|
5318
|
+
* quiet through every liveness finding it produced.
|
|
5319
|
+
*/
|
|
5320
|
+
{
|
|
5321
|
+
entity: "alerts_active",
|
|
5322
|
+
platform: "binary_sensor",
|
|
5323
|
+
label: "Alerts active",
|
|
5324
|
+
deviceClass: "problem"
|
|
5325
|
+
}),
|
|
5326
|
+
{
|
|
5327
|
+
entity: "alerts_active_count",
|
|
5328
|
+
platform: "sensor",
|
|
5329
|
+
label: "Active alerts",
|
|
5330
|
+
icon: "mdi:alert"
|
|
5331
|
+
},
|
|
5332
|
+
{
|
|
5333
|
+
entity: "alerts_last_title",
|
|
5334
|
+
platform: "sensor",
|
|
5335
|
+
label: "Last active alert",
|
|
5336
|
+
icon: "mdi:alert-circle-outline",
|
|
5337
|
+
entityCategory: "diagnostic"
|
|
5338
|
+
},
|
|
5339
|
+
(
|
|
5340
|
+
/** The oldest footage across the whole cluster (Task D2). */
|
|
5341
|
+
{
|
|
5342
|
+
entity: "oldest_footage",
|
|
5343
|
+
platform: "sensor",
|
|
5344
|
+
label: "Oldest footage",
|
|
5345
|
+
deviceClass: "timestamp",
|
|
5346
|
+
entityCategory: "diagnostic"
|
|
5347
|
+
}),
|
|
5348
|
+
(
|
|
5349
|
+
/**
|
|
5350
|
+
* The seed-vs-contract verdict (Task D3, D3's `image_contract`) — the
|
|
5351
|
+
* 2026-08-02 guasto, where `runningVersion` was current while the
|
|
5352
|
+
* container image was weeks old from a renamed repository nothing
|
|
5353
|
+
* rebuilt. Hub only: it describes THIS process's own image.
|
|
5354
|
+
*/
|
|
5355
|
+
{
|
|
5356
|
+
entity: "image_contract",
|
|
5357
|
+
platform: "sensor",
|
|
5358
|
+
label: "Image contract",
|
|
5359
|
+
icon: "mdi:package-variant-closed-check",
|
|
5360
|
+
entityCategory: "diagnostic"
|
|
5361
|
+
}),
|
|
4939
5362
|
...input.nodes.flatMap((node) => {
|
|
4940
5363
|
const slug = toSlug(node.id);
|
|
4941
5364
|
return [
|
|
@@ -4964,7 +5387,38 @@ function serverSpecs(input) {
|
|
|
4964
5387
|
platform: "sensor",
|
|
4965
5388
|
label: `${node.name} uptime`,
|
|
4966
5389
|
deviceClass: "timestamp"
|
|
4967
|
-
}
|
|
5390
|
+
},
|
|
5391
|
+
(
|
|
5392
|
+
/** The disk (Task D2) — every node, hub included: every node records. */
|
|
5393
|
+
{
|
|
5394
|
+
entity: `${slug}_footage_used_bytes`,
|
|
5395
|
+
platform: "sensor",
|
|
5396
|
+
label: `${node.name} footage used`,
|
|
5397
|
+
deviceClass: "data_size",
|
|
5398
|
+
unit: "B",
|
|
5399
|
+
entityCategory: "diagnostic"
|
|
5400
|
+
}),
|
|
5401
|
+
{
|
|
5402
|
+
entity: `${slug}_footage_free_percent`,
|
|
5403
|
+
platform: "sensor",
|
|
5404
|
+
label: `${node.name} footage free`,
|
|
5405
|
+
unit: "%",
|
|
5406
|
+
icon: "mdi:harddisk",
|
|
5407
|
+
entityCategory: "diagnostic"
|
|
5408
|
+
},
|
|
5409
|
+
...node.isHub ? [] : [{
|
|
5410
|
+
entity: `${slug}_version`,
|
|
5411
|
+
platform: "sensor",
|
|
5412
|
+
label: `${node.name} version`,
|
|
5413
|
+
icon: "mdi:tag",
|
|
5414
|
+
entityCategory: "diagnostic"
|
|
5415
|
+
}, {
|
|
5416
|
+
entity: `${slug}_update_available`,
|
|
5417
|
+
platform: "binary_sensor",
|
|
5418
|
+
label: `${node.name} update available`,
|
|
5419
|
+
deviceClass: "update",
|
|
5420
|
+
entityCategory: "diagnostic"
|
|
5421
|
+
}]
|
|
4968
5422
|
];
|
|
4969
5423
|
})
|
|
4970
5424
|
];
|
|
@@ -5029,8 +5483,51 @@ function projectSynthetic(input, nowMs) {
|
|
|
5029
5483
|
{
|
|
5030
5484
|
topic: stateTopic(srv, "update_available"),
|
|
5031
5485
|
value: String(input.updateAvailable)
|
|
5486
|
+
},
|
|
5487
|
+
{
|
|
5488
|
+
topic: stateTopic(srv, "alerts_active"),
|
|
5489
|
+
value: String(input.alertsActive)
|
|
5490
|
+
},
|
|
5491
|
+
{
|
|
5492
|
+
topic: stateTopic(srv, "alerts_active_count"),
|
|
5493
|
+
value: String(input.alertsActiveCount)
|
|
5032
5494
|
}
|
|
5033
5495
|
];
|
|
5496
|
+
if (input.lastTrigger !== null) {
|
|
5497
|
+
const t = input.lastTrigger;
|
|
5498
|
+
values.push({
|
|
5499
|
+
topic: stateTopic(nc, "last_trigger_at"),
|
|
5500
|
+
value: new Date(t.at).toISOString()
|
|
5501
|
+
}, {
|
|
5502
|
+
topic: stateTopic(nc, "last_trigger_rule"),
|
|
5503
|
+
value: t.ruleName
|
|
5504
|
+
}, {
|
|
5505
|
+
topic: stateTopic(nc, "last_trigger_camera"),
|
|
5506
|
+
value: t.deviceName
|
|
5507
|
+
}, {
|
|
5508
|
+
topic: stateTopic(nc, "last_trigger_label"),
|
|
5509
|
+
value: t.label
|
|
5510
|
+
}, {
|
|
5511
|
+
topic: stateTopic(nc, "last_trigger_status"),
|
|
5512
|
+
value: t.status
|
|
5513
|
+
});
|
|
5514
|
+
if (t.imageUrl !== null) values.push({
|
|
5515
|
+
topic: stateTopic(nc, "last_trigger_image"),
|
|
5516
|
+
value: t.imageUrl
|
|
5517
|
+
});
|
|
5518
|
+
}
|
|
5519
|
+
if (input.alertsLastTitle !== null) values.push({
|
|
5520
|
+
topic: stateTopic(srv, "alerts_last_title"),
|
|
5521
|
+
value: input.alertsLastTitle
|
|
5522
|
+
});
|
|
5523
|
+
if (input.oldestFootageMs !== null) values.push({
|
|
5524
|
+
topic: stateTopic(srv, "oldest_footage"),
|
|
5525
|
+
value: new Date(input.oldestFootageMs).toISOString()
|
|
5526
|
+
});
|
|
5527
|
+
if (input.imageContractState !== null) values.push({
|
|
5528
|
+
topic: stateTopic(srv, "image_contract"),
|
|
5529
|
+
value: input.imageContractState
|
|
5530
|
+
});
|
|
5034
5531
|
for (const node of input.nodes) {
|
|
5035
5532
|
const slug = toSlug(node.id);
|
|
5036
5533
|
values.push({
|
|
@@ -5047,6 +5544,24 @@ function projectSynthetic(input, nowMs) {
|
|
|
5047
5544
|
topic: stateTopic(srv, `${slug}_uptime`),
|
|
5048
5545
|
value: (/* @__PURE__ */ new Date(nowMs - node.uptime * 1e3)).toISOString()
|
|
5049
5546
|
});
|
|
5547
|
+
if (node.footageUsedBytes !== null) values.push({
|
|
5548
|
+
topic: stateTopic(srv, `${slug}_footage_used_bytes`),
|
|
5549
|
+
value: String(node.footageUsedBytes)
|
|
5550
|
+
});
|
|
5551
|
+
if (node.footageFreePercent !== null) values.push({
|
|
5552
|
+
topic: stateTopic(srv, `${slug}_footage_free_percent`),
|
|
5553
|
+
value: String(Math.round(node.footageFreePercent * 10) / 10)
|
|
5554
|
+
});
|
|
5555
|
+
if (!node.isHub) {
|
|
5556
|
+
if (node.rootPackageVersion !== null) values.push({
|
|
5557
|
+
topic: stateTopic(srv, `${slug}_version`),
|
|
5558
|
+
value: node.rootPackageVersion
|
|
5559
|
+
});
|
|
5560
|
+
if (node.updateAvailable !== null) values.push({
|
|
5561
|
+
topic: stateTopic(srv, `${slug}_update_available`),
|
|
5562
|
+
value: String(node.updateAvailable)
|
|
5563
|
+
});
|
|
5564
|
+
}
|
|
5050
5565
|
}
|
|
5051
5566
|
return values;
|
|
5052
5567
|
}
|
|
@@ -5185,6 +5700,17 @@ var HaExportAddon = class extends BaseAddon {
|
|
|
5185
5700
|
*/
|
|
5186
5701
|
syntheticRules = [];
|
|
5187
5702
|
/**
|
|
5703
|
+
* `pipelineOrchestrator.getCameraStatuses`, keyed by deviceId — ONE call
|
|
5704
|
+
* per reconcile pass for every exported camera (Task C3), never per
|
|
5705
|
+
* device: the input is optional and omitting it means "the whole fleet",
|
|
5706
|
+
* which is exactly the path this repo just finished removing from hot
|
|
5707
|
+
* loops. Feeds `status` / `recording_active` (Traccia C) and
|
|
5708
|
+
* `broker-audio`'s node pin (Traccia A2). Cleared to empty on a failed
|
|
5709
|
+
* read — never stale data from a previous pass, which would be a status
|
|
5710
|
+
* silently wrong rather than absent.
|
|
5711
|
+
*/
|
|
5712
|
+
cameraStatusByDeviceId = /* @__PURE__ */ new Map();
|
|
5713
|
+
/**
|
|
5188
5714
|
* A link that returned repairs NOW, not at the next periodic pass.
|
|
5189
5715
|
*
|
|
5190
5716
|
* `PushClient` drops its dedup cache the moment Home Assistant answers
|
|
@@ -5684,6 +6210,39 @@ var HaExportAddon = class extends BaseAddon {
|
|
|
5684
6210
|
const devices = await this.ctx.api.deviceManager.listAll.query({});
|
|
5685
6211
|
const snapshots = await this.ctx.api.deviceState.getAllSnapshots.query({});
|
|
5686
6212
|
const byId = new Map(devices.map((device) => [device.id, device]));
|
|
6213
|
+
/**
|
|
6214
|
+
* The exported cameras' `CameraStatus`, ONE call for every one of them
|
|
6215
|
+
* (Task C3). Computed BEFORE the per-device loop: `status`,
|
|
6216
|
+
* `recording_active` and `broker-audio`'s node pin all read from it, and
|
|
6217
|
+
* asking per camera would be the fan-out this repo just finished
|
|
6218
|
+
* removing from a 300 s poll.
|
|
6219
|
+
*/
|
|
6220
|
+
const exposedIds = exposedDeviceIds(this.config.membership, enabled);
|
|
6221
|
+
const cameraDeviceIds = [];
|
|
6222
|
+
for (const idStr of exposedIds) {
|
|
6223
|
+
const numericId = Number(idStr);
|
|
6224
|
+
if (!Number.isFinite(numericId)) continue;
|
|
6225
|
+
const candidate = byId.get(numericId);
|
|
6226
|
+
if (candidate !== void 0 && candidate.type === "camera" && candidate.addonId !== "provider-homeassistant") cameraDeviceIds.push(numericId);
|
|
6227
|
+
}
|
|
6228
|
+
this.cameraStatusByDeviceId = await this.readCameraStatuses(cameraDeviceIds);
|
|
6229
|
+
/** `notificationRules.listDeviceMutes` — once per pass, a flat list. */
|
|
6230
|
+
const mutedDeviceIds = await this.ctx.api.notificationRules.listDeviceMutes.query({}).then((r) => r.mutedDeviceIds).catch((err) => {
|
|
6231
|
+
this.ctx.logger.warn("ha-export: could not read notification mutes, camera functions degrade", { meta: { error: errMsg(err) } });
|
|
6232
|
+
return null;
|
|
6233
|
+
});
|
|
6234
|
+
/** `deviceManager.listBindableCapsForDeviceType` — once per TYPE, not per device. */
|
|
6235
|
+
const bindableCapsByType = /* @__PURE__ */ new Map();
|
|
6236
|
+
/**
|
|
6237
|
+
* `notificationRules.listSnoozes` — ONE read for the whole pass, shared
|
|
6238
|
+
* by every camera's `snooze` select (Task B1) and the synthetic
|
|
6239
|
+
* `snooze` select on *Notification Center* (Task D4): two expressions
|
|
6240
|
+
* of the same read is how `audio_detected` went mute for a year.
|
|
6241
|
+
*/
|
|
6242
|
+
const snoozes = await this.ctx.api.notificationRules.listSnoozes.query({}).then((r) => r.snoozes).catch((err) => {
|
|
6243
|
+
this.ctx.logger.warn("ha-export: could not read snoozes, snooze selects go unknown", { meta: { error: errMsg(err) } });
|
|
6244
|
+
return [];
|
|
6245
|
+
});
|
|
5687
6246
|
const exported = /* @__PURE__ */ new Map();
|
|
5688
6247
|
const keyByDeviceId = /* @__PURE__ */ new Map();
|
|
5689
6248
|
const allCaps = /* @__PURE__ */ new Set();
|
|
@@ -5709,7 +6268,24 @@ var HaExportAddon = class extends BaseAddon {
|
|
|
5709
6268
|
this.ctx.logger.warn("ha-export: refusing to export a device imported from Home Assistant", { tags: { deviceId: numericId } });
|
|
5710
6269
|
continue;
|
|
5711
6270
|
}
|
|
5712
|
-
|
|
6271
|
+
/**
|
|
6272
|
+
* ONE collection of the eight function authorities per camera per
|
|
6273
|
+
* pass (Task A1/A3) — reused below by the announce (the switch
|
|
6274
|
+
* catalog, via `buildCatalogDevice`) AND by the state push
|
|
6275
|
+
* (`currentStateFor`, off `ExportedDevice.cameraFunctions`). The two
|
|
6276
|
+
* used to call the deprecated `getCameraSwitches` separately for the
|
|
6277
|
+
* same answer.
|
|
6278
|
+
*/
|
|
6279
|
+
const cameraFunctions = device.type === "camera" ? deriveCameraFunctions(await this.readCameraFunctions({
|
|
6280
|
+
deviceId: numericId,
|
|
6281
|
+
deviceType: device.type,
|
|
6282
|
+
deviceDisabled: device.disabled,
|
|
6283
|
+
privacySlice: toSnapshot(snapshots[String(numericId)])["privacy-mask"],
|
|
6284
|
+
sourceNodeId: this.cameraStatusByDeviceId.get(numericId)?.assignment.sourceNodeId ?? null,
|
|
6285
|
+
bindableCapsByType,
|
|
6286
|
+
mutedDeviceIds
|
|
6287
|
+
})) : null;
|
|
6288
|
+
const catalogDevice = await this.buildCatalogDevice(device, snapshots[String(numericId)], cameraFunctions);
|
|
5713
6289
|
for (const cap of catalogDevice.boundCaps) allCaps.add(cap);
|
|
5714
6290
|
const brokerIds = brokersExposing(this.config.membership, deviceIdStr).filter((id) => enabled.includes(id));
|
|
5715
6291
|
/**
|
|
@@ -5729,7 +6305,8 @@ var HaExportAddon = class extends BaseAddon {
|
|
|
5729
6305
|
boundCaps: catalogDevice.boundCaps
|
|
5730
6306
|
},
|
|
5731
6307
|
brokerIds,
|
|
5732
|
-
streams: catalogDevice.streams ?? []
|
|
6308
|
+
streams: catalogDevice.streams ?? [],
|
|
6309
|
+
cameraFunctions
|
|
5733
6310
|
});
|
|
5734
6311
|
keyByDeviceId.set(numericId, plan.deviceKey);
|
|
5735
6312
|
entityCount += Object.keys(plan.cmps).length;
|
|
@@ -5758,7 +6335,7 @@ var HaExportAddon = class extends BaseAddon {
|
|
|
5758
6335
|
* `brokerIds` is every enabled broker, because they belong to the server
|
|
5759
6336
|
* and not to a membership the operator picks per camera.
|
|
5760
6337
|
*/
|
|
5761
|
-
const synthetic = await this.buildSynthetic();
|
|
6338
|
+
const synthetic = await this.buildSynthetic(snoozes, byId);
|
|
5762
6339
|
if (synthetic !== null) for (const plan of syntheticPlans(synthetic)) {
|
|
5763
6340
|
exported.set(plan.deviceKey, {
|
|
5764
6341
|
plan,
|
|
@@ -5769,7 +6346,8 @@ var HaExportAddon = class extends BaseAddon {
|
|
|
5769
6346
|
boundCaps: []
|
|
5770
6347
|
},
|
|
5771
6348
|
brokerIds: [...enabled],
|
|
5772
|
-
streams: []
|
|
6349
|
+
streams: [],
|
|
6350
|
+
cameraFunctions: null
|
|
5773
6351
|
});
|
|
5774
6352
|
entityCount += Object.keys(plan.cmps).length;
|
|
5775
6353
|
}
|
|
@@ -5797,8 +6375,16 @@ var HaExportAddon = class extends BaseAddon {
|
|
|
5797
6375
|
}
|
|
5798
6376
|
});
|
|
5799
6377
|
await this.announce(exported);
|
|
5800
|
-
await this.pushFullState(exported, snapshots);
|
|
5801
|
-
if (synthetic !== null)
|
|
6378
|
+
await this.pushFullState(exported, snapshots, snoozes);
|
|
6379
|
+
if (synthetic !== null) {
|
|
6380
|
+
/**
|
|
6381
|
+
* The global snooze's STATE (Task D4) rides the SAME `projectSnooze`
|
|
6382
|
+
* a camera's `snooze` select uses, over the SAME `snoozes` read this
|
|
6383
|
+
* pass already made — one expression of the rule, not two.
|
|
6384
|
+
*/
|
|
6385
|
+
const ncKey = deviceKeyFor(NOTIFICATION_CENTER_STABLE_ID);
|
|
6386
|
+
await this.pushSynthetic([...projectSynthetic(synthetic, Date.now()), ...projectSnooze(ncKey, latestGlobalSnoozeWindow(snoozes))], [...enabled]);
|
|
6387
|
+
}
|
|
5802
6388
|
this.ctx.logger.info("ha-export: reconciled", { meta: {
|
|
5803
6389
|
reason,
|
|
5804
6390
|
brokers: enabled.length,
|
|
@@ -5832,7 +6418,7 @@ var HaExportAddon = class extends BaseAddon {
|
|
|
5832
6418
|
* Returns `null` only when NOTHING answered, so the devices are not
|
|
5833
6419
|
* announced empty on a hub that is still booting.
|
|
5834
6420
|
*/
|
|
5835
|
-
async buildSynthetic() {
|
|
6421
|
+
async buildSynthetic(snoozes, byId) {
|
|
5836
6422
|
const rules = await this.ctx.api.notificationRules.listRules.query({}).then((r) => r.rules.map((rule) => ({
|
|
5837
6423
|
id: rule.id,
|
|
5838
6424
|
name: rule.name,
|
|
@@ -5841,15 +6427,9 @@ var HaExportAddon = class extends BaseAddon {
|
|
|
5841
6427
|
this.ctx.logger.warn("ha-export: could not read notification rules", { meta: { error: errMsg(err) } });
|
|
5842
6428
|
return null;
|
|
5843
6429
|
});
|
|
5844
|
-
|
|
5845
|
-
const
|
|
5846
|
-
|
|
5847
|
-
name: node.name,
|
|
5848
|
-
online: node.isOnline,
|
|
5849
|
-
cpuPercent: node.cpuPercent,
|
|
5850
|
-
memoryPercent: node.memoryPercent,
|
|
5851
|
-
uptime: node.uptime
|
|
5852
|
-
}))).catch((err) => {
|
|
6430
|
+
/** Reuses the ONE `listSnoozes` read the pass already made (Task B1/D4). */
|
|
6431
|
+
const anySnoozed = snoozes.length > 0;
|
|
6432
|
+
const topology = await this.ctx.api.nodes.topology.query().catch((err) => {
|
|
5853
6433
|
this.ctx.logger.warn("ha-export: could not read the cluster topology", { meta: { error: errMsg(err) } });
|
|
5854
6434
|
return null;
|
|
5855
6435
|
});
|
|
@@ -5864,16 +6444,132 @@ var HaExportAddon = class extends BaseAddon {
|
|
|
5864
6444
|
};
|
|
5865
6445
|
}).catch(() => null);
|
|
5866
6446
|
const server = await this.ctx.api.serverManagement.getServerPackageStatus.query().catch(() => null);
|
|
5867
|
-
|
|
6447
|
+
const alerts = await this.readActiveAlerts();
|
|
6448
|
+
const lastTrigger = await this.buildLastTrigger(byId);
|
|
6449
|
+
if (rules === null && topology === null && addons === null && server === null) return null;
|
|
5868
6450
|
this.syntheticRules = rules ?? this.syntheticRules;
|
|
6451
|
+
const { nodes, oldestFootageMs } = await this.buildSyntheticNodes(topology ?? []);
|
|
5869
6452
|
return {
|
|
5870
6453
|
rules: rules ?? this.syntheticRules,
|
|
5871
|
-
snoozed:
|
|
5872
|
-
|
|
6454
|
+
snoozed: anySnoozed,
|
|
6455
|
+
lastTrigger,
|
|
6456
|
+
nodes,
|
|
5873
6457
|
addonsRunning: addons?.running ?? 0,
|
|
5874
6458
|
addonsFailed: addons?.failed ?? 0,
|
|
5875
6459
|
serverVersion: server?.runningVersion ?? server?.activeVersion ?? "unknown",
|
|
5876
|
-
updateAvailable: server?.updateAvailable ?? false
|
|
6460
|
+
updateAvailable: server?.updateAvailable ?? false,
|
|
6461
|
+
alertsActive: alerts.active,
|
|
6462
|
+
alertsActiveCount: alerts.count,
|
|
6463
|
+
alertsLastTitle: alerts.lastTitle,
|
|
6464
|
+
oldestFootageMs,
|
|
6465
|
+
imageContractState: server?.imageContract?.state ?? null
|
|
6466
|
+
};
|
|
6467
|
+
}
|
|
6468
|
+
/**
|
|
6469
|
+
* The most recent notification-rule trigger — "conoscere gli ultimi
|
|
6470
|
+
* trigger, le ultime immagini... e da quale label" (operator, 2026-08-25).
|
|
6471
|
+
*
|
|
6472
|
+
* `getHistory` is already newest-first (§3.2), so `limit: 1` is the whole
|
|
6473
|
+
* question. Best-effort like every other synthetic source here: a failed
|
|
6474
|
+
* read costs only these six entities, never the rest of the device.
|
|
6475
|
+
*/
|
|
6476
|
+
async buildLastTrigger(byId) {
|
|
6477
|
+
try {
|
|
6478
|
+
const { entries } = await this.ctx.api.notificationRules.getHistory.query({ filter: { limit: 1 } });
|
|
6479
|
+
const entry = entries[0];
|
|
6480
|
+
if (entry === void 0) return null;
|
|
6481
|
+
const label = entry.subject.label ?? entry.subject.className;
|
|
6482
|
+
const artifactId = entry.artifactIds?.[0];
|
|
6483
|
+
const imageUrl = artifactId === void 0 ? null : await this.ctx.api.notificationRules.resolveArtifactUrl.query({ artifactId }).then((r) => r.url).catch((err) => {
|
|
6484
|
+
this.ctx.logger.debug("ha-export: could not resolve the last trigger image url", {
|
|
6485
|
+
tags: { deviceId: entry.deviceId },
|
|
6486
|
+
meta: {
|
|
6487
|
+
artifactId,
|
|
6488
|
+
error: errMsg(err)
|
|
6489
|
+
}
|
|
6490
|
+
});
|
|
6491
|
+
return null;
|
|
6492
|
+
});
|
|
6493
|
+
return {
|
|
6494
|
+
at: entry.createdAt,
|
|
6495
|
+
ruleName: entry.ruleName,
|
|
6496
|
+
deviceName: byId.get(entry.deviceId)?.name ?? `camera ${entry.deviceId}`,
|
|
6497
|
+
label,
|
|
6498
|
+
status: entry.status,
|
|
6499
|
+
imageUrl
|
|
6500
|
+
};
|
|
6501
|
+
} catch (err) {
|
|
6502
|
+
this.ctx.logger.warn("ha-export: could not read the last notification trigger", { meta: { error: errMsg(err) } });
|
|
6503
|
+
return null;
|
|
6504
|
+
}
|
|
6505
|
+
}
|
|
6506
|
+
/**
|
|
6507
|
+
* `alerts.list` — the conditions nobody said (Task D1). Best-effort: a
|
|
6508
|
+
* failed read yields "nothing active", not a fabricated absence dressed up
|
|
6509
|
+
* as a clean one — this addon logs the drop rather than pretending the
|
|
6510
|
+
* cluster is quiet. The derivation itself is pure — see
|
|
6511
|
+
* `deriveAlertSummary` in `synthetic-devices.ts`, which is what is tested.
|
|
6512
|
+
*/
|
|
6513
|
+
async readActiveAlerts() {
|
|
6514
|
+
try {
|
|
6515
|
+
return deriveAlertSummary(await this.ctx.api.alerts.list.query({
|
|
6516
|
+
unreadOnly: false,
|
|
6517
|
+
limit: 50
|
|
6518
|
+
}));
|
|
6519
|
+
} catch (err) {
|
|
6520
|
+
this.ctx.logger.warn("ha-export: could not read alerts, the alert entities go quiet", { meta: { error: errMsg(err) } });
|
|
6521
|
+
return {
|
|
6522
|
+
active: false,
|
|
6523
|
+
count: 0,
|
|
6524
|
+
lastTitle: null
|
|
6525
|
+
};
|
|
6526
|
+
}
|
|
6527
|
+
}
|
|
6528
|
+
/**
|
|
6529
|
+
* Per-node disk usage (Task D2) and per-AGENT package status (Task D3),
|
|
6530
|
+
* fanned out from the topology this pass already read. Bounded per node —
|
|
6531
|
+
* one unreachable node degrades its own two rows, never the others'.
|
|
6532
|
+
*/
|
|
6533
|
+
async buildSyntheticNodes(topology) {
|
|
6534
|
+
let oldestFootageMs = null;
|
|
6535
|
+
return {
|
|
6536
|
+
nodes: await Promise.all(topology.map(async (node) => {
|
|
6537
|
+
const disk = await this.ctx.api.recording.getStorageUsage.query({}, nodePin(node.id)).catch((err) => {
|
|
6538
|
+
this.ctx.logger.warn("ha-export: could not read storage usage for a node, its disk entities go unknown", { meta: {
|
|
6539
|
+
nodeId: node.id,
|
|
6540
|
+
error: errMsg(err)
|
|
6541
|
+
} });
|
|
6542
|
+
return null;
|
|
6543
|
+
});
|
|
6544
|
+
const freePercents = (disk?.locations ?? []).filter((loc) => loc.availableBytes !== null && loc.totalBytes !== null && loc.totalBytes > 0).map((loc) => loc.availableBytes / loc.totalBytes * 100);
|
|
6545
|
+
const footageFreePercent = freePercents.length > 0 ? Math.min(...freePercents) : null;
|
|
6546
|
+
for (const device of disk?.devices ?? []) {
|
|
6547
|
+
const oldest = device.oldestMs;
|
|
6548
|
+
if (oldest === null || oldest === void 0) continue;
|
|
6549
|
+
if (oldestFootageMs === null || oldest < oldestFootageMs) oldestFootageMs = oldest;
|
|
6550
|
+
}
|
|
6551
|
+
const packageStatus = node.isHub ? null : await this.ctx.api.serverManagement.getServerPackageStatus.query({ nodeId: node.id }).catch((err) => {
|
|
6552
|
+
this.ctx.logger.warn("ha-export: could not read package status for an agent node, its update entities go unknown", { meta: {
|
|
6553
|
+
nodeId: node.id,
|
|
6554
|
+
error: errMsg(err)
|
|
6555
|
+
} });
|
|
6556
|
+
return null;
|
|
6557
|
+
});
|
|
6558
|
+
return {
|
|
6559
|
+
id: node.id,
|
|
6560
|
+
name: node.name,
|
|
6561
|
+
online: node.isOnline,
|
|
6562
|
+
cpuPercent: node.cpuPercent,
|
|
6563
|
+
memoryPercent: node.memoryPercent,
|
|
6564
|
+
uptime: node.uptime,
|
|
6565
|
+
isHub: node.isHub,
|
|
6566
|
+
footageUsedBytes: disk?.totalUsedBytes ?? null,
|
|
6567
|
+
footageFreePercent,
|
|
6568
|
+
rootPackageVersion: node.rootPackage?.version ?? null,
|
|
6569
|
+
updateAvailable: packageStatus?.updateAvailable ?? null
|
|
6570
|
+
};
|
|
6571
|
+
})),
|
|
6572
|
+
oldestFootageMs
|
|
5877
6573
|
};
|
|
5878
6574
|
}
|
|
5879
6575
|
/** Push the synthetic values to every enabled broker. */
|
|
@@ -5901,9 +6597,9 @@ var HaExportAddon = class extends BaseAddon {
|
|
|
5901
6597
|
}
|
|
5902
6598
|
}
|
|
5903
6599
|
}
|
|
5904
|
-
async pushFullState(exported, snapshots) {
|
|
6600
|
+
async pushFullState(exported, snapshots, snoozes) {
|
|
5905
6601
|
for (const entry of exported.values()) try {
|
|
5906
|
-
const values = await this.currentStateFor(entry, snapshots[String(entry.deviceId)]);
|
|
6602
|
+
const values = await this.currentStateFor(entry, snapshots[String(entry.deviceId)], snoozes);
|
|
5907
6603
|
this.push(entry.deviceId, values);
|
|
5908
6604
|
} catch (err) {
|
|
5909
6605
|
this.ctx.logger.warn("ha-export: could not read current state for a device", {
|
|
@@ -5914,7 +6610,7 @@ var HaExportAddon = class extends BaseAddon {
|
|
|
5914
6610
|
for (const link of this.links.values()) await link.client.flush();
|
|
5915
6611
|
}
|
|
5916
6612
|
/** Everything readable without waiting for an event. */
|
|
5917
|
-
async currentStateFor(entry, snapshotRaw) {
|
|
6613
|
+
async currentStateFor(entry, snapshotRaw, snoozes) {
|
|
5918
6614
|
const key = entry.plan.deviceKey;
|
|
5919
6615
|
const values = [];
|
|
5920
6616
|
const snapshot = toSnapshot(snapshotRaw);
|
|
@@ -5984,13 +6680,26 @@ var HaExportAddon = class extends BaseAddon {
|
|
|
5984
6680
|
* video itself. The url this block used to publish was read by
|
|
5985
6681
|
* nobody — see `camera-entities.ts`.
|
|
5986
6682
|
*/
|
|
5987
|
-
|
|
5988
|
-
|
|
5989
|
-
|
|
5990
|
-
|
|
5991
|
-
|
|
5992
|
-
|
|
5993
|
-
|
|
6683
|
+
values.push(...projectCameraFunctions(key, entry.cameraFunctions ?? []));
|
|
6684
|
+
/**
|
|
6685
|
+
* `status` / `recording_active` (Traccia C) — off the SAME
|
|
6686
|
+
* `CameraStatus` `runReconcile` already read once for the whole pass
|
|
6687
|
+
* (Task C3). A camera the batch read did not cover (the call failed
|
|
6688
|
+
* entirely, or a camera added mid-pass) publishes NEITHER: an invented
|
|
6689
|
+
* `ok` is the D62 failure this entity exists to prevent.
|
|
6690
|
+
*/
|
|
6691
|
+
const cameraStatus = this.cameraStatusByDeviceId.get(entry.deviceId);
|
|
6692
|
+
if (cameraStatus !== void 0) values.push(...projectCameraStatus(key, toCameraStatusInput(cameraStatus)));
|
|
6693
|
+
/**
|
|
6694
|
+
* `snooze` (Task B1) — reconstructed from `notificationRules.listSnoozes`,
|
|
6695
|
+
* never stored: the select's state is the SAME authority the write path
|
|
6696
|
+
* (`applySnooze`) already uses.
|
|
6697
|
+
*/
|
|
6698
|
+
const snoozeValues = projectSnooze(key, latestDeviceSnoozeWindow(snoozes, entry.deviceId));
|
|
6699
|
+
if (snoozeValues.length === 0) {
|
|
6700
|
+
if (snoozes.filter((s) => s.scope === "device" && s.deviceId === entry.deviceId).length > 0) this.ctx.logger.debug("ha-export: an active snooze has a duration the select cannot express, publishing nothing", { tags: { deviceId: entry.deviceId } });
|
|
6701
|
+
}
|
|
6702
|
+
values.push(...snoozeValues);
|
|
5994
6703
|
const occupancy = await this.ctx.api.zoneAnalytics.getCurrentSnapshot.query({ deviceId: entry.deviceId });
|
|
5995
6704
|
if (occupancy !== null) values.push(...projectZoneOccupancy(key, {
|
|
5996
6705
|
zones: occupancy.zones.map((zone) => ({
|
|
@@ -6006,11 +6715,20 @@ var HaExportAddon = class extends BaseAddon {
|
|
|
6006
6715
|
}
|
|
6007
6716
|
return values;
|
|
6008
6717
|
}
|
|
6009
|
-
async buildCatalogDevice(device, snapshotRaw) {
|
|
6718
|
+
async buildCatalogDevice(device, snapshotRaw, cameraFunctions) {
|
|
6010
6719
|
const snapshot = toSnapshot(snapshotRaw);
|
|
6011
6720
|
const boundCaps = await this.loadBoundCaps(device.id);
|
|
6012
6721
|
const zones = device.type === "camera" ? await this.loadZones(device.id) : [];
|
|
6013
|
-
|
|
6722
|
+
/**
|
|
6723
|
+
* Every camera function, available or not — `entity-catalog.ts` is the
|
|
6724
|
+
* one place that filters to `available`, exactly as it did for
|
|
6725
|
+
* `getCameraSwitches`'s answer.
|
|
6726
|
+
*/
|
|
6727
|
+
const switches = (cameraFunctions ?? []).map((fn) => ({
|
|
6728
|
+
id: fn.id,
|
|
6729
|
+
label: fn.label,
|
|
6730
|
+
available: fn.available
|
|
6731
|
+
}));
|
|
6014
6732
|
const ptzPresets = device.type === "camera" && boundCaps.includes("ptz") ? await this.loadPresets(device.id) : [];
|
|
6015
6733
|
const streams = device.type === "camera" && boundCaps.includes("webrtc-session") ? await this.loadStreamChoices(device.id) : [];
|
|
6016
6734
|
const manufacturer = readString(device.metadata ?? {}, "manufacturer");
|
|
@@ -6096,25 +6814,118 @@ var HaExportAddon = class extends BaseAddon {
|
|
|
6096
6814
|
return [];
|
|
6097
6815
|
}
|
|
6098
6816
|
}
|
|
6099
|
-
|
|
6817
|
+
/**
|
|
6818
|
+
* `pipelineOrchestrator.getCameraStatuses`, for every exported camera in
|
|
6819
|
+
* ONE call (Task C3). Best-effort for the WHOLE batch: a failure here means
|
|
6820
|
+
* `status`, `recording_active` and every `broker-audio` control degrade for
|
|
6821
|
+
* this pass — never a value invented from a previous pass's answer.
|
|
6822
|
+
*/
|
|
6823
|
+
async readCameraStatuses(deviceIds) {
|
|
6824
|
+
if (deviceIds.length === 0) return /* @__PURE__ */ new Map();
|
|
6825
|
+
const startedAt = Date.now();
|
|
6100
6826
|
try {
|
|
6101
|
-
|
|
6102
|
-
|
|
6103
|
-
|
|
6104
|
-
|
|
6105
|
-
})
|
|
6827
|
+
const statuses = await this.ctx.api.pipelineOrchestrator.getCameraStatuses.query({ deviceIds: [...deviceIds] });
|
|
6828
|
+
this.ctx.logger.debug("ha-export: read camera statuses for the pass", { meta: {
|
|
6829
|
+
cameraStatusMs: Date.now() - startedAt,
|
|
6830
|
+
cameras: deviceIds.length
|
|
6831
|
+
} });
|
|
6832
|
+
return new Map(statuses.map((status) => [status.deviceId, status]));
|
|
6106
6833
|
} catch (err) {
|
|
6107
|
-
|
|
6108
|
-
|
|
6109
|
-
|
|
6110
|
-
|
|
6111
|
-
*/
|
|
6112
|
-
|
|
6834
|
+
this.ctx.logger.warn("ha-export: could not read camera statuses — status, recording_active and broker-audio degrade for every exported camera this pass", { meta: {
|
|
6835
|
+
error: errMsg(err),
|
|
6836
|
+
cameras: deviceIds.length
|
|
6837
|
+
} });
|
|
6838
|
+
return /* @__PURE__ */ new Map();
|
|
6839
|
+
}
|
|
6840
|
+
}
|
|
6841
|
+
/**
|
|
6842
|
+
* The eight per-camera function authorities (Task A1), assembled into
|
|
6843
|
+
* {@link CameraFunctionReads}. Every source degrades INDEPENDENTLY with a
|
|
6844
|
+
* WARN carrying `tags: { deviceId }` — a dropped authority must never look
|
|
6845
|
+
* like "this function does not exist" (D62).
|
|
6846
|
+
*
|
|
6847
|
+
* `bindableCapsByType`/`mutedDeviceIds` are the reconcile pass's shared
|
|
6848
|
+
* caches when the caller has them (a per-TYPE lookup and a flat list read
|
|
6849
|
+
* once for the whole pass); omitted, each is read fresh — the one-off
|
|
6850
|
+
* command path (`dispatch`) has no pass to share.
|
|
6851
|
+
*/
|
|
6852
|
+
async readCameraFunctions(input) {
|
|
6853
|
+
const { deviceId, deviceType, deviceDisabled, privacySlice, sourceNodeId } = input;
|
|
6854
|
+
const bindableCache = input.bindableCapsByType;
|
|
6855
|
+
let bindableCapNames = bindableCache?.get(deviceType);
|
|
6856
|
+
if (bindableCache === void 0 || bindableCapNames === void 0) {
|
|
6857
|
+
bindableCapNames = await this.ctx.api.deviceManager.listBindableCapsForDeviceType.query({ deviceType }).then((entries) => entries.filter((e) => e.wrappers.length > 0).map((e) => e.capName)).catch((err) => {
|
|
6858
|
+
this.ctx.logger.warn("ha-export: could not read bindable caps for a device type, camera functions degrade", {
|
|
6859
|
+
tags: { deviceId },
|
|
6860
|
+
meta: {
|
|
6861
|
+
deviceType,
|
|
6862
|
+
error: errMsg(err)
|
|
6863
|
+
}
|
|
6864
|
+
});
|
|
6865
|
+
return null;
|
|
6866
|
+
});
|
|
6867
|
+
bindableCache?.set(deviceType, bindableCapNames);
|
|
6868
|
+
}
|
|
6869
|
+
const wrapperAddonIdByCap = /* @__PURE__ */ new Map();
|
|
6870
|
+
const wrappedCapNames = await this.ctx.api.deviceManager.getBindings.query({ deviceId }).then((bindings) => {
|
|
6871
|
+
const active = [];
|
|
6872
|
+
for (const entry of bindings.entries) {
|
|
6873
|
+
if (entry.kind !== "wrapped") continue;
|
|
6874
|
+
active.push(entry.capName);
|
|
6875
|
+
if (entry.providerAddonId !== "") wrapperAddonIdByCap.set(entry.capName, entry.providerAddonId);
|
|
6876
|
+
}
|
|
6877
|
+
return active;
|
|
6878
|
+
}).catch((err) => {
|
|
6879
|
+
this.ctx.logger.warn("ha-export: could not read bindings, camera functions degrade", {
|
|
6113
6880
|
tags: { deviceId },
|
|
6114
6881
|
meta: { error: errMsg(err) }
|
|
6115
6882
|
});
|
|
6116
|
-
return
|
|
6117
|
-
}
|
|
6883
|
+
return null;
|
|
6884
|
+
});
|
|
6885
|
+
const recordingEnabled = await this.ctx.api.recording.getDeviceConfig.query({ deviceId }).then((config) => config.enabled).catch((err) => {
|
|
6886
|
+
this.ctx.logger.warn("ha-export: could not read recording config, the recording function degrades", {
|
|
6887
|
+
tags: { deviceId },
|
|
6888
|
+
meta: { error: errMsg(err) }
|
|
6889
|
+
});
|
|
6890
|
+
return null;
|
|
6891
|
+
});
|
|
6892
|
+
const mutedDeviceIds = input.mutedDeviceIds !== void 0 ? input.mutedDeviceIds : await this.ctx.api.notificationRules.listDeviceMutes.query({}).then((r) => r.mutedDeviceIds).catch((err) => {
|
|
6893
|
+
this.ctx.logger.warn("ha-export: could not read notification mutes, the notifications function degrades", {
|
|
6894
|
+
tags: { deviceId },
|
|
6895
|
+
meta: { error: errMsg(err) }
|
|
6896
|
+
});
|
|
6897
|
+
return null;
|
|
6898
|
+
});
|
|
6899
|
+
const notificationsMuted = mutedDeviceIds === null ? null : mutedDeviceIds.includes(deviceId);
|
|
6900
|
+
/**
|
|
6901
|
+
* The `privacy-mask` slice already in the snapshot this pass downloaded
|
|
6902
|
+
* (D224: a camera fact's freshness lives in the provider that owns it) —
|
|
6903
|
+
* zero new dialers to the camera.
|
|
6904
|
+
*/
|
|
6905
|
+
const privacy = privacySlice === void 0 ? null : {
|
|
6906
|
+
maskEnabled: privacySlice["enabled"] === true,
|
|
6907
|
+
audioEnabled: typeof privacySlice["audioEnabled"] === "boolean" ? privacySlice["audioEnabled"] : null
|
|
6908
|
+
};
|
|
6909
|
+
const brokerAudioMuted = sourceNodeId === null ? null : await this.ctx.api.streamBroker.getDeviceAudioMute.query({ deviceId }, nodePin(sourceNodeId)).then((r) => r.muted).catch((err) => {
|
|
6910
|
+
this.ctx.logger.warn("ha-export: could not read broker audio mute, the broker-audio function degrades", {
|
|
6911
|
+
tags: { deviceId },
|
|
6912
|
+
meta: {
|
|
6913
|
+
sourceNodeId,
|
|
6914
|
+
error: errMsg(err)
|
|
6915
|
+
}
|
|
6916
|
+
});
|
|
6917
|
+
return null;
|
|
6918
|
+
});
|
|
6919
|
+
return {
|
|
6920
|
+
deviceDisabled,
|
|
6921
|
+
bindableCapNames,
|
|
6922
|
+
wrappedCapNames,
|
|
6923
|
+
wrapperAddonIdByCap,
|
|
6924
|
+
recordingEnabled,
|
|
6925
|
+
notificationsMuted,
|
|
6926
|
+
privacy,
|
|
6927
|
+
brokerAudioMuted
|
|
6928
|
+
};
|
|
6118
6929
|
}
|
|
6119
6930
|
async loadPresets(deviceId) {
|
|
6120
6931
|
try {
|
|
@@ -6367,6 +7178,38 @@ var HaExportAddon = class extends BaseAddon {
|
|
|
6367
7178
|
* disagree with the admin UI is worse than no knob (D62).
|
|
6368
7179
|
*/
|
|
6369
7180
|
if (isSyntheticDeviceKey(parsed.deviceKey)) {
|
|
7181
|
+
/**
|
|
7182
|
+
* The global snooze select (Task D4) — the OTHER control this branch
|
|
7183
|
+
* routes, alongside the per-rule switches below. Same rule as the
|
|
7184
|
+
* camera's own snooze (`applySnooze`): the export writes
|
|
7185
|
+
* `createSnooze`/`cancelSnooze` directly, `scope: 'all'`, never a
|
|
7186
|
+
* store of its own.
|
|
7187
|
+
*/
|
|
7188
|
+
if (parsed.deviceKey === deviceKeyFor("synthetic-notification-center") && parsed.entity === "snooze") {
|
|
7189
|
+
if (!SNOOZE_OPTIONS.includes(value)) {
|
|
7190
|
+
this.ctx.logger.warn("ha-export: dropping a global snooze command, unknown option", { meta: {
|
|
7191
|
+
topic,
|
|
7192
|
+
value
|
|
7193
|
+
} });
|
|
7194
|
+
reply.status(422);
|
|
7195
|
+
reply.send({ error: "unroutable command" });
|
|
7196
|
+
return;
|
|
7197
|
+
}
|
|
7198
|
+
const minutes = SNOOZE_MINUTES[value] ?? null;
|
|
7199
|
+
try {
|
|
7200
|
+
await this.applyGlobalSnooze(minutes);
|
|
7201
|
+
reply.status(200);
|
|
7202
|
+
reply.send({});
|
|
7203
|
+
} catch (err) {
|
|
7204
|
+
this.ctx.logger.warn("ha-export: could not apply the global snooze from Home Assistant", { meta: {
|
|
7205
|
+
minutes,
|
|
7206
|
+
error: errMsg(err)
|
|
7207
|
+
} });
|
|
7208
|
+
reply.status(422);
|
|
7209
|
+
reply.send({ error: "command not applied" });
|
|
7210
|
+
}
|
|
7211
|
+
return;
|
|
7212
|
+
}
|
|
6370
7213
|
const ruleId = ruleIdFromEntity(parsed.entity, this.syntheticRules);
|
|
6371
7214
|
if (ruleId === null) {
|
|
6372
7215
|
this.ctx.logger.warn("ha-export: dropping a synthetic command with no authority behind it", { meta: {
|
|
@@ -6452,20 +7295,62 @@ var HaExportAddon = class extends BaseAddon {
|
|
|
6452
7295
|
return false;
|
|
6453
7296
|
}
|
|
6454
7297
|
switch (command.kind) {
|
|
6455
|
-
case "camera-
|
|
7298
|
+
case "camera-function": {
|
|
6456
7299
|
const parsed = CameraSwitchIdSchema.safeParse(command.switchId);
|
|
6457
7300
|
if (!parsed.success) {
|
|
6458
|
-
this.ctx.logger.warn("ha-export: dropping a command for an unknown camera
|
|
7301
|
+
this.ctx.logger.warn("ha-export: dropping a command for an unknown camera function", {
|
|
6459
7302
|
tags: { deviceId: command.deviceId },
|
|
6460
7303
|
meta: { switchId: command.switchId }
|
|
6461
7304
|
});
|
|
6462
7305
|
return false;
|
|
6463
7306
|
}
|
|
6464
|
-
|
|
7307
|
+
/**
|
|
7308
|
+
* A FRESH read for this one device — the pass-level caches are up to
|
|
7309
|
+
* `reconcileIntervalSec` old, and an availability decision that
|
|
7310
|
+
* writes belongs to the moment of the command, not the last poll.
|
|
7311
|
+
*/
|
|
7312
|
+
const device = await this.ctx.api.deviceManager.getDevice.query({ deviceId: command.deviceId });
|
|
7313
|
+
if (device === null) {
|
|
7314
|
+
this.ctx.logger.warn("ha-export: dropping a camera-function command, the device is not in the registry", { tags: { deviceId: command.deviceId } });
|
|
7315
|
+
return false;
|
|
7316
|
+
}
|
|
7317
|
+
const privacySlice = await this.ctx.api.deviceState.getCapSlice.query({
|
|
7318
|
+
deviceId: command.deviceId,
|
|
7319
|
+
capName: "privacy-mask"
|
|
7320
|
+
}).catch((err) => {
|
|
7321
|
+
this.ctx.logger.warn("ha-export: could not read the privacy slice for a camera-function command", {
|
|
7322
|
+
tags: { deviceId: command.deviceId },
|
|
7323
|
+
meta: { error: errMsg(err) }
|
|
7324
|
+
});
|
|
7325
|
+
return null;
|
|
7326
|
+
});
|
|
7327
|
+
/**
|
|
7328
|
+
* The source node comes from the last reconcile's `CameraStatus`
|
|
7329
|
+
* batch (Task C3) rather than a fresh per-command call: it changes
|
|
7330
|
+
* rarely, and only `broker-audio` needs it — a stale pin degrades
|
|
7331
|
+
* that ONE function for this command, not the other seven.
|
|
7332
|
+
*/
|
|
7333
|
+
const sourceNodeId = this.cameraStatusByDeviceId.get(command.deviceId)?.assignment.sourceNodeId ?? null;
|
|
7334
|
+
const reads = await this.readCameraFunctions({
|
|
6465
7335
|
deviceId: command.deviceId,
|
|
6466
|
-
|
|
6467
|
-
|
|
7336
|
+
deviceType: device.type,
|
|
7337
|
+
deviceDisabled: device.disabled,
|
|
7338
|
+
privacySlice: privacySlice ?? void 0,
|
|
7339
|
+
sourceNodeId
|
|
6468
7340
|
});
|
|
7341
|
+
const write = resolveCameraFunctionWrite(parsed.data, command.enabled, reads);
|
|
7342
|
+
if (write === null) {
|
|
7343
|
+
/**
|
|
7344
|
+
* Refused, never approximated. No optimistic ack: the next
|
|
7345
|
+
* reconcile corrects Home Assistant's toggle back to reality.
|
|
7346
|
+
*/
|
|
7347
|
+
this.ctx.logger.warn("ha-export: dropping a camera-function command, the function is not available", {
|
|
7348
|
+
tags: { deviceId: command.deviceId },
|
|
7349
|
+
meta: { switchId: parsed.data }
|
|
7350
|
+
});
|
|
7351
|
+
return false;
|
|
7352
|
+
}
|
|
7353
|
+
await applyCameraFunctionWrite(this.ctx.api, command.deviceId, write, sourceNodeId);
|
|
6469
7354
|
return true;
|
|
6470
7355
|
}
|
|
6471
7356
|
case "reboot": {
|
|
@@ -6529,19 +7414,69 @@ var HaExportAddon = class extends BaseAddon {
|
|
|
6529
7414
|
tags: { deviceId },
|
|
6530
7415
|
meta: { cancelled: mine.length }
|
|
6531
7416
|
});
|
|
6532
|
-
|
|
7417
|
+
} else {
|
|
7418
|
+
await this.ctx.api.notificationRules.createSnooze.mutate({ snooze: {
|
|
7419
|
+
scope: "device",
|
|
7420
|
+
deviceId,
|
|
7421
|
+
durationMinutes: minutes
|
|
7422
|
+
} });
|
|
7423
|
+
this.ctx.logger.info("ha-export: snoozed from Home Assistant", {
|
|
7424
|
+
tags: { deviceId },
|
|
7425
|
+
meta: { minutes }
|
|
7426
|
+
});
|
|
6533
7427
|
}
|
|
6534
|
-
|
|
6535
|
-
|
|
6536
|
-
|
|
6537
|
-
|
|
6538
|
-
|
|
6539
|
-
this.
|
|
6540
|
-
tags: { deviceId },
|
|
6541
|
-
meta: { minutes }
|
|
6542
|
-
});
|
|
7428
|
+
/**
|
|
7429
|
+
* Republish right away — the select is otherwise stale for up to
|
|
7430
|
+
* `reconcileIntervalSec` after a write Home Assistant itself made
|
|
7431
|
+
* (Task B1 passo 4).
|
|
7432
|
+
*/
|
|
7433
|
+
await this.republishSnooze(deviceId);
|
|
6543
7434
|
return true;
|
|
6544
7435
|
}
|
|
7436
|
+
/** Push `snooze`'s current state for one camera, outside the reconcile cadence. */
|
|
7437
|
+
async republishSnooze(deviceId) {
|
|
7438
|
+
const key = this.keyByDeviceId.get(deviceId);
|
|
7439
|
+
if (key === void 0) return;
|
|
7440
|
+
try {
|
|
7441
|
+
const { snoozes } = await this.ctx.api.notificationRules.listSnoozes.query({});
|
|
7442
|
+
this.push(deviceId, projectSnooze(key, latestDeviceSnoozeWindow(snoozes, deviceId)));
|
|
7443
|
+
} catch (err) {
|
|
7444
|
+
this.ctx.logger.warn("ha-export: could not republish snooze after a write from Home Assistant", {
|
|
7445
|
+
tags: { deviceId },
|
|
7446
|
+
meta: { error: errMsg(err) }
|
|
7447
|
+
});
|
|
7448
|
+
}
|
|
7449
|
+
}
|
|
7450
|
+
/**
|
|
7451
|
+
* The global `select` on *Notification Center* (Task D4) — `scope: 'all'`,
|
|
7452
|
+
* the same authority `applySnooze` already writes for a camera. `Off`
|
|
7453
|
+
* cancels every global snooze, exactly as a camera's `Off` cancels its own.
|
|
7454
|
+
*/
|
|
7455
|
+
async applyGlobalSnooze(minutes) {
|
|
7456
|
+
if (minutes === null) {
|
|
7457
|
+
const { snoozes } = await this.ctx.api.notificationRules.listSnoozes.query({});
|
|
7458
|
+
const mine = snoozes.filter((snooze) => snooze.scope === "all");
|
|
7459
|
+
for (const snooze of mine) await this.ctx.api.notificationRules.cancelSnooze.mutate({ snoozeId: snooze.id });
|
|
7460
|
+
this.ctx.logger.info("ha-export: cancelled the global snooze from Home Assistant", { meta: { cancelled: mine.length } });
|
|
7461
|
+
} else {
|
|
7462
|
+
await this.ctx.api.notificationRules.createSnooze.mutate({ snooze: {
|
|
7463
|
+
scope: "all",
|
|
7464
|
+
durationMinutes: minutes
|
|
7465
|
+
} });
|
|
7466
|
+
this.ctx.logger.info("ha-export: snoozed globally from Home Assistant", { meta: { minutes } });
|
|
7467
|
+
}
|
|
7468
|
+
await this.republishGlobalSnooze();
|
|
7469
|
+
}
|
|
7470
|
+
/** Push the global `snooze` select's current state, outside the reconcile cadence. */
|
|
7471
|
+
async republishGlobalSnooze() {
|
|
7472
|
+
try {
|
|
7473
|
+
const { snoozes } = await this.ctx.api.notificationRules.listSnoozes.query({});
|
|
7474
|
+
const ncKey = deviceKeyFor(NOTIFICATION_CENTER_STABLE_ID);
|
|
7475
|
+
await this.pushSynthetic(projectSnooze(ncKey, latestGlobalSnoozeWindow(snoozes)), this.enabledBrokerIds());
|
|
7476
|
+
} catch (err) {
|
|
7477
|
+
this.ctx.logger.warn("ha-export: could not republish the global snooze after a write from Home Assistant", { meta: { error: errMsg(err) } });
|
|
7478
|
+
}
|
|
7479
|
+
}
|
|
6545
7480
|
/**
|
|
6546
7481
|
* A public, signed, expiring route Home Assistant can fetch bytes from.
|
|
6547
7482
|
*
|
|
@@ -6934,6 +7869,37 @@ function toSnapshot(raw) {
|
|
|
6934
7869
|
for (const [cap, slice] of Object.entries(raw)) if (slice !== null && typeof slice === "object") out[cap] = { ...slice };
|
|
6935
7870
|
return out;
|
|
6936
7871
|
}
|
|
7872
|
+
/** `CameraStatus` → the narrower shape `projectCameraStatus` needs. */
|
|
7873
|
+
function toCameraStatusInput(status) {
|
|
7874
|
+
return {
|
|
7875
|
+
switchedOff: status.switchedOff,
|
|
7876
|
+
degradedStages: status.degraded.map((d) => d.stage),
|
|
7877
|
+
recording: status.recording === null ? null : { active: status.recording.active }
|
|
7878
|
+
};
|
|
7879
|
+
}
|
|
7880
|
+
/**
|
|
7881
|
+
* The ACTIVE window for one scope, from a flat `listSnoozes` answer.
|
|
7882
|
+
*
|
|
7883
|
+
* "Active" is `expiresAt` in the future — `listSnoozes` never sweeps expired
|
|
7884
|
+
* rows (expiry is a comparison, not a job), so a reader that skipped this
|
|
7885
|
+
* would keep publishing a duration that ended hours ago. When several windows
|
|
7886
|
+
* match, the one expiring LATEST wins: that is the duration still in effect.
|
|
7887
|
+
*/
|
|
7888
|
+
function latestSnoozeWindow(snoozes, matches, now) {
|
|
7889
|
+
const latest = snoozes.filter((s) => matches(s) && s.expiresAt > now).reduce((top, s) => top === null || s.expiresAt > top.expiresAt ? s : top, null);
|
|
7890
|
+
return latest === null ? null : {
|
|
7891
|
+
startedAt: latest.startedAt,
|
|
7892
|
+
expiresAt: latest.expiresAt
|
|
7893
|
+
};
|
|
7894
|
+
}
|
|
7895
|
+
/** The camera's own `scope: 'device'` snoozes (Task B1). */
|
|
7896
|
+
function latestDeviceSnoozeWindow(snoozes, deviceId) {
|
|
7897
|
+
return latestSnoozeWindow(snoozes, (s) => s.scope === "device" && s.deviceId === deviceId, Date.now());
|
|
7898
|
+
}
|
|
7899
|
+
/** The global `scope: 'all'` snooze (Task D4). */
|
|
7900
|
+
function latestGlobalSnoozeWindow(snoozes) {
|
|
7901
|
+
return latestSnoozeWindow(snoozes, (s) => s.scope === "all", Date.now());
|
|
7902
|
+
}
|
|
6937
7903
|
function toCountRecord(raw) {
|
|
6938
7904
|
if (raw === null || typeof raw !== "object") return {};
|
|
6939
7905
|
const out = {};
|