@camstack/addon-provider-homeassistant 1.2.41 → 1.2.43

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-D1G6jUej.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-Dbn3F-bB.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,21 @@ 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
+ }),
4878
5193
  ...input.rules.map((rule) => ({
4879
5194
  entity: ruleEntity(rule.id),
4880
5195
  platform: "switch",
@@ -4936,6 +5251,56 @@ function serverSpecs(input) {
4936
5251
  deviceClass: "update",
4937
5252
  entityCategory: "diagnostic"
4938
5253
  },
5254
+ (
5255
+ /**
5256
+ * The conditions nobody said (Task D1) — `alerts.list`'s active,
5257
+ * warning-or-error findings. `info` never raises `alerts_active` or
5258
+ * inflates the count: a badge that lights up on `info` teaches an
5259
+ * operator to ignore it, which is how the four-hour blackout stayed
5260
+ * quiet through every liveness finding it produced.
5261
+ */
5262
+ {
5263
+ entity: "alerts_active",
5264
+ platform: "binary_sensor",
5265
+ label: "Alerts active",
5266
+ deviceClass: "problem"
5267
+ }),
5268
+ {
5269
+ entity: "alerts_active_count",
5270
+ platform: "sensor",
5271
+ label: "Active alerts",
5272
+ icon: "mdi:alert"
5273
+ },
5274
+ {
5275
+ entity: "alerts_last_title",
5276
+ platform: "sensor",
5277
+ label: "Last active alert",
5278
+ icon: "mdi:alert-circle-outline",
5279
+ entityCategory: "diagnostic"
5280
+ },
5281
+ (
5282
+ /** The oldest footage across the whole cluster (Task D2). */
5283
+ {
5284
+ entity: "oldest_footage",
5285
+ platform: "sensor",
5286
+ label: "Oldest footage",
5287
+ deviceClass: "timestamp",
5288
+ entityCategory: "diagnostic"
5289
+ }),
5290
+ (
5291
+ /**
5292
+ * The seed-vs-contract verdict (Task D3, D3's `image_contract`) — the
5293
+ * 2026-08-02 guasto, where `runningVersion` was current while the
5294
+ * container image was weeks old from a renamed repository nothing
5295
+ * rebuilt. Hub only: it describes THIS process's own image.
5296
+ */
5297
+ {
5298
+ entity: "image_contract",
5299
+ platform: "sensor",
5300
+ label: "Image contract",
5301
+ icon: "mdi:package-variant-closed-check",
5302
+ entityCategory: "diagnostic"
5303
+ }),
4939
5304
  ...input.nodes.flatMap((node) => {
4940
5305
  const slug = toSlug(node.id);
4941
5306
  return [
@@ -4964,7 +5329,38 @@ function serverSpecs(input) {
4964
5329
  platform: "sensor",
4965
5330
  label: `${node.name} uptime`,
4966
5331
  deviceClass: "timestamp"
4967
- }
5332
+ },
5333
+ (
5334
+ /** The disk (Task D2) — every node, hub included: every node records. */
5335
+ {
5336
+ entity: `${slug}_footage_used_bytes`,
5337
+ platform: "sensor",
5338
+ label: `${node.name} footage used`,
5339
+ deviceClass: "data_size",
5340
+ unit: "B",
5341
+ entityCategory: "diagnostic"
5342
+ }),
5343
+ {
5344
+ entity: `${slug}_footage_free_percent`,
5345
+ platform: "sensor",
5346
+ label: `${node.name} footage free`,
5347
+ unit: "%",
5348
+ icon: "mdi:harddisk",
5349
+ entityCategory: "diagnostic"
5350
+ },
5351
+ ...node.isHub ? [] : [{
5352
+ entity: `${slug}_version`,
5353
+ platform: "sensor",
5354
+ label: `${node.name} version`,
5355
+ icon: "mdi:tag",
5356
+ entityCategory: "diagnostic"
5357
+ }, {
5358
+ entity: `${slug}_update_available`,
5359
+ platform: "binary_sensor",
5360
+ label: `${node.name} update available`,
5361
+ deviceClass: "update",
5362
+ entityCategory: "diagnostic"
5363
+ }]
4968
5364
  ];
4969
5365
  })
4970
5366
  ];
@@ -5029,8 +5425,28 @@ function projectSynthetic(input, nowMs) {
5029
5425
  {
5030
5426
  topic: stateTopic(srv, "update_available"),
5031
5427
  value: String(input.updateAvailable)
5428
+ },
5429
+ {
5430
+ topic: stateTopic(srv, "alerts_active"),
5431
+ value: String(input.alertsActive)
5432
+ },
5433
+ {
5434
+ topic: stateTopic(srv, "alerts_active_count"),
5435
+ value: String(input.alertsActiveCount)
5032
5436
  }
5033
5437
  ];
5438
+ if (input.alertsLastTitle !== null) values.push({
5439
+ topic: stateTopic(srv, "alerts_last_title"),
5440
+ value: input.alertsLastTitle
5441
+ });
5442
+ if (input.oldestFootageMs !== null) values.push({
5443
+ topic: stateTopic(srv, "oldest_footage"),
5444
+ value: new Date(input.oldestFootageMs).toISOString()
5445
+ });
5446
+ if (input.imageContractState !== null) values.push({
5447
+ topic: stateTopic(srv, "image_contract"),
5448
+ value: input.imageContractState
5449
+ });
5034
5450
  for (const node of input.nodes) {
5035
5451
  const slug = toSlug(node.id);
5036
5452
  values.push({
@@ -5047,6 +5463,24 @@ function projectSynthetic(input, nowMs) {
5047
5463
  topic: stateTopic(srv, `${slug}_uptime`),
5048
5464
  value: (/* @__PURE__ */ new Date(nowMs - node.uptime * 1e3)).toISOString()
5049
5465
  });
5466
+ if (node.footageUsedBytes !== null) values.push({
5467
+ topic: stateTopic(srv, `${slug}_footage_used_bytes`),
5468
+ value: String(node.footageUsedBytes)
5469
+ });
5470
+ if (node.footageFreePercent !== null) values.push({
5471
+ topic: stateTopic(srv, `${slug}_footage_free_percent`),
5472
+ value: String(Math.round(node.footageFreePercent * 10) / 10)
5473
+ });
5474
+ if (!node.isHub) {
5475
+ if (node.rootPackageVersion !== null) values.push({
5476
+ topic: stateTopic(srv, `${slug}_version`),
5477
+ value: node.rootPackageVersion
5478
+ });
5479
+ if (node.updateAvailable !== null) values.push({
5480
+ topic: stateTopic(srv, `${slug}_update_available`),
5481
+ value: String(node.updateAvailable)
5482
+ });
5483
+ }
5050
5484
  }
5051
5485
  return values;
5052
5486
  }
@@ -5185,6 +5619,17 @@ var HaExportAddon = class extends BaseAddon {
5185
5619
  */
5186
5620
  syntheticRules = [];
5187
5621
  /**
5622
+ * `pipelineOrchestrator.getCameraStatuses`, keyed by deviceId — ONE call
5623
+ * per reconcile pass for every exported camera (Task C3), never per
5624
+ * device: the input is optional and omitting it means "the whole fleet",
5625
+ * which is exactly the path this repo just finished removing from hot
5626
+ * loops. Feeds `status` / `recording_active` (Traccia C) and
5627
+ * `broker-audio`'s node pin (Traccia A2). Cleared to empty on a failed
5628
+ * read — never stale data from a previous pass, which would be a status
5629
+ * silently wrong rather than absent.
5630
+ */
5631
+ cameraStatusByDeviceId = /* @__PURE__ */ new Map();
5632
+ /**
5188
5633
  * A link that returned repairs NOW, not at the next periodic pass.
5189
5634
  *
5190
5635
  * `PushClient` drops its dedup cache the moment Home Assistant answers
@@ -5684,6 +6129,39 @@ var HaExportAddon = class extends BaseAddon {
5684
6129
  const devices = await this.ctx.api.deviceManager.listAll.query({});
5685
6130
  const snapshots = await this.ctx.api.deviceState.getAllSnapshots.query({});
5686
6131
  const byId = new Map(devices.map((device) => [device.id, device]));
6132
+ /**
6133
+ * The exported cameras' `CameraStatus`, ONE call for every one of them
6134
+ * (Task C3). Computed BEFORE the per-device loop: `status`,
6135
+ * `recording_active` and `broker-audio`'s node pin all read from it, and
6136
+ * asking per camera would be the fan-out this repo just finished
6137
+ * removing from a 300 s poll.
6138
+ */
6139
+ const exposedIds = exposedDeviceIds(this.config.membership, enabled);
6140
+ const cameraDeviceIds = [];
6141
+ for (const idStr of exposedIds) {
6142
+ const numericId = Number(idStr);
6143
+ if (!Number.isFinite(numericId)) continue;
6144
+ const candidate = byId.get(numericId);
6145
+ if (candidate !== void 0 && candidate.type === "camera" && candidate.addonId !== "provider-homeassistant") cameraDeviceIds.push(numericId);
6146
+ }
6147
+ this.cameraStatusByDeviceId = await this.readCameraStatuses(cameraDeviceIds);
6148
+ /** `notificationRules.listDeviceMutes` — once per pass, a flat list. */
6149
+ const mutedDeviceIds = await this.ctx.api.notificationRules.listDeviceMutes.query({}).then((r) => r.mutedDeviceIds).catch((err) => {
6150
+ this.ctx.logger.warn("ha-export: could not read notification mutes, camera functions degrade", { meta: { error: errMsg(err) } });
6151
+ return null;
6152
+ });
6153
+ /** `deviceManager.listBindableCapsForDeviceType` — once per TYPE, not per device. */
6154
+ const bindableCapsByType = /* @__PURE__ */ new Map();
6155
+ /**
6156
+ * `notificationRules.listSnoozes` — ONE read for the whole pass, shared
6157
+ * by every camera's `snooze` select (Task B1) and the synthetic
6158
+ * `snooze` select on *Notification Center* (Task D4): two expressions
6159
+ * of the same read is how `audio_detected` went mute for a year.
6160
+ */
6161
+ const snoozes = await this.ctx.api.notificationRules.listSnoozes.query({}).then((r) => r.snoozes).catch((err) => {
6162
+ this.ctx.logger.warn("ha-export: could not read snoozes, snooze selects go unknown", { meta: { error: errMsg(err) } });
6163
+ return [];
6164
+ });
5687
6165
  const exported = /* @__PURE__ */ new Map();
5688
6166
  const keyByDeviceId = /* @__PURE__ */ new Map();
5689
6167
  const allCaps = /* @__PURE__ */ new Set();
@@ -5709,7 +6187,24 @@ var HaExportAddon = class extends BaseAddon {
5709
6187
  this.ctx.logger.warn("ha-export: refusing to export a device imported from Home Assistant", { tags: { deviceId: numericId } });
5710
6188
  continue;
5711
6189
  }
5712
- const catalogDevice = await this.buildCatalogDevice(device, snapshots[String(numericId)]);
6190
+ /**
6191
+ * ONE collection of the eight function authorities per camera per
6192
+ * pass (Task A1/A3) — reused below by the announce (the switch
6193
+ * catalog, via `buildCatalogDevice`) AND by the state push
6194
+ * (`currentStateFor`, off `ExportedDevice.cameraFunctions`). The two
6195
+ * used to call the deprecated `getCameraSwitches` separately for the
6196
+ * same answer.
6197
+ */
6198
+ const cameraFunctions = device.type === "camera" ? deriveCameraFunctions(await this.readCameraFunctions({
6199
+ deviceId: numericId,
6200
+ deviceType: device.type,
6201
+ deviceDisabled: device.disabled,
6202
+ privacySlice: toSnapshot(snapshots[String(numericId)])["privacy-mask"],
6203
+ sourceNodeId: this.cameraStatusByDeviceId.get(numericId)?.assignment.sourceNodeId ?? null,
6204
+ bindableCapsByType,
6205
+ mutedDeviceIds
6206
+ })) : null;
6207
+ const catalogDevice = await this.buildCatalogDevice(device, snapshots[String(numericId)], cameraFunctions);
5713
6208
  for (const cap of catalogDevice.boundCaps) allCaps.add(cap);
5714
6209
  const brokerIds = brokersExposing(this.config.membership, deviceIdStr).filter((id) => enabled.includes(id));
5715
6210
  /**
@@ -5729,7 +6224,8 @@ var HaExportAddon = class extends BaseAddon {
5729
6224
  boundCaps: catalogDevice.boundCaps
5730
6225
  },
5731
6226
  brokerIds,
5732
- streams: catalogDevice.streams ?? []
6227
+ streams: catalogDevice.streams ?? [],
6228
+ cameraFunctions
5733
6229
  });
5734
6230
  keyByDeviceId.set(numericId, plan.deviceKey);
5735
6231
  entityCount += Object.keys(plan.cmps).length;
@@ -5758,7 +6254,7 @@ var HaExportAddon = class extends BaseAddon {
5758
6254
  * `brokerIds` is every enabled broker, because they belong to the server
5759
6255
  * and not to a membership the operator picks per camera.
5760
6256
  */
5761
- const synthetic = await this.buildSynthetic();
6257
+ const synthetic = await this.buildSynthetic(snoozes);
5762
6258
  if (synthetic !== null) for (const plan of syntheticPlans(synthetic)) {
5763
6259
  exported.set(plan.deviceKey, {
5764
6260
  plan,
@@ -5769,7 +6265,8 @@ var HaExportAddon = class extends BaseAddon {
5769
6265
  boundCaps: []
5770
6266
  },
5771
6267
  brokerIds: [...enabled],
5772
- streams: []
6268
+ streams: [],
6269
+ cameraFunctions: null
5773
6270
  });
5774
6271
  entityCount += Object.keys(plan.cmps).length;
5775
6272
  }
@@ -5797,8 +6294,16 @@ var HaExportAddon = class extends BaseAddon {
5797
6294
  }
5798
6295
  });
