@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.
@@ -1,4 +1,4 @@
1
- import { M as deviceExportCapability, Ot as EventCategory, S as buildAddonRouteProvider, Y as oauthIntegrationCapability, a as CameraSwitchIdSchema, c as HvacModeSchema, f as addonRoutesCapability, ft as BaseAddon, i as COCO_TO_MACRO, l as MediaPlayerRepeatSchema, s as FanDirectionSchema, t as AlarmArmModeSchema, wt as string } from "../dist-DolpU5X1.mjs";
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. Fed by `pipeline.audio-inference-result`
1256
- * the event this addon did not subscribe to at all, which is why
1257
- * `audio_detected` below has effectively never moved.
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 switch, rendered from
1337
- * `getCameraSwitches` and commanded back through `setCameraSwitch`.
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 a camera switch through getCameraSwitches, never written directly (D62)."
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 switch ids, keyed by the entity slug the catalog emits. */
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-switch",
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-switch":
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
- * `pipelineOrchestrator.getCameraSwitches`.
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 switch publishes nothing, because `enabled` is
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 projectCameraSwitches(deviceKey, switches) {
4692
- return switches.filter((sw) => sw.available).map((sw) => ({
4693
- topic: stateTopic(deviceKey, toSlug(sw.id)),
4694
- value: bool(sw.enabled)
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
- const catalogDevice = await this.buildCatalogDevice(device, snapshots[String(numericId)]);
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) await this.pushSynthetic(projectSynthetic(synthetic, Date.now()), [...enabled]);
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
- const snoozes = await this.ctx.api.notificationRules.listSnoozes.query({}).then((r) => r.snoozes.length > 0).catch(() => null);
5845
- const nodes = await this.ctx.api.nodes.topology.query().then((list) => list.map((node) => ({
5846
- id: node.id,
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
- if (rules === null && nodes === null && addons === null && server === null) return null;
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: snoozes ?? false,
5872
- nodes: nodes ?? [],
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
- const group = await this.ctx.api.pipelineOrchestrator.getCameraSwitches.query({ deviceId: entry.deviceId });
5988
- values.push(...projectCameraSwitches(key, group.switches.map((sw) => ({
5989
- id: sw.id,
5990
- label: sw.label,
5991
- available: sw.available,
5992
- enabled: sw.enabled
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
- const switches = device.type === "camera" ? await this.loadSwitches(device.id) : [];
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
- async loadSwitches(deviceId) {
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
- return (await this.ctx.api.pipelineOrchestrator.getCameraSwitches.query({ deviceId })).switches.map((sw) => ({
6102
- id: sw.id,
6103
- label: sw.label,
6104
- available: sw.available
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
- * A source that did not answer produces NO control, never one
6109
- * defaulted to on. This is the rule `CameraSwitch.available`
6110
- * exists to enforce, and it has to survive the read failing.
6111
- */
6112
- this.ctx.logger.warn("ha-export: could not read camera switches, exporting no controls", {
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-switch": {
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 switch", {
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
- await this.ctx.api.pipelineOrchestrator.setCameraSwitch.mutate({
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
- switchId: parsed.data,
6467
- enabled: command.enabled
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
- return true;
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
- await this.ctx.api.notificationRules.createSnooze.mutate({ snooze: {
6535
- scope: "device",
6536
- deviceId,
6537
- durationMinutes: minutes
6538
- } });
6539
- this.ctx.logger.info("ha-export: snoozed from Home Assistant", {
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 = {};