@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.
@@ -3,7 +3,7 @@ Object.defineProperties(exports, {
3
3
  [Symbol.toStringTag]: { value: "Module" }
4
4
  });
5
5
  //#endregion
6
- const require_dist = require("../dist-Cef7D9kq.js");
6
+ const require_dist = require("../dist-B1grYZFh.js");
7
7
  let node_crypto = require("node:crypto");
8
8
  //#region src/ha-export/topics.ts
9
9
  /**
@@ -1257,9 +1257,14 @@ function cameraSpecs(device) {
1257
1257
  }),
1258
1258
  (
1259
1259
  /**
1260
- * What was heard, and how loud. Fed by `pipeline.audio-inference-result`
1261
- * the event this addon did not subscribe to at all, which is why
1262
- * `audio_detected` below has effectively never moved.
1260
+ * What was heard, and how loud. Both fed by `pipeline.audio-inference-result`
1261
+ * via `projectAudioWindow` which is also where `audio_detected` (the
1262
+ * `audio` row of the macro loop below) gets its value now. Until this was
1263
+ * fixed, `audio_detected` was fed only by the track lifecycle, which
1264
+ * almost never carries an audio macro, so the entity existed and
1265
+ * effectively never moved: a comment right here used to claim this window
1266
+ * was already its source, and it was not — an avanzo that described the
1267
+ * right design while being false.
1263
1268
  *
1264
1269
  * `audio_last_sound`, NOT `audio_last_label`: `<macro>_last_label` is the
1265
1270
  * TRACK path's naming, reserved for the macros that carry an
@@ -1338,8 +1343,10 @@ function cameraSpecs(device) {
1338
1343
  enabledByDefault: false
1339
1344
  });
1340
1345
  /**
1341
- * One HA switch per AVAILABLE camera switch, rendered from
1342
- * `getCameraSwitches` and commanded back through `setCameraSwitch`.
1346
+ * One HA switch per AVAILABLE camera function, rendered from
1347
+ * `deriveCameraFunctions` and commanded back through
1348
+ * `resolveCameraFunctionWrite` + `applyCameraFunctionWrite` — each straight
1349
+ * onto the authority that already owns it (D62, D113 phase 4).
1343
1350
  *
1344
1351
  * The export never writes an authority itself: a second knob that can
1345
1352
  * disagree with the admin UI is worse than no knob (D62). An
@@ -1435,6 +1442,30 @@ function cameraSpecs(device) {
1435
1442
  entityCategory: "diagnostic",
1436
1443
  enabledByDefault: false
1437
1444
  });
1445
+ /**
1446
+ * `status` — the entity D62 exists for outside this repo. A camera an
1447
+ * operator switched off must never paint the same picture in Home
1448
+ * Assistant as one that is genuinely broken (`projectCameraStatus`).
1449
+ *
1450
+ * No `device_class`: HA's `enum` device class demands `options`, and a
1451
+ * `select` here would be a control that cannot be written — the dead
1452
+ * control this whole export refuses to ship.
1453
+ */
1454
+ specs.push({
1455
+ entity: "status",
1456
+ platform: "sensor",
1457
+ label: "Status",
1458
+ icon: "mdi:cctv",
1459
+ enabledByDefault: true
1460
+ });
1461
+ /** «Is it recording right now?» — `CameraStatus.recording.active`. */
1462
+ specs.push({
1463
+ entity: "recording_active",
1464
+ platform: "binary_sensor",
1465
+ label: "Recording",
1466
+ deviceClass: "running",
1467
+ enabledByDefault: true
1468
+ });
1438
1469
  specs.push({
1439
1470
  entity: "snooze",
1440
1471
  platform: "select",
@@ -2692,7 +2723,7 @@ var CAPS_NOT_EXPORTED = [
2692
2723
  },
2693
2724
  {
2694
2725
  cap: "privacy-mask",
2695
- reason: "Rendered as a camera switch through getCameraSwitches, never written directly (D62)."
2726
+ reason: "Rendered as two camera function switches (privacy-mask, device-audio) through deriveCameraFunctions, never written directly (D62, D113 phase 4)."
2696
2727
  },
2697
2728
  {
2698
2729
  cap: "zone-analytics",
@@ -2948,17 +2979,8 @@ function buildDevicePlan(device, options = UNNEGOTIATED_PLAN_OPTIONS) {
2948
2979
  * refusal carries a named reason so the log line says which of those it
2949
2980
  * was.
2950
2981
  */
2951
- /** The camera switch ids, keyed by the entity slug the catalog emits. */
2952
- var CAMERA_SWITCH_BY_SLUG = Object.fromEntries([
2953
- "stream-broker",
2954
- "object-detection",
2955
- "privacy-mask",
2956
- "device-audio",
2957
- "broker-audio",
2958
- "audio-analysis",
2959
- "recording",
2960
- "notifications"
2961
- ].map((id) => [toSlug(id), id]));
2982
+ /** The camera function ids, keyed by the entity slug the catalog emits. */
2983
+ var CAMERA_SWITCH_BY_SLUG = Object.fromEntries(require_dist.CAMERA_SWITCH_ORDER.map((id) => [toSlug(id), id]));
2962
2984
  var PTZ_DIRECTION_BY_ENTITY = Object.fromEntries(PTZ_BUTTONS.map((entity) => [entity, entity.slice(4)]));
2963
2985
  function parseBool(value) {
2964
2986
  const lower = value.trim().toLowerCase();
@@ -3543,7 +3565,7 @@ function resolveCommand(target, entity, value) {
3543
3565
  return {
3544
3566
  ok: true,
3545
3567
  command: {
3546
- kind: "camera-switch",
3568
+ kind: "camera-function",
3547
3569
  deviceId: target.deviceId,
3548
3570
  switchId,
3549
3571
  enabled
@@ -3703,7 +3725,7 @@ function build(builder, deviceId, value, capName) {
3703
3725
  */
3704
3726
  function isCameraCommand(command) {
3705
3727
  switch (command.kind) {
3706
- case "camera-switch":
3728
+ case "camera-function":
3707
3729
  case "reboot":
3708
3730
  case "ptz-move":
3709
3731
  case "ptz-preset":
@@ -3942,6 +3964,187 @@ async function call(slice, capName, make) {
3942
3964
  }
3943
3965
  return { ok: true };
3944
3966
  }
3967
+ //#endregion
3968
+ //#region src/ha-export/camera-function-writes.ts
3969
+ /**
3970
+ * Esegue la SCRITTURA di una funzione camera sull'autorità che la possiede.
3971
+ *
3972
+ * `deriveCameraFunctions`/`resolveCameraFunctionWrite` (`camera-functions.ts`)
3973
+ * decidono; questo modulo agisce. È il file che
3974
+ * `scripts/check-switch-successor-surfaces.ts` punta come successore della
3975
+ * coppia deprecata `getCameraSwitches` / `setCameraSwitch` (D113 fase 4).
3976
+ */
3977
+ async function applyCameraFunctionWrite(api, deviceId, write, sourceNodeId) {
3978
+ switch (write.kind) {
3979
+ case "device-disabled":
3980
+ await api.deviceManager.setDisabled.mutate({
3981
+ deviceId,
3982
+ disabled: write.disabled
3983
+ });
3984
+ return;
3985
+ case "wrapper-binding":
3986
+ await api.deviceManager.setWrapperActive.mutate({
3987
+ deviceId,
3988
+ capName: write.capName,
3989
+ wrapperAddonId: write.wrapperAddonId,
3990
+ active: write.active
3991
+ });
3992
+ return;
3993
+ case "recording-enabled": {
3994
+ const config = await api.recording.getDeviceConfig.query({ deviceId });
3995
+ await api.recording.setDeviceConfig.mutate({
3996
+ deviceId,
3997
+ config: {
3998
+ ...config,
3999
+ enabled: write.enabled
4000
+ }
4001
+ });
4002
+ return;
4003
+ }
4004
+ case "notification-mute":
4005
+ await api.notificationRules.setDeviceMuted.mutate({
4006
+ deviceId,
4007
+ muted: write.muted
4008
+ });
4009
+ return;
4010
+ case "camera-mask":
4011
+ await api.privacyMask.setMask.mutate({
4012
+ deviceId,
4013
+ patch: { enabled: write.enabled }
4014
+ });
4015
+ return;
4016
+ case "camera-audio":
4017
+ await api.privacyMask.setAudioEnabled.mutate({
4018
+ deviceId,
4019
+ enabled: write.enabled
4020
+ });
4021
+ return;
4022
+ case "broker-audio-mute":
4023
+ if (sourceNodeId === null) throw new Error(`refusing an unrouted broker audio write for device ${String(deviceId)}: no source node`);
4024
+ await api.streamBroker.setDeviceAudioMute.mutate({
4025
+ deviceId,
4026
+ muted: write.muted
4027
+ }, require_dist.nodePin(sourceNodeId));
4028
+ return;
4029
+ }
4030
+ }
4031
+ //#endregion
4032
+ //#region src/ha-export/camera-functions.ts
4033
+ var DETECTION_CAP = "detection-pipeline";
4034
+ var AUDIO_ANALYSIS_CAP = "audio-analysis";
4035
+ /** Ordine di resa: raggio d'azione più ampio prima, sorgente prima del consumatore. */
4036
+ var ORDER = [
4037
+ "stream-broker",
4038
+ "object-detection",
4039
+ "privacy-mask",
4040
+ "device-audio",
4041
+ "broker-audio",
4042
+ "audio-analysis",
4043
+ "recording",
4044
+ "notifications"
4045
+ ];
4046
+ var LABELS = {
4047
+ "stream-broker": "Camera",
4048
+ "object-detection": "Object detection & tracking",
4049
+ "privacy-mask": "Privacy mask",
4050
+ "device-audio": "Camera microphone",
4051
+ "broker-audio": "Audio distribution",
4052
+ "audio-analysis": "Audio detection & classification",
4053
+ recording: "Recording",
4054
+ notifications: "Notifications"
4055
+ };
4056
+ var UNAVAILABLE = {
4057
+ available: false,
4058
+ enabled: true
4059
+ };
4060
+ function resolveWrapper(capName, reads) {
4061
+ if (reads.bindableCapNames === null || reads.wrappedCapNames === null) return UNAVAILABLE;
4062
+ if (!reads.bindableCapNames.includes(capName)) return UNAVAILABLE;
4063
+ if (reads.wrapperAddonIdByCap.get(capName) === void 0) return UNAVAILABLE;
4064
+ return {
4065
+ available: true,
4066
+ enabled: reads.wrappedCapNames.includes(capName)
4067
+ };
4068
+ }
4069
+ function resolveOne(id, reads) {
4070
+ switch (id) {
4071
+ case "stream-broker": return reads.deviceDisabled === null ? UNAVAILABLE : {
4072
+ available: true,
4073
+ enabled: !reads.deviceDisabled
4074
+ };
4075
+ case "object-detection": return resolveWrapper(DETECTION_CAP, reads);
4076
+ case "audio-analysis": return resolveWrapper(AUDIO_ANALYSIS_CAP, reads);
4077
+ case "recording": return reads.recordingEnabled === null ? UNAVAILABLE : {
4078
+ available: true,
4079
+ enabled: reads.recordingEnabled
4080
+ };
4081
+ case "notifications": return reads.notificationsMuted === null ? UNAVAILABLE : {
4082
+ available: true,
4083
+ enabled: !reads.notificationsMuted
4084
+ };
4085
+ case "privacy-mask": return reads.privacy === null ? UNAVAILABLE : {
4086
+ available: true,
4087
+ enabled: reads.privacy.maskEnabled
4088
+ };
4089
+ case "device-audio": return reads.privacy === null || reads.privacy.audioEnabled === null ? UNAVAILABLE : {
4090
+ available: true,
4091
+ enabled: reads.privacy.audioEnabled
4092
+ };
4093
+ case "broker-audio": return reads.brokerAudioMuted === null ? UNAVAILABLE : {
4094
+ available: true,
4095
+ enabled: !reads.brokerAudioMuted
4096
+ };
4097
+ }
4098
+ }
4099
+ function deriveCameraFunctions(reads) {
4100
+ return ORDER.map((id) => ({
4101
+ id,
4102
+ label: LABELS[id],
4103
+ ...resolveOne(id, reads)
4104
+ }));
4105
+ }
4106
+ /** `null` = non disponibile: la scrittura va RIFIUTATA, non approssimata. */
4107
+ function resolveCameraFunctionWrite(id, on, reads) {
4108
+ if (!resolveOne(id, reads).available) return null;
4109
+ switch (id) {
4110
+ case "stream-broker": return {
4111
+ kind: "device-disabled",
4112
+ disabled: !on
4113
+ };
4114
+ case "object-detection":
4115
+ case "audio-analysis": {
4116
+ const capName = id === "object-detection" ? DETECTION_CAP : AUDIO_ANALYSIS_CAP;
4117
+ const wrapperAddonId = reads.wrapperAddonIdByCap.get(capName);
4118
+ if (wrapperAddonId === void 0) return null;
4119
+ return {
4120
+ kind: "wrapper-binding",
4121
+ capName,
4122
+ wrapperAddonId,
4123
+ active: on
4124
+ };
4125
+ }
4126
+ case "recording": return {
4127
+ kind: "recording-enabled",
4128
+ enabled: on
4129
+ };
4130
+ case "notifications": return {
4131
+ kind: "notification-mute",
4132
+ muted: !on
4133
+ };
4134
+ case "privacy-mask": return {
4135
+ kind: "camera-mask",
4136
+ enabled: on
4137
+ };
4138
+ case "device-audio": return {
4139
+ kind: "camera-audio",
4140
+ enabled: on
4141
+ };
4142
+ case "broker-audio": return {
4143
+ kind: "broker-audio-mute",
4144
+ muted: !on
4145
+ };
4146
+ }
4147
+ }
3945
4148
  /**
3946
4149
  * The ONE derivation of "a signed, expiring URL".
3947
4150
  *
@@ -4687,16 +4890,18 @@ function projectBatterySlice(deviceKey, slice) {
4687
4890
  }
4688
4891
  /**
4689
4892
  * The per-camera function switches, mirrored 1:1 from
4690
- * `pipelineOrchestrator.getCameraSwitches`.
4893
+ * `deriveCameraFunctions` (`camera-functions.ts`) — the pure derivation over
4894
+ * the AUTHORITIES that own each function, replacing the deprecated
4895
+ * `pipelineOrchestrator.getCameraSwitches` group (D113 phase 4).
4691
4896
  *
4692
- * An UNAVAILABLE switch publishes nothing, because `enabled` is
4897
+ * An UNAVAILABLE function publishes nothing, because `enabled` is
4693
4898
  * meaningless when `available` is false — a source that did not answer
4694
4899
  * must never become a control rendered `on`.
4695
4900
  */
4696
- function projectCameraSwitches(deviceKey, switches) {
4697
- return switches.filter((sw) => sw.available).map((sw) => ({
4698
- topic: stateTopic(deviceKey, toSlug(sw.id)),
4699
- value: bool(sw.enabled)
4901
+ function projectCameraFunctions(deviceKey, functions) {
4902
+ return functions.filter((fn) => fn.available).map((fn) => ({
4903
+ topic: stateTopic(deviceKey, toSlug(fn.id)),
4904
+ value: bool(fn.enabled)
4700
4905
  }));
4701
4906
  }
4702
4907
  /**
@@ -4762,12 +4967,88 @@ function projectAudioWindow(deviceKey, input) {
4762
4967
  value: String(Math.round(input.rms * 1e4) / 1e4)
4763
4968
  });
4764
4969
  const best = input.detections.reduce((top, d) => top === null || d.confidence > top.confidence ? d : top, null);
4970
+ /**
4971
+ * `audio_detected` — the source it never had. The catalog's comment used
4972
+ * to claim this window was already feeding it; it was not (grep found no
4973
+ * publisher), and that comment was a leftover describing the right design
4974
+ * while being false. This window is the fix: it raises on any window that
4975
+ * carries a classification and lowers on a quiet one, WITHOUT clearing
4976
+ * `audio_last_sound` — "what was that noise" is asked after the noise has
4977
+ * stopped.
4978
+ */
4979
+ values.push({
4980
+ topic: stateTopic(deviceKey, "audio_detected"),
4981
+ value: bool(best !== null)
4982
+ });
4765
4983
  if (best !== null) values.push({
4766
4984
  topic: stateTopic(deviceKey, "audio_last_sound"),
4767
4985
  value: best.className
4768
4986
  });
4769
4987
  return values;
4770
4988
  }
4989
+ /** Inverse of `SNOOZE_MINUTES` — a minute count back to the option label. */
4990
+ var SNOOZE_OPTION_BY_MINUTES = new Map(Object.entries(SNOOZE_MINUTES).map(([option, minutes]) => [minutes, option]));
4991
+ /**
4992
+ * The `snooze` select's STATE. The authority is
4993
+ * `notificationRules.listSnoozes`, which carries `startedAt`/`expiresAt` but
4994
+ * not the preset label an operator picked — there is nowhere to store that
4995
+ * label, and there must not be: it is reconstructed from the two timestamps
4996
+ * every time, so it can never drift from the authority.
4997
+ *
4998
+ * No active snooze publishes `Off`, never silence: Home Assistant reads a
4999
+ * missing state update as "the last value still holds", so a select gone
5000
+ * quiet after a snooze expires would keep showing the expired duration
5001
+ * forever.
5002
+ *
5003
+ * A duration the select's closed vocabulary cannot express — a snooze the
5004
+ * viewer created with an arbitrary minute count — publishes NOTHING.
5005
+ * Rounding it to the nearest preset would let Home Assistant re-send a
5006
+ * duration the operator never chose; `unknown` is the honest state.
5007
+ */
5008
+ function projectSnooze(deviceKey, window) {
5009
+ if (window === null) return [{
5010
+ topic: stateTopic(deviceKey, "snooze"),
5011
+ value: "Off"
5012
+ }];
5013
+ const minutes = Math.round((window.expiresAt - window.startedAt) / 6e4);
5014
+ const option = SNOOZE_OPTION_BY_MINUTES.get(minutes);
5015
+ if (option === void 0) return [];
5016
+ return [{
5017
+ topic: stateTopic(deviceKey, "snooze"),
5018
+ value: option
5019
+ }];
5020
+ }
5021
+ /**
5022
+ * `status` — the entity D62 exists for. A camera an operator switched off and
5023
+ * a camera that is genuinely broken must never paint the same picture in Home
5024
+ * Assistant: this is what tells the two apart.
5025
+ *
5026
+ * Order matters: the `switches` stage failing to answer makes the WHOLE
5027
+ * badge unknown, because an empty `switchedOff` is only a positive claim when
5028
+ * `degraded` does not name `switches` — reporting `ok` here would be the D62
5029
+ * failure landing inside the very field written to prevent it.
5030
+ */
5031
+ function projectCameraStatus(deviceKey, input) {
5032
+ const values = [{
5033
+ topic: stateTopic(deviceKey, "status"),
5034
+ value: cameraStatusToken(input)
5035
+ }];
5036
+ if (input.recording !== null) values.push({
5037
+ topic: stateTopic(deviceKey, "recording_active"),
5038
+ value: bool(input.recording.active)
5039
+ });
5040
+ else if (!input.degradedStages.includes("recording")) values.push({
5041
+ topic: stateTopic(deviceKey, "recording_active"),
5042
+ value: bool(false)
5043
+ });
5044
+ return values;
5045
+ }
5046
+ function cameraStatusToken(input) {
5047
+ if (input.degradedStages.includes("switches")) return "unknown";
5048
+ if (input.switchedOff.length > 0) return "switched_off";
5049
+ if (input.degradedStages.length > 0) return "degraded";
5050
+ return "ok";
5051
+ }
4771
5052
  //#endregion
4772
5053
  //#region src/ha-export/synthetic-devices.ts
4773
5054
  /**
@@ -4808,6 +5089,25 @@ function isSyntheticDeviceKey(deviceKey) {
4808
5089
  return SYNTHETIC_STABLE_IDS.some((id) => deviceKeyFor(id) === deviceKey);
4809
5090
  }
4810
5091
  /**
5092
+ * Pure derivation of the three alert entities (Task D1) — the conditions
5093
+ * nobody said, surfaced from `alerts.list`.
5094
+ *
5095
+ * Counts and raises the badge ONLY on `status: 'active'` alerts whose
5096
+ * `severity` is `warning` or `error`. `info` moves neither: a badge that
5097
+ * lights up on `info` teaches an operator to ignore it, which is how the
5098
+ * 2026-08-08 four-hour blackout produced not one visible alert despite the
5099
+ * liveness monitor's findings running the whole time.
5100
+ */
5101
+ function deriveAlertSummary(alerts) {
5102
+ const relevant = alerts.filter((a) => a.status === "active" && (a.severity === "warning" || a.severity === "error"));
5103
+ const last = relevant.reduce((top, a) => top === null || a.updatedAt > top.updatedAt ? a : top, null);
5104
+ return {
5105
+ active: relevant.length > 0,
5106
+ count: relevant.length,
5107
+ lastTitle: last?.title ?? null
5108
+ };
5109
+ }
5110
+ /**
4811
5111
  * Everything a synthetic device exports arrives ENABLED.
4812
5112
  *
4813
5113
  * The pressure valve exists for the camera fan-out — three zones is ~73
@@ -4880,6 +5180,79 @@ function notificationCenterSpecs(input) {
4880
5180
  icon: "mdi:counter",
4881
5181
  entityCategory: "diagnostic"
4882
5182
  },
5183
+ (
5184
+ /**
5185
+ * The GLOBAL snooze, as a control (Task D4) — the half `snoozed` (above)
5186
+ * never had. Its state is NOT projected here: `ha-export.addon.ts` pushes
5187
+ * it through the SAME `projectSnooze` a camera's snooze uses, so the rule
5188
+ * lives in one place — two expressions of it in two files is how
5189
+ * `audio_detected` went mute for a year.
5190
+ */
5191
+ {
5192
+ entity: "snooze",
5193
+ platform: "select",
5194
+ label: "Snooze all notifications",
5195
+ writable: true,
5196
+ options: SNOOZE_OPTIONS
5197
+ }),
5198
+ (
5199
+ /**
5200
+ * The last trigger, six entities: five facts plus its picture — "conoscere
5201
+ * gli ultimi trigger, le ultime immagini... e da quale label" (operator,
5202
+ * 2026-08-25). ENABLED by default like every other entity on this device
5203
+ * (see {@link buildComponents}'s docblock): a rule the operator wrote is a
5204
+ * rule they want to watch fire.
5205
+ *
5206
+ * Fixed count regardless of camera fleet size — the fan-out this design
5207
+ * explicitly avoids (960 entities at 30 cameras × 32 rules) lives ONLY in
5208
+ * the per-rule switches below, which already existed. These six are on
5209
+ * ONE synthetic device and never repeat per camera or per rule.
5210
+ */
5211
+ {
5212
+ entity: "last_trigger_at",
5213
+ platform: "sensor",
5214
+ label: "Last trigger",
5215
+ deviceClass: "timestamp"
5216
+ }),
5217
+ {
5218
+ entity: "last_trigger_rule",
5219
+ platform: "sensor",
5220
+ label: "Last trigger rule",
5221
+ icon: "mdi:bell-ring-outline"
5222
+ },
5223
+ {
5224
+ entity: "last_trigger_camera",
5225
+ platform: "sensor",
5226
+ label: "Last trigger camera",
5227
+ icon: "mdi:cctv"
5228
+ },
5229
+ (
5230
+ /** The identified label, or the detected class when none was resolved —
5231
+ * see {@link SyntheticLastTrigger.label}. Never blank. */
5232
+ {
5233
+ entity: "last_trigger_label",
5234
+ platform: "sensor",
5235
+ label: "Last trigger label",
5236
+ icon: "mdi:tag-outline"
5237
+ }),
5238
+ (
5239
+ /** `pending` / `sent` / `dead` — the outbox row's own status, straight
5240
+ * from `getHistory`, never a second delivery ledger. */
5241
+ {
5242
+ entity: "last_trigger_status",
5243
+ platform: "sensor",
5244
+ label: "Last trigger delivery status",
5245
+ icon: "mdi:send-check-outline",
5246
+ entityCategory: "diagnostic"
5247
+ }),
5248
+ (
5249
+ /** See {@link SyntheticLastTrigger.imageUrl} for the expired/unreachable
5250
+ * degrade: no value is published rather than a link that 404s. */
5251
+ {
5252
+ entity: "last_trigger_image",
5253
+ platform: "image",
5254
+ label: "Last trigger image"
5255
+ }),
4883
5256
  ...input.rules.map((rule) => ({
4884
5257
  entity: ruleEntity(rule.id),
4885
5258
  platform: "switch",
@@ -4941,6 +5314,56 @@ function serverSpecs(input) {
4941
5314
  deviceClass: "update",
4942
5315
  entityCategory: "diagnostic"
4943
5316
  },
5317
+ (
5318
+ /**
5319
+ * The conditions nobody said (Task D1) — `alerts.list`'s active,
5320
+ * warning-or-error findings. `info` never raises `alerts_active` or
5321
+ * inflates the count: a badge that lights up on `info` teaches an
5322
+ * operator to ignore it, which is how the four-hour blackout stayed
5323
+ * quiet through every liveness finding it produced.
5324
+ */
5325
+ {
5326
+ entity: "alerts_active",
5327
+ platform: "binary_sensor",
5328
+ label: "Alerts active",
5329
+ deviceClass: "problem"
5330
+ }),
5331
+ {
5332
+ entity: "alerts_active_count",
5333
+ platform: "sensor",
5334
+ label: "Active alerts",
5335
+ icon: "mdi:alert"
5336
+ },
5337
+ {
5338
+ entity: "alerts_last_title",
5339
+ platform: "sensor",
5340
+ label: "Last active alert",
5341
+ icon: "mdi:alert-circle-outline",
5342
+ entityCategory: "diagnostic"
5343
+ },
5344
+ (
5345
+ /** The oldest footage across the whole cluster (Task D2). */
5346
+ {
5347
+ entity: "oldest_footage",
5348
+ platform: "sensor",
5349
+ label: "Oldest footage",
5350
+ deviceClass: "timestamp",
5351
+ entityCategory: "diagnostic"
5352
+ }),
5353
+ (
5354
+ /**
5355
+ * The seed-vs-contract verdict (Task D3, D3's `image_contract`) — the
5356
+ * 2026-08-02 guasto, where `runningVersion` was current while the
5357
+ * container image was weeks old from a renamed repository nothing
5358
+ * rebuilt. Hub only: it describes THIS process's own image.
5359
+ */
5360
+ {
5361
+ entity: "image_contract",
5362
+ platform: "sensor",
5363
+ label: "Image contract",
5364
+ icon: "mdi:package-variant-closed-check",
5365
+ entityCategory: "diagnostic"
5366
+ }),
4944
5367
  ...input.nodes.flatMap((node) => {
4945
5368
  const slug = toSlug(node.id);
4946
5369
  return [
@@ -4969,7 +5392,38 @@ function serverSpecs(input) {
4969
5392
  platform: "sensor",
4970
5393
  label: `${node.name} uptime`,
4971
5394
  deviceClass: "timestamp"
4972
- }
5395
+ },
5396
+ (
5397
+ /** The disk (Task D2) — every node, hub included: every node records. */
5398
+ {
5399
+ entity: `${slug}_footage_used_bytes`,
5400
+ platform: "sensor",
5401
+ label: `${node.name} footage used`,
5402
+ deviceClass: "data_size",
5403
+ unit: "B",
5404
+ entityCategory: "diagnostic"
5405
+ }),
5406
+ {
5407
+ entity: `${slug}_footage_free_percent`,
5408
+ platform: "sensor",
5409
+ label: `${node.name} footage free`,
5410
+ unit: "%",
5411
+ icon: "mdi:harddisk",
5412
+ entityCategory: "diagnostic"
5413
+ },
5414
+ ...node.isHub ? [] : [{
5415
+ entity: `${slug}_version`,
5416
+ platform: "sensor",
5417
+ label: `${node.name} version`,
5418
+ icon: "mdi:tag",
5419
+ entityCategory: "diagnostic"
5420
+ }, {
5421
+ entity: `${slug}_update_available`,
5422
+ platform: "binary_sensor",
5423
+ label: `${node.name} update available`,
5424
+ deviceClass: "update",
5425
+ entityCategory: "diagnostic"
5426
+ }]
4973
5427
  ];
4974
5428
  })
4975
5429
  ];
@@ -5034,8 +5488,51 @@ function projectSynthetic(input, nowMs) {
5034
5488
  {
5035
5489
  topic: stateTopic(srv, "update_available"),
5036
5490
  value: String(input.updateAvailable)
5491
+ },
5492
+ {
5493
+ topic: stateTopic(srv, "alerts_active"),
5494
+ value: String(input.alertsActive)
5495
+ },
5496
+ {
5497
+ topic: stateTopic(srv, "alerts_active_count"),
5498
+ value: String(input.alertsActiveCount)
5037
5499
  }
5038
5500
  ];
5501
+ if (input.lastTrigger !== null) {
5502
+ const t = input.lastTrigger;
5503
+ values.push({
5504
+ topic: stateTopic(nc, "last_trigger_at"),
5505
+ value: new Date(t.at).toISOString()
5506
+ }, {
5507
+ topic: stateTopic(nc, "last_trigger_rule"),
5508
+ value: t.ruleName
5509
+ }, {
5510
+ topic: stateTopic(nc, "last_trigger_camera"),
5511
+ value: t.deviceName
5512
+ }, {
5513
+ topic: stateTopic(nc, "last_trigger_label"),
5514
+ value: t.label
5515
+ }, {
5516
+ topic: stateTopic(nc, "last_trigger_status"),
5517
+ value: t.status
5518
+ });
5519
+ if (t.imageUrl !== null) values.push({
5520
+ topic: stateTopic(nc, "last_trigger_image"),
5521
+ value: t.imageUrl
5522
+ });
5523
+ }
5524
+ if (input.alertsLastTitle !== null) values.push({
5525
+ topic: stateTopic(srv, "alerts_last_title"),
5526
+ value: input.alertsLastTitle
5527
+ });
5528
+ if (input.oldestFootageMs !== null) values.push({
5529
+ topic: stateTopic(srv, "oldest_footage"),
5530
+ value: new Date(input.oldestFootageMs).toISOString()
5531
+ });
5532
+ if (input.imageContractState !== null) values.push({
5533
+ topic: stateTopic(srv, "image_contract"),
5534
+ value: input.imageContractState
5535
+ });
5039
5536
  for (const node of input.nodes) {
5040
5537
  const slug = toSlug(node.id);
5041
5538
  values.push({
@@ -5052,6 +5549,24 @@ function projectSynthetic(input, nowMs) {
5052
5549
  topic: stateTopic(srv, `${slug}_uptime`),
5053
5550
  value: (/* @__PURE__ */ new Date(nowMs - node.uptime * 1e3)).toISOString()
5054
5551
  });
5552
+ if (node.footageUsedBytes !== null) values.push({
5553
+ topic: stateTopic(srv, `${slug}_footage_used_bytes`),
5554
+ value: String(node.footageUsedBytes)
5555
+ });
5556
+ if (node.footageFreePercent !== null) values.push({
5557
+ topic: stateTopic(srv, `${slug}_footage_free_percent`),
5558
+ value: String(Math.round(node.footageFreePercent * 10) / 10)
5559
+ });
5560
+ if (!node.isHub) {
5561
+ if (node.rootPackageVersion !== null) values.push({
5562
+ topic: stateTopic(srv, `${slug}_version`),
5563
+ value: node.rootPackageVersion
5564
+ });
5565
+ if (node.updateAvailable !== null) values.push({
5566
+ topic: stateTopic(srv, `${slug}_update_available`),
5567
+ value: String(node.updateAvailable)
5568
+ });
5569
+ }
5055
5570
  }
5056
5571
  return values;
5057
5572
  }
@@ -5190,6 +5705,17 @@ var HaExportAddon = class extends require_dist.BaseAddon {
5190
5705
  */
5191
5706
  syntheticRules = [];
5192
5707
  /**
5708
+ * `pipelineOrchestrator.getCameraStatuses`, keyed by deviceId — ONE call
5709
+ * per reconcile pass for every exported camera (Task C3), never per
5710
+ * device: the input is optional and omitting it means "the whole fleet",
5711
+ * which is exactly the path this repo just finished removing from hot
5712
+ * loops. Feeds `status` / `recording_active` (Traccia C) and
5713
+ * `broker-audio`'s node pin (Traccia A2). Cleared to empty on a failed
5714
+ * read — never stale data from a previous pass, which would be a status
5715
+ * silently wrong rather than absent.
5716
+ */
5717
+ cameraStatusByDeviceId = /* @__PURE__ */ new Map();
5718
+ /**
5193
5719
  * A link that returned repairs NOW, not at the next periodic pass.
5194
5720
  *
5195
5721
  * `PushClient` drops its dedup cache the moment Home Assistant answers
@@ -5689,6 +6215,39 @@ var HaExportAddon = class extends require_dist.BaseAddon {
5689
6215
  const devices = await this.ctx.api.deviceManager.listAll.query({});
5690
6216
  const snapshots = await this.ctx.api.deviceState.getAllSnapshots.query({});
5691
6217
  const byId = new Map(devices.map((device) => [device.id, device]));
6218
+ /**
6219
+ * The exported cameras' `CameraStatus`, ONE call for every one of them
6220
+ * (Task C3). Computed BEFORE the per-device loop: `status`,
6221
+ * `recording_active` and `broker-audio`'s node pin all read from it, and
6222
+ * asking per camera would be the fan-out this repo just finished
6223
+ * removing from a 300 s poll.
6224
+ */
6225
+ const exposedIds = exposedDeviceIds(this.config.membership, enabled);
6226
+ const cameraDeviceIds = [];
6227
+ for (const idStr of exposedIds) {
6228
+ const numericId = Number(idStr);
6229
+ if (!Number.isFinite(numericId)) continue;
6230
+ const candidate = byId.get(numericId);
6231
+ if (candidate !== void 0 && candidate.type === "camera" && candidate.addonId !== "provider-homeassistant") cameraDeviceIds.push(numericId);
6232
+ }
6233
+ this.cameraStatusByDeviceId = await this.readCameraStatuses(cameraDeviceIds);
6234
+ /** `notificationRules.listDeviceMutes` — once per pass, a flat list. */
6235
+ const mutedDeviceIds = await this.ctx.api.notificationRules.listDeviceMutes.query({}).then((r) => r.mutedDeviceIds).catch((err) => {
6236
+ this.ctx.logger.warn("ha-export: could not read notification mutes, camera functions degrade", { meta: { error: errMsg(err) } });
6237
+ return null;
6238
+ });
6239
+ /** `deviceManager.listBindableCapsForDeviceType` — once per TYPE, not per device. */
6240
+ const bindableCapsByType = /* @__PURE__ */ new Map();
6241
+ /**
6242
+ * `notificationRules.listSnoozes` — ONE read for the whole pass, shared
6243
+ * by every camera's `snooze` select (Task B1) and the synthetic
6244
+ * `snooze` select on *Notification Center* (Task D4): two expressions
6245
+ * of the same read is how `audio_detected` went mute for a year.
6246
+ */
6247
+ const snoozes = await this.ctx.api.notificationRules.listSnoozes.query({}).then((r) => r.snoozes).catch((err) => {
6248
+ this.ctx.logger.warn("ha-export: could not read snoozes, snooze selects go unknown", { meta: { error: errMsg(err) } });
6249
+ return [];
6250
+ });
5692
6251
  const exported = /* @__PURE__ */ new Map();
5693
6252
  const keyByDeviceId = /* @__PURE__ */ new Map();
5694
6253
  const allCaps = /* @__PURE__ */ new Set();
@@ -5714,7 +6273,24 @@ var HaExportAddon = class extends require_dist.BaseAddon {
5714
6273
  this.ctx.logger.warn("ha-export: refusing to export a device imported from Home Assistant", { tags: { deviceId: numericId } });
5715
6274
  continue;
5716
6275
  }
5717
- const catalogDevice = await this.buildCatalogDevice(device, snapshots[String(numericId)]);
6276
+ /**
6277
+ * ONE collection of the eight function authorities per camera per
6278
+ * pass (Task A1/A3) — reused below by the announce (the switch
6279
+ * catalog, via `buildCatalogDevice`) AND by the state push
6280
+ * (`currentStateFor`, off `ExportedDevice.cameraFunctions`). The two
6281
+ * used to call the deprecated `getCameraSwitches` separately for the
6282
+ * same answer.
6283
+ */
6284
+ const cameraFunctions = device.type === "camera" ? deriveCameraFunctions(await this.readCameraFunctions({
6285
+ deviceId: numericId,
6286
+ deviceType: device.type,
6287
+ deviceDisabled: device.disabled,
6288
+ privacySlice: toSnapshot(snapshots[String(numericId)])["privacy-mask"],
6289
+ sourceNodeId: this.cameraStatusByDeviceId.get(numericId)?.assignment.sourceNodeId ?? null,
6290
+ bindableCapsByType,
6291
+ mutedDeviceIds
6292
+ })) : null;
6293
+ const catalogDevice = await this.buildCatalogDevice(device, snapshots[String(numericId)], cameraFunctions);
5718
6294
  for (const cap of catalogDevice.boundCaps) allCaps.add(cap);
5719
6295
  const brokerIds = brokersExposing(this.config.membership, deviceIdStr).filter((id) => enabled.includes(id));
5720
6296
  /**
@@ -5734,7 +6310,8 @@ var HaExportAddon = class extends require_dist.BaseAddon {
5734
6310
  boundCaps: catalogDevice.boundCaps
5735
6311
  },
5736
6312
  brokerIds,
5737
- streams: catalogDevice.streams ?? []
6313
+ streams: catalogDevice.streams ?? [],
6314
+ cameraFunctions
5738
6315
  });
5739
6316
  keyByDeviceId.set(numericId, plan.deviceKey);
5740
6317
  entityCount += Object.keys(plan.cmps).length;
@@ -5763,7 +6340,7 @@ var HaExportAddon = class extends require_dist.BaseAddon {
5763
6340
  * `brokerIds` is every enabled broker, because they belong to the server
5764
6341
  * and not to a membership the operator picks per camera.
5765
6342
  */
5766
- const synthetic = await this.buildSynthetic();
6343
+ const synthetic = await this.buildSynthetic(snoozes, byId);
5767
6344
  if (synthetic !== null) for (const plan of syntheticPlans(synthetic)) {
5768
6345
  exported.set(plan.deviceKey, {
5769
6346
  plan,
@@ -5774,7 +6351,8 @@ var HaExportAddon = class extends require_dist.BaseAddon {
5774
6351
  boundCaps: []
5775
6352
  },
5776
6353
  brokerIds: [...enabled],
5777
- streams: []
6354
+ streams: [],
6355
+ cameraFunctions: null
5778
6356
  });
5779
6357
  entityCount += Object.keys(plan.cmps).length;
5780
6358
  }
@@ -5802,8 +6380,16 @@ var HaExportAddon = class extends require_dist.BaseAddon {
5802
6380
  }
5803
6381
  });
5804
6382
  await this.announce(exported);
5805
- await this.pushFullState(exported, snapshots);
5806
- if (synthetic !== null) await this.pushSynthetic(projectSynthetic(synthetic, Date.now()), [...enabled]);
6383
+ await this.pushFullState(exported, snapshots, snoozes);
6384
+ if (synthetic !== null) {
6385
+ /**
6386
+ * The global snooze's STATE (Task D4) rides the SAME `projectSnooze`
6387
+ * a camera's `snooze` select uses, over the SAME `snoozes` read this
6388
+ * pass already made — one expression of the rule, not two.
6389
+ */
6390
+ const ncKey = deviceKeyFor(NOTIFICATION_CENTER_STABLE_ID);
6391
+ await this.pushSynthetic([...projectSynthetic(synthetic, Date.now()), ...projectSnooze(ncKey, latestGlobalSnoozeWindow(snoozes))], [...enabled]);
6392
+ }
5807
6393
  this.ctx.logger.info("ha-export: reconciled", { meta: {
5808
6394
  reason,
5809
6395
  brokers: enabled.length,
@@ -5837,7 +6423,7 @@ var HaExportAddon = class extends require_dist.BaseAddon {
5837
6423
  * Returns `null` only when NOTHING answered, so the devices are not
5838
6424
  * announced empty on a hub that is still booting.
5839
6425
  */
5840
- async buildSynthetic() {
6426
+ async buildSynthetic(snoozes, byId) {
5841
6427
  const rules = await this.ctx.api.notificationRules.listRules.query({}).then((r) => r.rules.map((rule) => ({
5842
6428
  id: rule.id,
5843
6429
  name: rule.name,
@@ -5846,15 +6432,9 @@ var HaExportAddon = class extends require_dist.BaseAddon {
5846
6432
  this.ctx.logger.warn("ha-export: could not read notification rules", { meta: { error: errMsg(err) } });
5847
6433
  return null;
5848
6434
  });
5849
- const snoozes = await this.ctx.api.notificationRules.listSnoozes.query({}).then((r) => r.snoozes.length > 0).catch(() => null);
5850
- const nodes = await this.ctx.api.nodes.topology.query().then((list) => list.map((node) => ({
5851
- id: node.id,
5852
- name: node.name,
5853
- online: node.isOnline,
5854
- cpuPercent: node.cpuPercent,
5855
- memoryPercent: node.memoryPercent,
5856
- uptime: node.uptime
5857
- }))).catch((err) => {
6435
+ /** Reuses the ONE `listSnoozes` read the pass already made (Task B1/D4). */
6436
+ const anySnoozed = snoozes.length > 0;
6437
+ const topology = await this.ctx.api.nodes.topology.query().catch((err) => {
5858
6438
  this.ctx.logger.warn("ha-export: could not read the cluster topology", { meta: { error: errMsg(err) } });
5859
6439
  return null;
5860
6440
  });
@@ -5869,16 +6449,132 @@ var HaExportAddon = class extends require_dist.BaseAddon {
5869
6449
  };
5870
6450
  }).catch(() => null);
5871
6451
  const server = await this.ctx.api.serverManagement.getServerPackageStatus.query().catch(() => null);
5872
- if (rules === null && nodes === null && addons === null && server === null) return null;
6452
+ const alerts = await this.readActiveAlerts();
6453
+ const lastTrigger = await this.buildLastTrigger(byId);
6454
+ if (rules === null && topology === null && addons === null && server === null) return null;
5873
6455
  this.syntheticRules = rules ?? this.syntheticRules;
6456
+ const { nodes, oldestFootageMs } = await this.buildSyntheticNodes(topology ?? []);
5874
6457
  return {
5875
6458
  rules: rules ?? this.syntheticRules,
5876
- snoozed: snoozes ?? false,
5877
- nodes: nodes ?? [],
6459
+ snoozed: anySnoozed,
6460
+ lastTrigger,
6461
+ nodes,
5878
6462
  addonsRunning: addons?.running ?? 0,
5879
6463
  addonsFailed: addons?.failed ?? 0,
5880
6464
  serverVersion: server?.runningVersion ?? server?.activeVersion ?? "unknown",
5881
- updateAvailable: server?.updateAvailable ?? false
6465
+ updateAvailable: server?.updateAvailable ?? false,
6466
+ alertsActive: alerts.active,
6467
+ alertsActiveCount: alerts.count,
6468
+ alertsLastTitle: alerts.lastTitle,
6469
+ oldestFootageMs,
6470
+ imageContractState: server?.imageContract?.state ?? null
6471
+ };
6472
+ }
6473
+ /**
6474
+ * The most recent notification-rule trigger — "conoscere gli ultimi
6475
+ * trigger, le ultime immagini... e da quale label" (operator, 2026-08-25).
6476
+ *
6477
+ * `getHistory` is already newest-first (§3.2), so `limit: 1` is the whole
6478
+ * question. Best-effort like every other synthetic source here: a failed
6479
+ * read costs only these six entities, never the rest of the device.
6480
+ */
6481
+ async buildLastTrigger(byId) {
6482
+ try {
6483
+ const { entries } = await this.ctx.api.notificationRules.getHistory.query({ filter: { limit: 1 } });
6484
+ const entry = entries[0];
6485
+ if (entry === void 0) return null;
6486
+ const label = entry.subject.label ?? entry.subject.className;
6487
+ const artifactId = entry.artifactIds?.[0];
6488
+ const imageUrl = artifactId === void 0 ? null : await this.ctx.api.notificationRules.resolveArtifactUrl.query({ artifactId }).then((r) => r.url).catch((err) => {
6489
+ this.ctx.logger.debug("ha-export: could not resolve the last trigger image url", {
6490
+ tags: { deviceId: entry.deviceId },
6491
+ meta: {
6492
+ artifactId,
6493
+ error: errMsg(err)
6494
+ }
6495
+ });
6496
+ return null;
6497
+ });
6498
+ return {
6499
+ at: entry.createdAt,
6500
+ ruleName: entry.ruleName,
6501
+ deviceName: byId.get(entry.deviceId)?.name ?? `camera ${entry.deviceId}`,
6502
+ label,
6503
+ status: entry.status,
6504
+ imageUrl
6505
+ };
6506
+ } catch (err) {
6507
+ this.ctx.logger.warn("ha-export: could not read the last notification trigger", { meta: { error: errMsg(err) } });
6508
+ return null;
6509
+ }
6510
+ }
6511
+ /**
6512
+ * `alerts.list` — the conditions nobody said (Task D1). Best-effort: a
6513
+ * failed read yields "nothing active", not a fabricated absence dressed up
6514
+ * as a clean one — this addon logs the drop rather than pretending the
6515
+ * cluster is quiet. The derivation itself is pure — see
6516
+ * `deriveAlertSummary` in `synthetic-devices.ts`, which is what is tested.
6517
+ */
6518
+ async readActiveAlerts() {
6519
+ try {
6520
+ return deriveAlertSummary(await this.ctx.api.alerts.list.query({
6521
+ unreadOnly: false,
6522
+ limit: 50
6523
+ }));
6524
+ } catch (err) {
6525
+ this.ctx.logger.warn("ha-export: could not read alerts, the alert entities go quiet", { meta: { error: errMsg(err) } });
6526
+ return {
6527
+ active: false,
6528
+ count: 0,
6529
+ lastTitle: null
6530
+ };
6531
+ }
6532
+ }
6533
+ /**
6534
+ * Per-node disk usage (Task D2) and per-AGENT package status (Task D3),
6535
+ * fanned out from the topology this pass already read. Bounded per node —
6536
+ * one unreachable node degrades its own two rows, never the others'.
6537
+ */
6538
+ async buildSyntheticNodes(topology) {
6539
+ let oldestFootageMs = null;
6540
+ return {
6541
+ nodes: await Promise.all(topology.map(async (node) => {
6542
+ const disk = await this.ctx.api.recording.getStorageUsage.query({}, require_dist.nodePin(node.id)).catch((err) => {
6543
+ this.ctx.logger.warn("ha-export: could not read storage usage for a node, its disk entities go unknown", { meta: {
6544
+ nodeId: node.id,
6545
+ error: errMsg(err)
6546
+ } });
6547
+ return null;
6548
+ });
6549
+ const freePercents = (disk?.locations ?? []).filter((loc) => loc.availableBytes !== null && loc.totalBytes !== null && loc.totalBytes > 0).map((loc) => loc.availableBytes / loc.totalBytes * 100);
6550
+ const footageFreePercent = freePercents.length > 0 ? Math.min(...freePercents) : null;
6551
+ for (const device of disk?.devices ?? []) {
6552
+ const oldest = device.oldestMs;
6553
+ if (oldest === null || oldest === void 0) continue;
6554
+ if (oldestFootageMs === null || oldest < oldestFootageMs) oldestFootageMs = oldest;
6555
+ }
6556
+ const packageStatus = node.isHub ? null : await this.ctx.api.serverManagement.getServerPackageStatus.query({ nodeId: node.id }).catch((err) => {
6557
+ this.ctx.logger.warn("ha-export: could not read package status for an agent node, its update entities go unknown", { meta: {
6558
+ nodeId: node.id,
6559
+ error: errMsg(err)
6560
+ } });
6561
+ return null;
6562
+ });
6563
+ return {
6564
+ id: node.id,
6565
+ name: node.name,
6566
+ online: node.isOnline,
6567
+ cpuPercent: node.cpuPercent,
6568
+ memoryPercent: node.memoryPercent,
6569
+ uptime: node.uptime,
6570
+ isHub: node.isHub,
6571
+ footageUsedBytes: disk?.totalUsedBytes ?? null,
6572
+ footageFreePercent,
6573
+ rootPackageVersion: node.rootPackage?.version ?? null,
6574
+ updateAvailable: packageStatus?.updateAvailable ?? null
6575
+ };
6576
+ })),
6577
+ oldestFootageMs
5882
6578
  };
5883
6579
  }
5884
6580
  /** Push the synthetic values to every enabled broker. */
@@ -5906,9 +6602,9 @@ var HaExportAddon = class extends require_dist.BaseAddon {
5906
6602
  }
5907
6603
  }
5908
6604
  }
5909
- async pushFullState(exported, snapshots) {
6605
+ async pushFullState(exported, snapshots, snoozes) {
5910
6606
  for (const entry of exported.values()) try {
5911
- const values = await this.currentStateFor(entry, snapshots[String(entry.deviceId)]);
6607
+ const values = await this.currentStateFor(entry, snapshots[String(entry.deviceId)], snoozes);
5912
6608
  this.push(entry.deviceId, values);
5913
6609
  } catch (err) {
5914
6610
  this.ctx.logger.warn("ha-export: could not read current state for a device", {
@@ -5919,7 +6615,7 @@ var HaExportAddon = class extends require_dist.BaseAddon {
5919
6615
  for (const link of this.links.values()) await link.client.flush();
5920
6616
  }
5921
6617
  /** Everything readable without waiting for an event. */
5922
- async currentStateFor(entry, snapshotRaw) {
6618
+ async currentStateFor(entry, snapshotRaw, snoozes) {
5923
6619
  const key = entry.plan.deviceKey;
5924
6620
  const values = [];
5925
6621
  const snapshot = toSnapshot(snapshotRaw);
@@ -5989,13 +6685,26 @@ var HaExportAddon = class extends require_dist.BaseAddon {
5989
6685
  * video itself. The url this block used to publish was read by
5990
6686
  * nobody — see `camera-entities.ts`.
5991
6687
  */
5992
- const group = await this.ctx.api.pipelineOrchestrator.getCameraSwitches.query({ deviceId: entry.deviceId });
5993
- values.push(...projectCameraSwitches(key, group.switches.map((sw) => ({
5994
- id: sw.id,
5995
- label: sw.label,
5996
- available: sw.available,
5997
- enabled: sw.enabled
5998
- }))));
6688
+ values.push(...projectCameraFunctions(key, entry.cameraFunctions ?? []));
6689
+ /**
6690
+ * `status` / `recording_active` (Traccia C) — off the SAME
6691
+ * `CameraStatus` `runReconcile` already read once for the whole pass
6692
+ * (Task C3). A camera the batch read did not cover (the call failed
6693
+ * entirely, or a camera added mid-pass) publishes NEITHER: an invented
6694
+ * `ok` is the D62 failure this entity exists to prevent.
6695
+ */
6696
+ const cameraStatus = this.cameraStatusByDeviceId.get(entry.deviceId);
6697
+ if (cameraStatus !== void 0) values.push(...projectCameraStatus(key, toCameraStatusInput(cameraStatus)));
6698
+ /**
6699
+ * `snooze` (Task B1) — reconstructed from `notificationRules.listSnoozes`,
6700
+ * never stored: the select's state is the SAME authority the write path
6701
+ * (`applySnooze`) already uses.
6702
+ */
6703
+ const snoozeValues = projectSnooze(key, latestDeviceSnoozeWindow(snoozes, entry.deviceId));
6704
+ if (snoozeValues.length === 0) {
6705
+ 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 } });
6706
+ }
6707
+ values.push(...snoozeValues);
5999
6708
  const occupancy = await this.ctx.api.zoneAnalytics.getCurrentSnapshot.query({ deviceId: entry.deviceId });
6000
6709
  if (occupancy !== null) values.push(...projectZoneOccupancy(key, {
6001
6710
  zones: occupancy.zones.map((zone) => ({
@@ -6011,11 +6720,20 @@ var HaExportAddon = class extends require_dist.BaseAddon {
6011
6720
  }
6012
6721
  return values;
6013
6722
  }
6014
- async buildCatalogDevice(device, snapshotRaw) {
6723
+ async buildCatalogDevice(device, snapshotRaw, cameraFunctions) {
6015
6724
  const snapshot = toSnapshot(snapshotRaw);
6016
6725
  const boundCaps = await this.loadBoundCaps(device.id);
6017
6726
  const zones = device.type === "camera" ? await this.loadZones(device.id) : [];
6018
- const switches = device.type === "camera" ? await this.loadSwitches(device.id) : [];
6727
+ /**
6728
+ * Every camera function, available or not — `entity-catalog.ts` is the
6729
+ * one place that filters to `available`, exactly as it did for
6730
+ * `getCameraSwitches`'s answer.
6731
+ */
6732
+ const switches = (cameraFunctions ?? []).map((fn) => ({
6733
+ id: fn.id,
6734
+ label: fn.label,
6735
+ available: fn.available
6736
+ }));
6019
6737
  const ptzPresets = device.type === "camera" && boundCaps.includes("ptz") ? await this.loadPresets(device.id) : [];
6020
6738
  const streams = device.type === "camera" && boundCaps.includes("webrtc-session") ? await this.loadStreamChoices(device.id) : [];
6021
6739
  const manufacturer = readString(device.metadata ?? {}, "manufacturer");
@@ -6101,25 +6819,118 @@ var HaExportAddon = class extends require_dist.BaseAddon {
6101
6819
  return [];
6102
6820
  }
6103
6821
  }
6104
- async loadSwitches(deviceId) {
6822
+ /**
6823
+ * `pipelineOrchestrator.getCameraStatuses`, for every exported camera in
6824
+ * ONE call (Task C3). Best-effort for the WHOLE batch: a failure here means
6825
+ * `status`, `recording_active` and every `broker-audio` control degrade for
6826
+ * this pass — never a value invented from a previous pass's answer.
6827
+ */
6828
+ async readCameraStatuses(deviceIds) {
6829
+ if (deviceIds.length === 0) return /* @__PURE__ */ new Map();
6830
+ const startedAt = Date.now();
6105
6831
  try {
6106
- return (await this.ctx.api.pipelineOrchestrator.getCameraSwitches.query({ deviceId })).switches.map((sw) => ({
6107
- id: sw.id,
6108
- label: sw.label,
6109
- available: sw.available
6110
- }));
6832
+ const statuses = await this.ctx.api.pipelineOrchestrator.getCameraStatuses.query({ deviceIds: [...deviceIds] });
6833
+ this.ctx.logger.debug("ha-export: read camera statuses for the pass", { meta: {
6834
+ cameraStatusMs: Date.now() - startedAt,
6835
+ cameras: deviceIds.length
6836
+ } });
6837
+ return new Map(statuses.map((status) => [status.deviceId, status]));
6111
6838
  } catch (err) {
6112
- /**
6113
- * A source that did not answer produces NO control, never one
6114
- * defaulted to on. This is the rule `CameraSwitch.available`
6115
- * exists to enforce, and it has to survive the read failing.
6116
- */
6117
- this.ctx.logger.warn("ha-export: could not read camera switches, exporting no controls", {
6839
+ 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: {
6840
+ error: errMsg(err),
6841
+ cameras: deviceIds.length
6842
+ } });
6843
+ return /* @__PURE__ */ new Map();
6844
+ }
6845
+ }
6846
+ /**
6847
+ * The eight per-camera function authorities (Task A1), assembled into
6848
+ * {@link CameraFunctionReads}. Every source degrades INDEPENDENTLY with a
6849
+ * WARN carrying `tags: { deviceId }` — a dropped authority must never look
6850
+ * like "this function does not exist" (D62).
6851
+ *
6852
+ * `bindableCapsByType`/`mutedDeviceIds` are the reconcile pass's shared
6853
+ * caches when the caller has them (a per-TYPE lookup and a flat list read
6854
+ * once for the whole pass); omitted, each is read fresh — the one-off
6855
+ * command path (`dispatch`) has no pass to share.
6856
+ */
6857
+ async readCameraFunctions(input) {
6858
+ const { deviceId, deviceType, deviceDisabled, privacySlice, sourceNodeId } = input;
6859
+ const bindableCache = input.bindableCapsByType;
6860
+ let bindableCapNames = bindableCache?.get(deviceType);
6861
+ if (bindableCache === void 0 || bindableCapNames === void 0) {
6862
+ bindableCapNames = await this.ctx.api.deviceManager.listBindableCapsForDeviceType.query({ deviceType }).then((entries) => entries.filter((e) => e.wrappers.length > 0).map((e) => e.capName)).catch((err) => {
6863
+ this.ctx.logger.warn("ha-export: could not read bindable caps for a device type, camera functions degrade", {
6864
+ tags: { deviceId },
6865
+ meta: {
6866
+ deviceType,
6867
+ error: errMsg(err)
6868
+ }
6869
+ });
6870
+ return null;
6871
+ });
6872
+ bindableCache?.set(deviceType, bindableCapNames);
6873
+ }
6874
+ const wrapperAddonIdByCap = /* @__PURE__ */ new Map();
6875
+ const wrappedCapNames = await this.ctx.api.deviceManager.getBindings.query({ deviceId }).then((bindings) => {
6876
+ const active = [];
6877
+ for (const entry of bindings.entries) {
6878
+ if (entry.kind !== "wrapped") continue;
6879
+ active.push(entry.capName);
6880
+ if (entry.providerAddonId !== "") wrapperAddonIdByCap.set(entry.capName, entry.providerAddonId);
6881
+ }
6882
+ return active;
6883
+ }).catch((err) => {
6884
+ this.ctx.logger.warn("ha-export: could not read bindings, camera functions degrade", {
6118
6885
  tags: { deviceId },
6119
6886
  meta: { error: errMsg(err) }
6120
6887
  });
6121
- return [];
6122
- }
6888
+ return null;
6889
+ });
6890
+ const recordingEnabled = await this.ctx.api.recording.getDeviceConfig.query({ deviceId }).then((config) => config.enabled).catch((err) => {
6891
+ this.ctx.logger.warn("ha-export: could not read recording config, the recording function degrades", {
6892
+ tags: { deviceId },
6893
+ meta: { error: errMsg(err) }
6894
+ });
6895
+ return null;
6896
+ });
6897
+ const mutedDeviceIds = input.mutedDeviceIds !== void 0 ? input.mutedDeviceIds : await this.ctx.api.notificationRules.listDeviceMutes.query({}).then((r) => r.mutedDeviceIds).catch((err) => {
6898
+ this.ctx.logger.warn("ha-export: could not read notification mutes, the notifications function degrades", {
6899
+ tags: { deviceId },
6900
+ meta: { error: errMsg(err) }
6901
+ });
6902
+ return null;
6903
+ });
6904
+ const notificationsMuted = mutedDeviceIds === null ? null : mutedDeviceIds.includes(deviceId);
6905
+ /**
6906
+ * The `privacy-mask` slice already in the snapshot this pass downloaded
6907
+ * (D224: a camera fact's freshness lives in the provider that owns it) —
6908
+ * zero new dialers to the camera.
6909
+ */
6910
+ const privacy = privacySlice === void 0 ? null : {
6911
+ maskEnabled: privacySlice["enabled"] === true,
6912
+ audioEnabled: typeof privacySlice["audioEnabled"] === "boolean" ? privacySlice["audioEnabled"] : null
6913
+ };
6914
+ const brokerAudioMuted = sourceNodeId === null ? null : await this.ctx.api.streamBroker.getDeviceAudioMute.query({ deviceId }, require_dist.nodePin(sourceNodeId)).then((r) => r.muted).catch((err) => {
6915
+ this.ctx.logger.warn("ha-export: could not read broker audio mute, the broker-audio function degrades", {
6916
+ tags: { deviceId },
6917
+ meta: {
6918
+ sourceNodeId,
6919
+ error: errMsg(err)
6920
+ }
6921
+ });
6922
+ return null;
6923
+ });
6924
+ return {
6925
+ deviceDisabled,
6926
+ bindableCapNames,
6927
+ wrappedCapNames,
6928
+ wrapperAddonIdByCap,
6929
+ recordingEnabled,
6930
+ notificationsMuted,
6931
+ privacy,
6932
+ brokerAudioMuted
6933
+ };
6123
6934
  }
6124
6935
  async loadPresets(deviceId) {
6125
6936
  try {
@@ -6372,6 +7183,38 @@ var HaExportAddon = class extends require_dist.BaseAddon {
6372
7183
  * disagree with the admin UI is worse than no knob (D62).
6373
7184
  */
6374
7185
  if (isSyntheticDeviceKey(parsed.deviceKey)) {
7186
+ /**
7187
+ * The global snooze select (Task D4) — the OTHER control this branch
7188
+ * routes, alongside the per-rule switches below. Same rule as the
7189
+ * camera's own snooze (`applySnooze`): the export writes
7190
+ * `createSnooze`/`cancelSnooze` directly, `scope: 'all'`, never a
7191
+ * store of its own.
7192
+ */
7193
+ if (parsed.deviceKey === deviceKeyFor("synthetic-notification-center") && parsed.entity === "snooze") {
7194
+ if (!SNOOZE_OPTIONS.includes(value)) {
7195
+ this.ctx.logger.warn("ha-export: dropping a global snooze command, unknown option", { meta: {
7196
+ topic,
7197
+ value
7198
+ } });
7199
+ reply.status(422);
7200
+ reply.send({ error: "unroutable command" });
7201
+ return;
7202
+ }
7203
+ const minutes = SNOOZE_MINUTES[value] ?? null;
7204
+ try {
7205
+ await this.applyGlobalSnooze(minutes);
7206
+ reply.status(200);
7207
+ reply.send({});
7208
+ } catch (err) {
7209
+ this.ctx.logger.warn("ha-export: could not apply the global snooze from Home Assistant", { meta: {
7210
+ minutes,
7211
+ error: errMsg(err)
7212
+ } });
7213
+ reply.status(422);
7214
+ reply.send({ error: "command not applied" });
7215
+ }
7216
+ return;
7217
+ }
6375
7218
  const ruleId = ruleIdFromEntity(parsed.entity, this.syntheticRules);
6376
7219
  if (ruleId === null) {
6377
7220
  this.ctx.logger.warn("ha-export: dropping a synthetic command with no authority behind it", { meta: {
@@ -6457,20 +7300,62 @@ var HaExportAddon = class extends require_dist.BaseAddon {
6457
7300
  return false;
6458
7301
  }
6459
7302
  switch (command.kind) {
6460
- case "camera-switch": {
7303
+ case "camera-function": {
6461
7304
  const parsed = require_dist.CameraSwitchIdSchema.safeParse(command.switchId);
6462
7305
  if (!parsed.success) {
6463
- this.ctx.logger.warn("ha-export: dropping a command for an unknown camera switch", {
7306
+ this.ctx.logger.warn("ha-export: dropping a command for an unknown camera function", {
6464
7307
  tags: { deviceId: command.deviceId },
6465
7308
  meta: { switchId: command.switchId }
6466
7309
  });
6467
7310
  return false;
6468
7311
  }
6469
- await this.ctx.api.pipelineOrchestrator.setCameraSwitch.mutate({
7312
+ /**
7313
+ * A FRESH read for this one device — the pass-level caches are up to
7314
+ * `reconcileIntervalSec` old, and an availability decision that
7315
+ * writes belongs to the moment of the command, not the last poll.
7316
+ */
7317
+ const device = await this.ctx.api.deviceManager.getDevice.query({ deviceId: command.deviceId });
7318
+ if (device === null) {
7319
+ this.ctx.logger.warn("ha-export: dropping a camera-function command, the device is not in the registry", { tags: { deviceId: command.deviceId } });
7320
+ return false;
7321
+ }
7322
+ const privacySlice = await this.ctx.api.deviceState.getCapSlice.query({
7323
+ deviceId: command.deviceId,
7324
+ capName: "privacy-mask"
7325
+ }).catch((err) => {
7326
+ this.ctx.logger.warn("ha-export: could not read the privacy slice for a camera-function command", {
7327
+ tags: { deviceId: command.deviceId },
7328
+ meta: { error: errMsg(err) }
7329
+ });
7330
+ return null;
7331
+ });
7332
+ /**
7333
+ * The source node comes from the last reconcile's `CameraStatus`
7334
+ * batch (Task C3) rather than a fresh per-command call: it changes
7335
+ * rarely, and only `broker-audio` needs it — a stale pin degrades
7336
+ * that ONE function for this command, not the other seven.
7337
+ */
7338
+ const sourceNodeId = this.cameraStatusByDeviceId.get(command.deviceId)?.assignment.sourceNodeId ?? null;
7339
+ const reads = await this.readCameraFunctions({
6470
7340
  deviceId: command.deviceId,
6471
- switchId: parsed.data,
6472
- enabled: command.enabled
7341
+ deviceType: device.type,
7342
+ deviceDisabled: device.disabled,
7343
+ privacySlice: privacySlice ?? void 0,
7344
+ sourceNodeId
6473
7345
  });
7346
+ const write = resolveCameraFunctionWrite(parsed.data, command.enabled, reads);
7347
+ if (write === null) {
7348
+ /**
7349
+ * Refused, never approximated. No optimistic ack: the next
7350
+ * reconcile corrects Home Assistant's toggle back to reality.
7351
+ */
7352
+ this.ctx.logger.warn("ha-export: dropping a camera-function command, the function is not available", {
7353
+ tags: { deviceId: command.deviceId },
7354
+ meta: { switchId: parsed.data }
7355
+ });
7356
+ return false;
7357
+ }
7358
+ await applyCameraFunctionWrite(this.ctx.api, command.deviceId, write, sourceNodeId);
6474
7359
  return true;
6475
7360
  }
6476
7361
  case "reboot": {
@@ -6534,19 +7419,69 @@ var HaExportAddon = class extends require_dist.BaseAddon {
6534
7419
  tags: { deviceId },
6535
7420
  meta: { cancelled: mine.length }
6536
7421
  });
6537
- return true;
7422
+ } else {
7423
+ await this.ctx.api.notificationRules.createSnooze.mutate({ snooze: {
7424
+ scope: "device",
7425
+ deviceId,
7426
+ durationMinutes: minutes
7427
+ } });
7428
+ this.ctx.logger.info("ha-export: snoozed from Home Assistant", {
7429
+ tags: { deviceId },
7430
+ meta: { minutes }
7431
+ });
6538
7432
  }
6539
- await this.ctx.api.notificationRules.createSnooze.mutate({ snooze: {
6540
- scope: "device",
6541
- deviceId,
6542
- durationMinutes: minutes
6543
- } });
6544
- this.ctx.logger.info("ha-export: snoozed from Home Assistant", {
6545
- tags: { deviceId },
6546
- meta: { minutes }
6547
- });
7433
+ /**
7434
+ * Republish right away — the select is otherwise stale for up to
7435
+ * `reconcileIntervalSec` after a write Home Assistant itself made
7436
+ * (Task B1 passo 4).
7437
+ */
7438
+ await this.republishSnooze(deviceId);
6548
7439
  return true;
6549
7440
  }
7441
+ /** Push `snooze`'s current state for one camera, outside the reconcile cadence. */
7442
+ async republishSnooze(deviceId) {
7443
+ const key = this.keyByDeviceId.get(deviceId);
7444
+ if (key === void 0) return;
7445
+ try {
7446
+ const { snoozes } = await this.ctx.api.notificationRules.listSnoozes.query({});
7447
+ this.push(deviceId, projectSnooze(key, latestDeviceSnoozeWindow(snoozes, deviceId)));
7448
+ } catch (err) {
7449
+ this.ctx.logger.warn("ha-export: could not republish snooze after a write from Home Assistant", {
7450
+ tags: { deviceId },
7451
+ meta: { error: errMsg(err) }
7452
+ });
7453
+ }
7454
+ }
7455
+ /**
7456
+ * The global `select` on *Notification Center* (Task D4) — `scope: 'all'`,
7457
+ * the same authority `applySnooze` already writes for a camera. `Off`
7458
+ * cancels every global snooze, exactly as a camera's `Off` cancels its own.
7459
+ */
7460
+ async applyGlobalSnooze(minutes) {
7461
+ if (minutes === null) {
7462
+ const { snoozes } = await this.ctx.api.notificationRules.listSnoozes.query({});
7463
+ const mine = snoozes.filter((snooze) => snooze.scope === "all");
7464
+ for (const snooze of mine) await this.ctx.api.notificationRules.cancelSnooze.mutate({ snoozeId: snooze.id });
7465
+ this.ctx.logger.info("ha-export: cancelled the global snooze from Home Assistant", { meta: { cancelled: mine.length } });
7466
+ } else {
7467
+ await this.ctx.api.notificationRules.createSnooze.mutate({ snooze: {
7468
+ scope: "all",
7469
+ durationMinutes: minutes
7470
+ } });
7471
+ this.ctx.logger.info("ha-export: snoozed globally from Home Assistant", { meta: { minutes } });
7472
+ }
7473
+ await this.republishGlobalSnooze();
7474
+ }
7475
+ /** Push the global `snooze` select's current state, outside the reconcile cadence. */
7476
+ async republishGlobalSnooze() {
7477
+ try {
7478
+ const { snoozes } = await this.ctx.api.notificationRules.listSnoozes.query({});
7479
+ const ncKey = deviceKeyFor(NOTIFICATION_CENTER_STABLE_ID);
7480
+ await this.pushSynthetic(projectSnooze(ncKey, latestGlobalSnoozeWindow(snoozes)), this.enabledBrokerIds());
7481
+ } catch (err) {
7482
+ this.ctx.logger.warn("ha-export: could not republish the global snooze after a write from Home Assistant", { meta: { error: errMsg(err) } });
7483
+ }
7484
+ }
6550
7485
  /**
6551
7486
  * A public, signed, expiring route Home Assistant can fetch bytes from.
6552
7487
  *
@@ -6939,6 +7874,37 @@ function toSnapshot(raw) {
6939
7874
  for (const [cap, slice] of Object.entries(raw)) if (slice !== null && typeof slice === "object") out[cap] = { ...slice };
6940
7875
  return out;
6941
7876
  }
7877
+ /** `CameraStatus` → the narrower shape `projectCameraStatus` needs. */
7878
+ function toCameraStatusInput(status) {
7879
+ return {
7880
+ switchedOff: status.switchedOff,
7881
+ degradedStages: status.degraded.map((d) => d.stage),
7882
+ recording: status.recording === null ? null : { active: status.recording.active }
7883
+ };
7884
+ }
7885
+ /**
7886
+ * The ACTIVE window for one scope, from a flat `listSnoozes` answer.
7887
+ *
7888
+ * "Active" is `expiresAt` in the future — `listSnoozes` never sweeps expired
7889
+ * rows (expiry is a comparison, not a job), so a reader that skipped this
7890
+ * would keep publishing a duration that ended hours ago. When several windows
7891
+ * match, the one expiring LATEST wins: that is the duration still in effect.
7892
+ */
7893
+ function latestSnoozeWindow(snoozes, matches, now) {
7894
+ const latest = snoozes.filter((s) => matches(s) && s.expiresAt > now).reduce((top, s) => top === null || s.expiresAt > top.expiresAt ? s : top, null);
7895
+ return latest === null ? null : {
7896
+ startedAt: latest.startedAt,
7897
+ expiresAt: latest.expiresAt
7898
+ };
7899
+ }
7900
+ /** The camera's own `scope: 'device'` snoozes (Task B1). */
7901
+ function latestDeviceSnoozeWindow(snoozes, deviceId) {
7902
+ return latestSnoozeWindow(snoozes, (s) => s.scope === "device" && s.deviceId === deviceId, Date.now());
7903
+ }
7904
+ /** The global `scope: 'all'` snooze (Task D4). */
7905
+ function latestGlobalSnoozeWindow(snoozes) {
7906
+ return latestSnoozeWindow(snoozes, (s) => s.scope === "all", Date.now());
7907
+ }
6942
7908
  function toCountRecord(raw) {
6943
7909
  if (raw === null || typeof raw !== "object") return {};
6944
7910
  const out = {};