5799
6296
  await this.announce(exported);
5800
- await this.pushFullState(exported, snapshots);
5801
- if (synthetic !== null) await this.pushSynthetic(projectSynthetic(synthetic, Date.now()), [...enabled]);
6297
+ await this.pushFullState(exported, snapshots, snoozes);
6298
+ if (synthetic !== null) {
6299
+ /**
6300
+ * The global snooze's STATE (Task D4) rides the SAME `projectSnooze`
6301
+ * a camera's `snooze` select uses, over the SAME `snoozes` read this
6302
+ * pass already made — one expression of the rule, not two.
6303
+ */
6304
+ const ncKey = deviceKeyFor(NOTIFICATION_CENTER_STABLE_ID);
6305
+ await this.pushSynthetic([...projectSynthetic(synthetic, Date.now()), ...projectSnooze(ncKey, latestGlobalSnoozeWindow(snoozes))], [...enabled]);
6306
+ }
5802
6307
  this.ctx.logger.info("ha-export: reconciled", { meta: {
5803
6308
  reason,
5804
6309
  brokers: enabled.length,
@@ -5832,7 +6337,7 @@ var HaExportAddon = class extends BaseAddon {
5832
6337
  * Returns `null` only when NOTHING answered, so the devices are not
5833
6338
  * announced empty on a hub that is still booting.
5834
6339
  */
5835
- async buildSynthetic() {
6340
+ async buildSynthetic(snoozes) {
5836
6341
  const rules = await this.ctx.api.notificationRules.listRules.query({}).then((r) => r.rules.map((rule) => ({
5837
6342
  id: rule.id,
5838
6343
  name: rule.name,
@@ -5841,15 +6346,9 @@ var HaExportAddon = class extends BaseAddon {
5841
6346
  this.ctx.logger.warn("ha-export: could not read notification rules", { meta: { error: errMsg(err) } });
5842
6347
  return null;
5843
6348
  });
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) => {
6349
+ /** Reuses the ONE `listSnoozes` read the pass already made (Task B1/D4). */
6350
+ const anySnoozed = snoozes.length > 0;
6351
+ const topology = await this.ctx.api.nodes.topology.query().catch((err) => {
5853
6352
  this.ctx.logger.warn("ha-export: could not read the cluster topology", { meta: { error: errMsg(err) } });
5854
6353
  return null;
5855
6354
  });
@@ -5864,16 +6363,92 @@ var HaExportAddon = class extends BaseAddon {
5864
6363
  };
5865
6364
  }).catch(() => null);
5866
6365
  const server = await this.ctx.api.serverManagement.getServerPackageStatus.query().catch(() => null);
5867
- if (rules === null && nodes === null && addons === null && server === null) return null;
6366
+ const alerts = await this.readActiveAlerts();
6367
+ if (rules === null && topology === null && addons === null && server === null) return null;
5868
6368
  this.syntheticRules = rules ?? this.syntheticRules;
6369
+ const { nodes, oldestFootageMs } = await this.buildSyntheticNodes(topology ?? []);
5869
6370
  return {
5870
6371
  rules: rules ?? this.syntheticRules,
5871
- snoozed: snoozes ?? false,
5872
- nodes: nodes ?? [],
6372
+ snoozed: anySnoozed,
6373
+ nodes,
5873
6374
  addonsRunning: addons?.running ?? 0,
5874
6375
  addonsFailed: addons?.failed ?? 0,
5875
6376
  serverVersion: server?.runningVersion ?? server?.activeVersion ?? "unknown",
5876
- updateAvailable: server?.updateAvailable ?? false
6377
+ updateAvailable: server?.updateAvailable ?? false,
6378
+ alertsActive: alerts.active,
6379
+ alertsActiveCount: alerts.count,
6380
+ alertsLastTitle: alerts.lastTitle,
6381
+ oldestFootageMs,
6382
+ imageContractState: server?.imageContract?.state ?? null
6383
+ };
6384
+ }
6385
+ /**
6386
+ * `alerts.list` — the conditions nobody said (Task D1). Best-effort: a
6387
+ * failed read yields "nothing active", not a fabricated absence dressed up
6388
+ * as a clean one — this addon logs the drop rather than pretending the
6389
+ * cluster is quiet. The derivation itself is pure — see
6390
+ * `deriveAlertSummary` in `synthetic-devices.ts`, which is what is tested.
6391
+ */
6392
+ async readActiveAlerts() {
6393
+ try {
6394
+ return deriveAlertSummary(await this.ctx.api.alerts.list.query({
6395
+ unreadOnly: false,
6396
+ limit: 50
6397
+ }));
6398
+ } catch (err) {
6399
+ this.ctx.logger.warn("ha-export: could not read alerts, the alert entities go quiet", { meta: { error: errMsg(err) } });
6400
+ return {
6401
+ active: false,
6402
+ count: 0,
6403
+ lastTitle: null
6404
+ };
6405
+ }
6406
+ }
6407
+ /**
6408
+ * Per-node disk usage (Task D2) and per-AGENT package status (Task D3),
6409
+ * fanned out from the topology this pass already read. Bounded per node —
6410
+ * one unreachable node degrades its own two rows, never the others'.
6411
+ */
6412
+ async buildSyntheticNodes(topology) {
6413
+ let oldestFootageMs = null;
6414
+ return {
6415
+ nodes: await Promise.all(topology.map(async (node) => {
6416
+ const disk = await this.ctx.api.recording.getStorageUsage.query({}, nodePin(node.id)).catch((err) => {
6417
+ this.ctx.logger.warn("ha-export: could not read storage usage for a node, its disk entities go unknown", { meta: {
6418
+ nodeId: node.id,
6419
+ error: errMsg(err)
6420
+ } });
6421
+ return null;
6422
+ });
6423
+ const freePercents = (disk?.locations ?? []).filter((loc) => loc.availableBytes !== null && loc.totalBytes !== null && loc.totalBytes > 0).map((loc) => loc.availableBytes / loc.totalBytes * 100);
6424
+ const footageFreePercent = freePercents.length > 0 ? Math.min(...freePercents) : null;
6425
+ for (const device of disk?.devices ?? []) {
6426
+ const oldest = device.oldestMs;
6427
+ if (oldest === null || oldest === void 0) continue;
6428
+ if (oldestFootageMs === null || oldest < oldestFootageMs) oldestFootageMs = oldest;
6429
+ }
6430
+ const packageStatus = node.isHub ? null : await this.ctx.api.serverManagement.getServerPackageStatus.query({ nodeId: node.id }).catch((err) => {
6431
+ this.ctx.logger.warn("ha-export: could not read package status for an agent node, its update entities go unknown", { meta: {
6432
+ nodeId: node.id,
6433
+ error: errMsg(err)
6434
+ } });
6435
+ return null;
6436
+ });
6437
+ return {
6438
+ id: node.id,
6439
+ name: node.name,
6440
+ online: node.isOnline,
6441
+ cpuPercent: node.cpuPercent,
6442
+ memoryPercent: node.memoryPercent,
6443
+ uptime: node.uptime,
6444
+ isHub: node.isHub,
6445
+ footageUsedBytes: disk?.totalUsedBytes ?? null,
6446
+ footageFreePercent,
6447
+ rootPackageVersion: node.rootPackage?.version ?? null,
6448
+ updateAvailable: packageStatus?.updateAvailable ?? null
6449
+ };
6450
+ })),
6451
+ oldestFootageMs
5877
6452
  };
5878
6453
  }
5879
6454
  /** Push the synthetic values to every enabled broker. */
@@ -5901,9 +6476,9 @@ var HaExportAddon = class extends BaseAddon {
5901
6476
  }
5902
6477
  }
5903
6478
  }
5904
- async pushFullState(exported, snapshots) {
6479
+ async pushFullState(exported, snapshots, snoozes) {
5905
6480
  for (const entry of exported.values()) try {
5906
- const values = await this.currentStateFor(entry, snapshots[String(entry.deviceId)]);
6481
+ const values = await this.currentStateFor(entry, snapshots[String(entry.deviceId)], snoozes);
5907
6482
  this.push(entry.deviceId, values);
5908
6483
  } catch (err) {
5909
6484
  this.ctx.logger.warn("ha-export: could not read current state for a device", {
@@ -5914,7 +6489,7 @@ var HaExportAddon = class extends BaseAddon {
5914
6489
  for (const link of this.links.values()) await link.client.flush();
5915
6490
  }
5916
6491
  /** Everything readable without waiting for an event. */
5917
- async currentStateFor(entry, snapshotRaw) {
6492
+ async currentStateFor(entry, snapshotRaw, snoozes) {
5918
6493
  const key = entry.plan.deviceKey;
5919
6494
  const values = [];
5920
6495
  const snapshot = toSnapshot(snapshotRaw);
@@ -5984,13 +6559,26 @@ var HaExportAddon = class extends BaseAddon {
5984
6559
  * video itself. The url this block used to publish was read by
5985
6560
  * nobody — see `camera-entities.ts`.
5986
6561
  */
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
- }))));
6562
+ values.push(...projectCameraFunctions(key, entry.cameraFunctions ?? []));
6563
+ /**
6564
+ * `status` / `recording_active` (Traccia C) — off the SAME
6565
+ * `CameraStatus` `runReconcile` already read once for the whole pass
6566
+ * (Task C3). A camera the batch read did not cover (the call failed
6567
+ * entirely, or a camera added mid-pass) publishes NEITHER: an invented
6568
+ * `ok` is the D62 failure this entity exists to prevent.
6569
+ */
6570
+ const cameraStatus = this.cameraStatusByDeviceId.get(entry.deviceId);
6571
+ if (cameraStatus !== void 0) values.push(...projectCameraStatus(key, toCameraStatusInput(cameraStatus)));
6572
+ /**
6573
+ * `snooze` (Task B1) — reconstructed from `notificationRules.listSnoozes`,
6574
+ * never stored: the select's state is the SAME authority the write path
6575
+ * (`applySnooze`) already uses.
6576
+ */
6577
+ const snoozeValues = projectSnooze(key, latestDeviceSnoozeWindow(snoozes, entry.deviceId));
6578
+ if (snoozeValues.length === 0) {
6579
+ 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 } });
6580
+ }
6581
+ values.push(...snoozeValues);
5994
6582
  const occupancy = await this.ctx.api.zoneAnalytics.getCurrentSnapshot.query({ deviceId: entry.deviceId });
5995
6583
  if (occupancy !== null) values.push(...projectZoneOccupancy(key, {
5996
6584
  zones: occupancy.zones.map((zone) => ({
@@ -6006,11 +6594,20 @@ var HaExportAddon = class extends BaseAddon {
6006
6594
  }
6007
6595
  return values;
6008
6596
  }
6009
- async buildCatalogDevice(device, snapshotRaw) {
6597
+ async buildCatalogDevice(device, snapshotRaw, cameraFunctions) {
6010
6598
  const snapshot = toSnapshot(snapshotRaw);
6011
6599
  const boundCaps = await this.loadBoundCaps(device.id);
6012
6600
  const zones = device.type === "camera" ? await this.loadZones(device.id) : [];
6013
- const switches = device.type === "camera" ? await this.loadSwitches(device.id) : [];
6601
+ /**
6602
+ * Every camera function, available or not — `entity-catalog.ts` is the
6603
+ * one place that filters to `available`, exactly as it did for
6604
+ * `getCameraSwitches`'s answer.
6605
+ */
6606
+ const switches = (cameraFunctions ?? []).map((fn) => ({
6607
+ id: fn.id,
6608
+ label: fn.label,
6609
+ available: fn.available
6610
+ }));
6014
6611
  const ptzPresets = device.type === "camera" && boundCaps.includes("ptz") ? await this.loadPresets(device.id) : [];
6015
6612
  const streams = device.type === "camera" && boundCaps.includes("webrtc-session") ? await this.loadStreamChoices(device.id) : [];
6016
6613
  const manufacturer = readString(device.metadata ?? {}, "manufacturer");
@@ -6096,25 +6693,118 @@ var HaExportAddon = class extends BaseAddon {
6096
6693
  return [];
6097
6694
  }
6098
6695
  }
6099
- async loadSwitches(deviceId) {
6696
+ /**
6697
+ * `pipelineOrchestrator.getCameraStatuses`, for every exported camera in
6698
+ * ONE call (Task C3). Best-effort for the WHOLE batch: a failure here means
6699
+ * `status`, `recording_active` and every `broker-audio` control degrade for
6700
+ * this pass — never a value invented from a previous pass's answer.
6701
+ */
6702
+ async readCameraStatuses(deviceIds) {
6703
+ if (deviceIds.length === 0) return /* @__PURE__ */ new Map();
6704
+ const startedAt = Date.now();
6100
6705
  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
- }));
6706
+ const statuses = await this.ctx.api.pipelineOrchestrator.getCameraStatuses.query({ deviceIds: [...deviceIds] });
6707
+ this.ctx.logger.debug("ha-export: read camera statuses for the pass", { meta: {
6708
+ cameraStatusMs: Date.now() - startedAt,
6709
+ cameras: deviceIds.length
6710
+ } });
6711
+ return new Map(statuses.map((status) => [status.deviceId, status]));
6106
6712
  } 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", {
6713
+ 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: {
6714
+ error: errMsg(err),
6715
+ cameras: deviceIds.length
6716
+ } });
6717
+ return /* @__PURE__ */ new Map();
6718
+ }
6719
+ }
6720
+ /**
6721
+ * The eight per-camera function authorities (Task A1), assembled into
6722
+ * {@link CameraFunctionReads}. Every source degrades INDEPENDENTLY with a
6723
+ * WARN carrying `tags: { deviceId }` — a dropped authority must never look
6724
+ * like "this function does not exist" (D62).
6725
+ *
6726
+ * `bindableCapsByType`/`mutedDeviceIds` are the reconcile pass's shared
6727
+ * caches when the caller has them (a per-TYPE lookup and a flat list read
6728
+ * once for the whole pass); omitted, each is read fresh — the one-off
6729
+ * command path (`dispatch`) has no pass to share.
6730
+ */
6731
+ async readCameraFunctions(input) {
6732
+ const { deviceId, deviceType, deviceDisabled, privacySlice, sourceNodeId } = input;
6733
+ const bindableCache = input.bindableCapsByType;
6734
+ let bindableCapNames = bindableCache?.get(deviceType);
6735
+ if (bindableCache === void 0 || bindableCapNames === void 0) {
6736
+ bindableCapNames = await this.ctx.api.deviceManager.listBindableCapsForDeviceType.query({ deviceType }).then((entries) => entries.filter((e) => e.wrappers.length > 0).map((e) => e.capName)).catch((err) => {
6737
+ this.ctx.logger.warn("ha-export: could not read bindable caps for a device type, camera functions degrade", {
6738
+ tags: { deviceId },
6739
+ meta: {
6740
+ deviceType,
6741
+ error: errMsg(err)
6742
+ }
6743
+ });
6744
+ return null;
6745
+ });
6746
+ bindableCache?.set(deviceType, bindableCapNames);
6747
+ }
6748
+ const wrapperAddonIdByCap = /* @__PURE__ */ new Map();
6749
+ const wrappedCapNames = await this.ctx.api.deviceManager.getBindings.query({ deviceId }).then((bindings) => {
6750
+ const active = [];
6751
+ for (const entry of bindings.entries) {
6752
+ if (entry.kind !== "wrapped") continue;
6753
+ active.push(entry.capName);
6754
+ if (entry.providerAddonId !== "") wrapperAddonIdByCap.set(entry.capName, entry.providerAddonId);
6755
+ }
6756
+ return active;
6757
+ }).catch((err) => {
6758
+ this.ctx.logger.warn("ha-export: could not read bindings, camera functions degrade", {
6113
6759
  tags: { deviceId },
6114
6760
  meta: { error: errMsg(err) }
6115
6761
  });
6116
- return [];
6117
- }
6762
+ return null;
6763
+ });
6764
+ const recordingEnabled = await this.ctx.api.recording.getDeviceConfig.query({ deviceId }).then((config) => config.enabled).catch((err) => {
6765
+ this.ctx.logger.warn("ha-export: could not read recording config, the recording function degrades", {
6766
+ tags: { deviceId },
6767
+ meta: { error: errMsg(err) }
6768
+ });
6769
+ return null;
6770
+ });
6771
+ const mutedDeviceIds = input.mutedDeviceIds !== void 0 ? input.mutedDeviceIds : await this.ctx.api.notificationRules.listDeviceMutes.query({}).then((r) => r.mutedDeviceIds).catch((err) => {
6772
+ this.ctx.logger.warn("ha-export: could not read notification mutes, the notifications function degrades", {
6773
+ tags: { deviceId },
6774
+ meta: { error: errMsg(err) }
6775
+ });
6776
+ return null;
6777
+ });
6778
+ const notificationsMuted = mutedDeviceIds === null ? null : mutedDeviceIds.includes(deviceId);
6779
+ /**
6780
+ * The `privacy-mask` slice already in the snapshot this pass downloaded
6781
+ * (D224: a camera fact's freshness lives in the provider that owns it) —
6782
+ * zero new dialers to the camera.
6783
+ */
6784
+ const privacy = privacySlice === void 0 ? null : {
6785
+ maskEnabled: privacySlice["enabled"] === true,
6786
+ audioEnabled: typeof privacySlice["audioEnabled"] === "boolean" ? privacySlice["audioEnabled"] : null
6787
+ };
6788
+ const brokerAudioMuted = sourceNodeId === null ? null : await this.ctx.api.streamBroker.getDeviceAudioMute.query({ deviceId }, nodePin(sourceNodeId)).then((r) => r.muted).catch((err) => {
6789
+ this.ctx.logger.warn("ha-export: could not read broker audio mute, the broker-audio function degrades", {
6790
+ tags: { deviceId },
6791
+ meta: {
6792
+ sourceNodeId,
6793
+ error: errMsg(err)
6794
+ }
6795
+ });
6796
+ return null;
6797
+ });
6798
+ return {
6799
+ deviceDisabled,
6800
+ bindableCapNames,
6801
+ wrappedCapNames,
6802
+ wrapperAddonIdByCap,
6803
+ recordingEnabled,
6804
+ notificationsMuted,
6805
+ privacy,
6806
+ brokerAudioMuted
6807
+ };
6118
6808
  }
6119
6809
  async loadPresets(deviceId) {
6120
6810
  try {
@@ -6367,6 +7057,38 @@ var HaExportAddon = class extends BaseAddon {
6367
7057
  * disagree with the admin UI is worse than no knob (D62).
6368
7058
  */
6369
7059
  if (isSyntheticDeviceKey(parsed.deviceKey)) {
7060
+ /**
7061
+ * The global snooze select (Task D4) — the OTHER control this branch
7062
+ * routes, alongside the per-rule switches below. Same rule as the
7063
+ * camera's own snooze (`applySnooze`): the export writes
7064
+ * `createSnooze`/`cancelSnooze` directly, `scope: 'all'`, never a
7065
+ * store of its own.
7066
+ */
7067
+ if (parsed.deviceKey === deviceKeyFor("synthetic-notification-center") && parsed.entity === "snooze") {
7068
+ if (!SNOOZE_OPTIONS.includes(value)) {
7069
+ this.ctx.logger.warn("ha-export: dropping a global snooze command, unknown option", { meta: {
7070
+ topic,
7071
+ value
7072
+ } });
7073
+ reply.status(422);
7074
+ reply.send({ error: "unroutable command" });
7075
+ return;
7076
+ }
7077
+ const minutes = SNOOZE_MINUTES[value] ?? null;
7078
+ try {
7079
+ await this.applyGlobalSnooze(minutes);
7080
+ reply.status(200);
7081
+ reply.send({});
7082
+ } catch (err) {
7083
+ this.ctx.logger.warn("ha-export: could not apply the global snooze from Home Assistant", { meta: {
7084
+ minutes,
7085
+ error: errMsg(err)
7086
+ } });
7087
+ reply.status(422);
7088
+ reply.send({ error: "command not applied" });
7089
+ }
7090
+ return;
7091
+ }
6370
7092
  const ruleId = ruleIdFromEntity(parsed.entity, this.syntheticRules);
6371
7093
  if (ruleId === null) {
6372
7094
  this.ctx.logger.warn("ha-export: dropping a synthetic command with no authority behind it", { meta: {
@@ -6452,20 +7174,62 @@ var HaExportAddon = class extends BaseAddon {
6452
7174
  return false;
6453
7175
  }
6454
7176
  switch (command.kind) {
6455
- case "camera-switch": {
7177
+ case "camera-function": {
6456
7178
  const parsed = CameraSwitchIdSchema.safeParse(command.switchId);
6457
7179
  if (!parsed.success) {
6458
- this.ctx.logger.warn("ha-export: dropping a command for an unknown camera switch", {
7180
+ this.ctx.logger.warn("ha-export: dropping a command for an unknown camera function", {
6459
7181
  tags: { deviceId: command.deviceId },
6460
7182
  meta: { switchId: command.switchId }
6461
7183
  });
6462
7184
  return false;
6463
7185
  }
6464
- await this.ctx.api.pipelineOrchestrator.setCameraSwitch.mutate({
7186
+ /**
7187
+ * A FRESH read for this one device — the pass-level caches are up to
7188
+ * `reconcileIntervalSec` old, and an availability decision that
7189
+ * writes belongs to the moment of the command, not the last poll.
7190
+ */
7191
+ const device = await this.ctx.api.deviceManager.getDevice.query({ deviceId: command.deviceId });
7192
+ if (device === null) {
7193
+ this.ctx.logger.warn("ha-export: dropping a camera-function command, the device is not in the registry", { tags: { deviceId: command.deviceId } });
7194
+ return false;
7195
+ }
7196
+ const privacySlice = await this.ctx.api.deviceState.getCapSlice.query({
6465
7197
  deviceId: command.deviceId,
6466
- switchId: parsed.data,
6467
- enabled: command.enabled
7198
+ capName: "privacy-mask"
7199
+ }).catch((err) => {
7200
+ this.ctx.logger.warn("ha-export: could not read the privacy slice for a camera-function command", {
7201
+ tags: { deviceId: command.deviceId },
7202
+ meta: { error: errMsg(err) }
7203
+ });
7204
+ return null;
6468
7205
  });
7206
+ /**
7207
+ * The source node comes from the last reconcile's `CameraStatus`
7208
+ * batch (Task C3) rather than a fresh per-command call: it changes
7209
+ * rarely, and only `broker-audio` needs it — a stale pin degrades
7210
+ * that ONE function for this command, not the other seven.
7211
+ */
7212
+ const sourceNodeId = this.cameraStatusByDeviceId.get(command.deviceId)?.assignment.sourceNodeId ?? null;
7213
+ const reads = await this.readCameraFunctions({
7214
+ deviceId: command.deviceId,
7215
+ deviceType: device.type,
7216
+ deviceDisabled: device.disabled,
7217
+ privacySlice: privacySlice ?? void 0,
7218
+ sourceNodeId
7219
+ });
7220
+ const write = resolveCameraFunctionWrite(parsed.data, command.enabled, reads);
7221
+ if (write === null) {
7222
+ /**
7223
+ * Refused, never approximated. No optimistic ack: the next
7224
+ * reconcile corrects Home Assistant's toggle back to reality.
7225
+ */
7226
+ this.ctx.logger.warn("ha-export: dropping a camera-function command, the function is not available", {
7227
+ tags: { deviceId: command.deviceId },
7228
+ meta: { switchId: parsed.data }
7229
+ });
7230
+ return false;
7231
+ }
7232
+ await applyCameraFunctionWrite(this.ctx.api, command.deviceId, write, sourceNodeId);
6469
7233
  return true;
6470
7234
  }
6471
7235
  case "reboot": {
@@ -6529,19 +7293,69 @@ var HaExportAddon = class extends BaseAddon {
6529
7293
  tags: { deviceId },
6530
7294
  meta: { cancelled: mine.length }
6531
7295
  });
6532
- return true;
7296
+ } else {
7297
+ await this.ctx.api.notificationRules.createSnooze.mutate({ snooze: {
7298
+ scope: "device",
7299
+ deviceId,
7300
+ durationMinutes: minutes
7301
+ } });
7302
+ this.ctx.logger.info("ha-export: snoozed from Home Assistant", {
7303
+ tags: { deviceId },
7304
+ meta: { minutes }
7305
+ });
6533
7306
  }
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
- });
7307
+ /**
7308
+ * Republish right away — the select is otherwise stale for up to
7309
+ * `reconcileIntervalSec` after a write Home Assistant itself made
7310
+ * (Task B1 passo 4).
7311
+ */
7312
+ await this.republishSnooze(deviceId);
6543
7313
  return true;
6544
7314
  }
7315
+ /** Push `snooze`'s current state for one camera, outside the reconcile cadence. */
7316
+ async republishSnooze(deviceId) {
7317
+ const key = this.keyByDeviceId.get(deviceId);
7318
+ if (key === void 0) return;
7319
+ try {
7320
+ const { snoozes } = await this.ctx.api.notificationRules.listSnoozes.query({});
7321
+ this.push(deviceId, projectSnooze(key, latestDeviceSnoozeWindow(snoozes, deviceId)));
7322
+ } catch (err) {
7323
+ this.ctx.logger.warn("ha-export: could not republish snooze after a write from Home Assistant", {
7324
+ tags: { deviceId },
7325
+ meta: { error: errMsg(err) }
7326
+ });
7327
+ }
7328
+ }
7329
+ /**
7330
+ * The global `select` on *Notification Center* (Task D4) — `scope: 'all'`,
7331
+ * the same authority `applySnooze` already writes for a camera. `Off`
7332
+ * cancels every global snooze, exactly as a camera's `Off` cancels its own.
7333
+ */
7334
+ async applyGlobalSnooze(minutes) {
7335
+ if (minutes === null) {
7336
+ const { snoozes } = await this.ctx.api.notificationRules.listSnoozes.query({});
7337
+ const mine = snoozes.filter((snooze) => snooze.scope === "all");
7338
+ for (const snooze of mine) await this.ctx.api.notificationRules.cancelSnooze.mutate({ snoozeId: snooze.id });
7339
+ this.ctx.logger.info("ha-export: cancelled the global snooze from Home Assistant", { meta: { cancelled: mine.length } });
7340
+ } else {
7341
+ await this.ctx.api.notificationRules.createSnooze.mutate({ snooze: {
7342
+ scope: "all",
7343
+ durationMinutes: minutes
7344
+ } });
7345
+ this.ctx.logger.info("ha-export: snoozed globally from Home Assistant", { meta: { minutes } });
7346
+ }
7347
+ await this.republishGlobalSnooze();
7348
+ }
7349
+ /** Push the global `snooze` select's current state, outside the reconcile cadence. */
7350
+ async republishGlobalSnooze() {
7351
+ try {
7352
+ const { snoozes } = await this.ctx.api.notificationRules.listSnoozes.query({});
7353
+ const ncKey = deviceKeyFor(NOTIFICATION_CENTER_STABLE_ID);
7354
+ await this.pushSynthetic(projectSnooze(ncKey, latestGlobalSnoozeWindow(snoozes)), this.enabledBrokerIds());
7355
+ } catch (err) {
7356
+ this.ctx.logger.warn("ha-export: could not republish the global snooze after a write from Home Assistant", { meta: { error: errMsg(err) } });
7357
+ }
7358
+ }
6545
7359
  /**
6546
7360
  * A public, signed, expiring route Home Assistant can fetch bytes from.
6547
7361
  *
@@ -6934,6 +7748,37 @@ function toSnapshot(raw) {
6934
7748
  for (const [cap, slice] of Object.entries(raw)) if (slice !== null && typeof slice === "object") out[cap] = { ...slice };
6935
7749
  return out;
6936
7750
  }
7751
+ /** `CameraStatus` → the narrower shape `projectCameraStatus` needs. */
7752
+ function toCameraStatusInput(status) {
7753
+ return {
7754
+ switchedOff: status.switchedOff,
7755
+ degradedStages: status.degraded.map((d) => d.stage),
7756
+ recording: status.recording === null ? null : { active: status.recording.active }
7757
+ };
7758
+ }
7759
+ /**
7760
+ * The ACTIVE window for one scope, from a flat `listSnoozes` answer.
7761
+ *
7762
+ * "Active" is `expiresAt` in the future — `listSnoozes` never sweeps expired
7763
+ * rows (expiry is a comparison, not a job), so a reader that skipped this
7764
+ * would keep publishing a duration that ended hours ago. When several windows
7765
+ * match, the one expiring LATEST wins: that is the duration still in effect.
7766
+ */
7767
+ function latestSnoozeWindow(snoozes, matches, now) {
7768
+ const latest = snoozes.filter((s) => matches(s) && s.expiresAt > now).reduce((top, s) => top === null || s.expiresAt > top.expiresAt ? s : top, null);
7769
+ return latest === null ? null : {
7770
+ startedAt: latest.startedAt,
7771
+ expiresAt: latest.expiresAt
7772
+ };
7773
+ }
7774
+ /** The camera's own `scope: 'device'` snoozes (Task B1). */
7775
+ function latestDeviceSnoozeWindow(snoozes, deviceId) {
7776
+ return latestSnoozeWindow(snoozes, (s) => s.scope === "device" && s.deviceId === deviceId, Date.now());
7777
+ }
7778
+ /** The global `scope: 'all'` snooze (Task D4). */
7779
+ function latestGlobalSnoozeWindow(snoozes) {
7780
+ return latestSnoozeWindow(snoozes, (s) => s.scope === "all", Date.now());
7781
+ }
6937
7782
  function toCountRecord(raw) {
6938
7783
  if (raw === null || typeof raw !== "object") return {};
6939
7784
  const out = {};