@camstack/addon-provider-homeassistant 1.2.80 → 1.2.81

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.
@@ -1075,31 +1075,6 @@ var ZONE_MACROS = [
1075
1075
  * already answer for automation), the per-zone timestamps, and the
1076
1076
  * doorbell's since-restart counter.
1077
1077
  */
1078
- /**
1079
- * The snooze surface, as a `select`.
1080
- *
1081
- * `NC_SNOOZE_MAX_MINUTES` is 1440 server-side and there is no enum of
1082
- * preset durations, so the presets are chosen here. `Off` cancels.
1083
- */
1084
- var SNOOZE_OPTIONS = [
1085
- "Off",
1086
- "15 minutes",
1087
- "30 minutes",
1088
- "1 hour",
1089
- "2 hours",
1090
- "4 hours",
1091
- "8 hours",
1092
- "24 hours"
1093
- ];
1094
- var SNOOZE_MINUTES = {
1095
- "15 minutes": 15,
1096
- "30 minutes": 30,
1097
- "1 hour": 60,
1098
- "2 hours": 120,
1099
- "4 hours": 240,
1100
- "8 hours": 480,
1101
- "24 hours": 1440
1102
- };
1103
1078
  var PTZ_BUTTONS = [
1104
1079
  "ptz_up",
1105
1080
  "ptz_down",
@@ -1504,14 +1479,6 @@ function cameraSpecs(device) {
1504
1479
  deviceClass: "running",
1505
1480
  enabledByDefault: true
1506
1481
  });
1507
- specs.push({
1508
- entity: "snooze",
1509
- platform: "select",
1510
- label: "Snooze notifications",
1511
- writable: true,
1512
- options: SNOOZE_OPTIONS,
1513
- enabledByDefault: true
1514
- });
1515
1482
  for (const macro of EXPORTED_MACROS) {
1516
1483
  const label = humanise(macro);
1517
1484
  specs.push({
@@ -3671,21 +3638,6 @@ function resolveCommand(target, entity, value) {
3671
3638
  }
3672
3639
  };
3673
3640
  }
3674
- if (entity === "snooze") {
3675
- if (!SNOOZE_OPTIONS.includes(value)) return {
3676
- ok: false,
3677
- reason: "bad-value"
3678
- };
3679
- const minutes = SNOOZE_MINUTES[value];
3680
- return {
3681
- ok: true,
3682
- command: {
3683
- kind: "snooze",
3684
- deviceId: target.deviceId,
3685
- minutes: minutes ?? null
3686
- }
3687
- };
3688
- }
3689
3641
  return {
3690
3642
  ok: false,
3691
3643
  reason: "unknown-entity"
@@ -3784,8 +3736,7 @@ function isCameraCommand(command) {
3784
3736
  case "camera-function":
3785
3737
  case "reboot":
3786
3738
  case "ptz-move":
3787
- case "ptz-preset":
3788
- case "snooze": return true;
3739
+ case "ptz-preset": return true;
3789
3740
  default: return false;
3790
3741
  }
3791
3742
  }
@@ -5127,38 +5078,6 @@ function projectAudioWindow(deviceKey, input) {
5127
5078
  });
5128
5079
  return values;
5129
5080
  }
5130
- /** Inverse of `SNOOZE_MINUTES` — a minute count back to the option label. */
5131
- var SNOOZE_OPTION_BY_MINUTES = new Map(Object.entries(SNOOZE_MINUTES).map(([option, minutes]) => [minutes, option]));
5132
- /**
5133
- * The `snooze` select's STATE. The authority is
5134
- * `notificationRules.listSnoozes`, which carries `startedAt`/`expiresAt` but
5135
- * not the preset label an operator picked — there is nowhere to store that
5136
- * label, and there must not be: it is reconstructed from the two timestamps
5137
- * every time, so it can never drift from the authority.
5138
- *
5139
- * No active snooze publishes `Off`, never silence: Home Assistant reads a
5140
- * missing state update as "the last value still holds", so a select gone
5141
- * quiet after a snooze expires would keep showing the expired duration
5142
- * forever.
5143
- *
5144
- * A duration the select's closed vocabulary cannot express — a snooze the
5145
- * viewer created with an arbitrary minute count — publishes NOTHING.
5146
- * Rounding it to the nearest preset would let Home Assistant re-send a
5147
- * duration the operator never chose; `unknown` is the honest state.
5148
- */
5149
- function projectSnooze(deviceKey, window) {
5150
- if (window === null) return [{
5151
- topic: stateTopic(deviceKey, "snooze"),
5152
- value: "Off"
5153
- }];
5154
- const minutes = Math.round((window.expiresAt - window.startedAt) / 6e4);
5155
- const option = SNOOZE_OPTION_BY_MINUTES.get(minutes);
5156
- if (option === void 0) return [];
5157
- return [{
5158
- topic: stateTopic(deviceKey, "snooze"),
5159
- value: option
5160
- }];
5161
- }
5162
5081
  /**
5163
5082
  * `status` — the entity D62 exists for. A camera an operator switched off and
5164
5083
  * a camera that is genuinely broken must never paint the same picture in Home
@@ -5317,12 +5236,6 @@ function ruleTriggerEntity(ruleId, suffix) {
5317
5236
  }
5318
5237
  function notificationCenterSpecs(input) {
5319
5238
  return [
5320
- {
5321
- entity: "snoozed",
5322
- platform: "binary_sensor",
5323
- label: "Notifications snoozed",
5324
- icon: "mdi:bell-sleep"
5325
- },
5326
5239
  {
5327
5240
  entity: "rules_enabled",
5328
5241
  platform: "sensor",
@@ -5332,21 +5245,6 @@ function notificationCenterSpecs(input) {
5332
5245
  },
5333
5246
  (
5334
5247
  /**
5335
- * The GLOBAL snooze, as a control (Task D4) — the half `snoozed` (above)
5336
- * never had. Its state is NOT projected here: `ha-export.addon.ts` pushes
5337
- * it through the SAME `projectSnooze` a camera's snooze uses, so the rule
5338
- * lives in one place — two expressions of it in two files is how
5339
- * `audio_detected` went mute for a year.
5340
- */
5341
- {
5342
- entity: "snooze",
5343
- platform: "select",
5344
- label: "Snooze all notifications",
5345
- writable: true,
5346
- options: SNOOZE_OPTIONS
5347
- }),
5348
- (
5349
- /**
5350
5248
  * The last trigger, six entities: five facts plus its picture — "conoscere
5351
5249
  * gli ultimi trigger, le ultime immagini... e da quale label" (operator,
5352
5250
  * 2026-08-25). ENABLED by default like every other entity on this device
@@ -5667,10 +5565,6 @@ function projectSynthetic(input, nowMs) {
5667
5565
  const srv = syntheticDeviceKeyFor(SERVER_STABLE_ID);
5668
5566
  const online = input.nodes.filter((node) => node.online);
5669
5567
  const values = [
5670
- {
5671
- topic: stateTopic(nc, "snoozed"),
5672
- value: String(input.snoozed)
5673
- },
5674
5568
  {
5675
5569
  topic: stateTopic(nc, "rules_enabled"),
5676
5570
  value: String(input.rules.filter((rule) => rule.enabled).length)
@@ -6506,16 +6400,6 @@ var HaExportAddon = class extends require_dist.BaseAddon {
6506
6400
  });
6507
6401
  /** `deviceManager.listBindableCapsForDeviceType` — once per TYPE, not per device. */
6508
6402
  const bindableCapsByType = /* @__PURE__ */ new Map();
6509
- /**
6510
- * `notificationRules.listSnoozes` — ONE read for the whole pass, shared
6511
- * by every camera's `snooze` select (Task B1) and the synthetic
6512
- * `snooze` select on *Notification Center* (Task D4): two expressions
6513
- * of the same read is how `audio_detected` went mute for a year.
6514
- */
6515
- const snoozes = await this.ctx.api.notificationRules.listSnoozes.query({}).then((r) => r.snoozes).catch((err) => {
6516
- this.ctx.logger.warn("ha-export: could not read snoozes, snooze selects go unknown", { meta: { error: errMsg(err) } });
6517
- return [];
6518
- });
6519
6403
  const exported = /* @__PURE__ */ new Map();
6520
6404
  const keyByDeviceId = /* @__PURE__ */ new Map();
6521
6405
  const allCaps = /* @__PURE__ */ new Set();
@@ -6608,7 +6492,7 @@ var HaExportAddon = class extends require_dist.BaseAddon {
6608
6492
  * `brokerIds` is every enabled broker, because they belong to the server
6609
6493
  * and not to a membership the operator picks per camera.
6610
6494
  */
6611
- const synthetic = await this.buildSynthetic(snoozes, byId);
6495
+ const synthetic = await this.buildSynthetic(byId);
6612
6496
  if (synthetic !== null) for (const plan of syntheticPlans(synthetic)) {
6613
6497
  exported.set(plan.deviceKey, {
6614
6498
  plan,
@@ -6648,16 +6532,8 @@ var HaExportAddon = class extends require_dist.BaseAddon {
6648
6532
  }
6649
6533
  });
6650
6534
  await this.announce(exported);
6651
- await this.pushFullState(exported, snapshots, snoozes);
6652
- if (synthetic !== null) {
6653
- /**
6654
- * The global snooze's STATE (Task D4) rides the SAME `projectSnooze`
6655
- * a camera's `snooze` select uses, over the SAME `snoozes` read this
6656
- * pass already made — one expression of the rule, not two.
6657
- */
6658
- const ncKey = syntheticDeviceKeyFor(NOTIFICATION_CENTER_STABLE_ID);
6659
- await this.pushSynthetic([...projectSynthetic(synthetic, Date.now()), ...projectSnooze(ncKey, latestGlobalSnoozeWindow(snoozes))], [...enabled]);
6660
- }
6535
+ await this.pushFullState(exported, snapshots);
6536
+ if (synthetic !== null) await this.pushSynthetic(projectSynthetic(synthetic, Date.now()), [...enabled]);
6661
6537
  this.ctx.logger.info("ha-export: reconciled", { meta: {
6662
6538
  reason,
6663
6539
  brokers: enabled.length,
@@ -6691,7 +6567,7 @@ var HaExportAddon = class extends require_dist.BaseAddon {
6691
6567
  * Returns `null` only when NOTHING answered, so the devices are not
6692
6568
  * announced empty on a hub that is still booting.
6693
6569
  */
6694
- async buildSynthetic(snoozes, byId) {
6570
+ async buildSynthetic(byId) {
6695
6571
  const rules = await this.ctx.api.notificationRules.listRules.query({}).then((r) => r.rules.map((rule) => ({
6696
6572
  id: rule.id,
6697
6573
  name: rule.name,
@@ -6700,8 +6576,6 @@ var HaExportAddon = class extends require_dist.BaseAddon {
6700
6576
  this.ctx.logger.warn("ha-export: could not read notification rules", { meta: { error: errMsg(err) } });
6701
6577
  return null;
6702
6578
  });
6703
- /** Reuses the ONE `listSnoozes` read the pass already made (Task B1/D4). */
6704
- const anySnoozed = snoozes.length > 0;
6705
6579
  const topology = await this.ctx.api.nodes.topology.query().catch((err) => {
6706
6580
  this.ctx.logger.warn("ha-export: could not read the cluster topology", { meta: { error: errMsg(err) } });
6707
6581
  return null;
@@ -6725,7 +6599,6 @@ var HaExportAddon = class extends require_dist.BaseAddon {
6725
6599
  const { nodes, oldestFootageMs } = await this.buildSyntheticNodes(topology ?? []);
6726
6600
  return {
6727
6601
  rules: effectiveRules,
6728
- snoozed: anySnoozed,
6729
6602
  lastTrigger,
6730
6603
  lastTriggerByRule,
6731
6604
  nodes,
@@ -6907,9 +6780,9 @@ var HaExportAddon = class extends require_dist.BaseAddon {
6907
6780
  }
6908
6781
  }
6909
6782
  }
6910
- async pushFullState(exported, snapshots, snoozes) {
6783
+ async pushFullState(exported, snapshots) {
6911
6784
  for (const entry of exported.values()) try {
6912
- const values = await this.currentStateFor(entry, snapshots[String(entry.deviceId)], snoozes);
6785
+ const values = await this.currentStateFor(entry, snapshots[String(entry.deviceId)]);
6913
6786
  this.push(entry.deviceId, values);
6914
6787
  } catch (err) {
6915
6788
  this.ctx.logger.warn("ha-export: could not read current state for a device", {
@@ -6920,7 +6793,7 @@ var HaExportAddon = class extends require_dist.BaseAddon {
6920
6793
  for (const link of this.links.values()) await link.client.flush();
6921
6794
  }
6922
6795
  /** Everything readable without waiting for an event. */
6923
- async currentStateFor(entry, snapshotRaw, snoozes) {
6796
+ async currentStateFor(entry, snapshotRaw) {
6924
6797
  const key = entry.plan.deviceKey;
6925
6798
  const values = [];
6926
6799
  const snapshot = toSnapshot(snapshotRaw);
@@ -7000,16 +6873,6 @@ var HaExportAddon = class extends require_dist.BaseAddon {
7000
6873
  */
7001
6874
  const cameraStatus = this.cameraStatusByDeviceId.get(entry.deviceId);
7002
6875
  if (cameraStatus !== void 0) values.push(...projectCameraStatus(key, toCameraStatusInput(cameraStatus)));
7003
- /**
7004
- * `snooze` (Task B1) — reconstructed from `notificationRules.listSnoozes`,
7005
- * never stored: the select's state is the SAME authority the write path
7006
- * (`applySnooze`) already uses.
7007
- */
7008
- const snoozeValues = projectSnooze(key, latestDeviceSnoozeWindow(snoozes, entry.deviceId));
7009
- if (snoozeValues.length === 0) {
7010
- 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 } });
7011
- }
7012
- values.push(...snoozeValues);
7013
6876
  const occupancy = await this.ctx.api.zoneAnalytics.getCurrentSnapshot.query({ deviceId: entry.deviceId });
7014
6877
  if (occupancy !== null) values.push(...projectZoneOccupancy(key, {
7015
6878
  zones: occupancy.zones.map((zone) => ({
@@ -7501,38 +7364,6 @@ var HaExportAddon = class extends require_dist.BaseAddon {
7501
7364
  * disagree with the admin UI is worse than no knob (D62).
7502
7365
  */
7503
7366
  if (isSyntheticDeviceKey(parsed.deviceKey)) {
7504
- /**
7505
- * The global snooze select (Task D4) — the OTHER control this branch
7506
- * routes, alongside the per-rule switches below. Same rule as the
7507
- * camera's own snooze (`applySnooze`): the export writes
7508
- * `createSnooze`/`cancelSnooze` directly, `scope: 'all'`, never a
7509
- * store of its own.
7510
- */
7511
- if (parsed.deviceKey === syntheticDeviceKeyFor("synthetic-notification-center") && parsed.entity === "snooze") {
7512
- if (!SNOOZE_OPTIONS.includes(value)) {
7513
- this.ctx.logger.warn("ha-export: dropping a global snooze command, unknown option", { meta: {
7514
- topic,
7515
- value
7516
- } });
7517
- reply.status(422);
7518
- reply.send({ error: "unroutable command" });
7519
- return;
7520
- }
7521
- const minutes = SNOOZE_MINUTES[value] ?? null;
7522
- try {
7523
- await this.applyGlobalSnooze(minutes);
7524
- reply.status(200);
7525
- reply.send({});
7526
- } catch (err) {
7527
- this.ctx.logger.warn("ha-export: could not apply the global snooze from Home Assistant", { meta: {
7528
- minutes,
7529
- error: errMsg(err)
7530
- } });
7531
- reply.status(422);
7532
- reply.send({ error: "command not applied" });
7533
- }
7534
- return;
7535
- }
7536
7367
  const ruleId = ruleIdFromEntity(parsed.entity, this.syntheticRules);
7537
7368
  if (ruleId === null) {
7538
7369
  this.ctx.logger.warn("ha-export: dropping a synthetic command with no authority behind it", { meta: {
@@ -7702,7 +7533,6 @@ var HaExportAddon = class extends require_dist.BaseAddon {
7702
7533
  await device.ptz.goToPreset({ presetId: preset.id });
7703
7534
  return true;
7704
7535
  }
7705
- case "snooze": return await this.applySnooze(command.deviceId, command.minutes);
7706
7536
  }
7707
7537
  } catch (err) {
7708
7538
  /**
@@ -7728,78 +7558,6 @@ var HaExportAddon = class extends require_dist.BaseAddon {
7728
7558
  });
7729
7559
  return false;
7730
7560
  }
7731
- async applySnooze(deviceId, minutes) {
7732
- if (minutes === null) {
7733
- const { snoozes } = await this.ctx.api.notificationRules.listSnoozes.query({});
7734
- const mine = snoozes.filter((snooze) => snooze.deviceId === deviceId);
7735
- for (const snooze of mine) await this.ctx.api.notificationRules.cancelSnooze.mutate({ snoozeId: snooze.id });
7736
- this.ctx.logger.info("ha-export: cancelled snoozes from Home Assistant", {
7737
- tags: { deviceId },
7738
- meta: { cancelled: mine.length }
7739
- });
7740
- } else {
7741
- await this.ctx.api.notificationRules.createSnooze.mutate({ snooze: {
7742
- scope: "device",
7743
- deviceId,
7744
- durationMinutes: minutes
7745
- } });
7746
- this.ctx.logger.info("ha-export: snoozed from Home Assistant", {
7747
- tags: { deviceId },
7748
- meta: { minutes }
7749
- });
7750
- }
7751
- /**
7752
- * Republish right away — the select is otherwise stale for up to
7753
- * `reconcileIntervalSec` after a write Home Assistant itself made
7754
- * (Task B1 passo 4).
7755
- */
7756
- await this.republishSnooze(deviceId);
7757
- return true;
7758
- }
7759
- /** Push `snooze`'s current state for one camera, outside the reconcile cadence. */
7760
- async republishSnooze(deviceId) {
7761
- const key = this.keyByDeviceId.get(deviceId);
7762
- if (key === void 0) return;
7763
- try {
7764
- const { snoozes } = await this.ctx.api.notificationRules.listSnoozes.query({});
7765
- this.push(deviceId, projectSnooze(key, latestDeviceSnoozeWindow(snoozes, deviceId)));
7766
- } catch (err) {
7767
- this.ctx.logger.warn("ha-export: could not republish snooze after a write from Home Assistant", {
7768
- tags: { deviceId },
7769
- meta: { error: errMsg(err) }
7770
- });
7771
- }
7772
- }
7773
- /**
7774
- * The global `select` on *Notification Center* (Task D4) — `scope: 'all'`,
7775
- * the same authority `applySnooze` already writes for a camera. `Off`
7776
- * cancels every global snooze, exactly as a camera's `Off` cancels its own.
7777
- */
7778
- async applyGlobalSnooze(minutes) {
7779
- if (minutes === null) {
7780
- const { snoozes } = await this.ctx.api.notificationRules.listSnoozes.query({});
7781
- const mine = snoozes.filter((snooze) => snooze.scope === "all");
7782
- for (const snooze of mine) await this.ctx.api.notificationRules.cancelSnooze.mutate({ snoozeId: snooze.id });
7783
- this.ctx.logger.info("ha-export: cancelled the global snooze from Home Assistant", { meta: { cancelled: mine.length } });
7784
- } else {
7785
- await this.ctx.api.notificationRules.createSnooze.mutate({ snooze: {
7786
- scope: "all",
7787
- durationMinutes: minutes
7788
- } });
7789
- this.ctx.logger.info("ha-export: snoozed globally from Home Assistant", { meta: { minutes } });
7790
- }
7791
- await this.republishGlobalSnooze();
7792
- }
7793
- /** Push the global `snooze` select's current state, outside the reconcile cadence. */
7794
- async republishGlobalSnooze() {
7795
- try {
7796
- const { snoozes } = await this.ctx.api.notificationRules.listSnoozes.query({});
7797
- const ncKey = syntheticDeviceKeyFor(NOTIFICATION_CENTER_STABLE_ID);
7798
- await this.pushSynthetic(projectSnooze(ncKey, latestGlobalSnoozeWindow(snoozes)), this.enabledBrokerIds());
7799
- } catch (err) {
7800
- this.ctx.logger.warn("ha-export: could not republish the global snooze after a write from Home Assistant", { meta: { error: errMsg(err) } });
7801
- }
7802
- }
7803
7561
  /**
7804
7562
  * A public, signed, expiring route Home Assistant can fetch bytes from.
7805
7563
  *
@@ -8097,10 +7855,6 @@ var HaExportAddon = class extends require_dist.BaseAddon {
8097
7855
  {
8098
7856
  label: "Capabilities unclassified",
8099
7857
  value: this.unclassified.length === 0 ? "none" : this.unclassified.join(", ")
8100
- },
8101
- {
8102
- label: "Snooze options",
8103
- value: SNOOZE_OPTIONS.join(", ")
8104
7858
  }
8105
7859
  ],
8106
7860
  note: "CamStack pushes to the CamStack custom component in Home Assistant. Home Assistant never polls the hub for a value; when the heartbeat stops, every entity is marked unavailable."
@@ -8193,29 +7947,6 @@ function toCameraStatusInput(status) {
8193
7947
  recording: status.recording === null ? null : { active: status.recording.active }
8194
7948
  };
8195
7949
  }
8196
- /**
8197
- * The ACTIVE window for one scope, from a flat `listSnoozes` answer.
8198
- *
8199
- * "Active" is `expiresAt` in the future — `listSnoozes` never sweeps expired
8200
- * rows (expiry is a comparison, not a job), so a reader that skipped this
8201
- * would keep publishing a duration that ended hours ago. When several windows
8202
- * match, the one expiring LATEST wins: that is the duration still in effect.
8203
- */
8204
- function latestSnoozeWindow(snoozes, matches, now) {
8205
- const latest = snoozes.filter((s) => matches(s) && s.expiresAt > now).reduce((top, s) => top === null || s.expiresAt > top.expiresAt ? s : top, null);
8206
- return latest === null ? null : {
8207
- startedAt: latest.startedAt,
8208
- expiresAt: latest.expiresAt
8209
- };
8210
- }
8211
- /** The camera's own `scope: 'device'` snoozes (Task B1). */
8212
- function latestDeviceSnoozeWindow(snoozes, deviceId) {
8213
- return latestSnoozeWindow(snoozes, (s) => s.scope === "device" && s.deviceId === deviceId, Date.now());
8214
- }
8215
- /** The global `scope: 'all'` snooze (Task D4). */
8216
- function latestGlobalSnoozeWindow(snoozes) {
8217
- return latestSnoozeWindow(snoozes, (s) => s.scope === "all", Date.now());
8218
- }
8219
7950
  function toCountRecord(raw) {
8220
7951
  if (raw === null || typeof raw !== "object") return {};
8221
7952
  const out = {};
@@ -1070,31 +1070,6 @@ var ZONE_MACROS = [
1070
1070
  * already answer for automation), the per-zone timestamps, and the
1071
1071
  * doorbell's since-restart counter.
1072
1072
  */
1073
- /**
1074
- * The snooze surface, as a `select`.
1075
- *
1076
- * `NC_SNOOZE_MAX_MINUTES` is 1440 server-side and there is no enum of
1077
- * preset durations, so the presets are chosen here. `Off` cancels.
1078
- */
1079
- var SNOOZE_OPTIONS = [
1080
- "Off",
1081
- "15 minutes",
1082
- "30 minutes",
1083
- "1 hour",
1084
- "2 hours",
1085
- "4 hours",
1086
- "8 hours",
1087
- "24 hours"
1088
- ];
1089
- var SNOOZE_MINUTES = {
1090
- "15 minutes": 15,
1091
- "30 minutes": 30,
1092
- "1 hour": 60,
1093
- "2 hours": 120,
1094
- "4 hours": 240,
1095
- "8 hours": 480,
1096
- "24 hours": 1440
1097
- };
1098
1073
  var PTZ_BUTTONS = [
1099
1074
  "ptz_up",
1100
1075
  "ptz_down",
@@ -1499,14 +1474,6 @@ function cameraSpecs(device) {
1499
1474
  deviceClass: "running",
1500
1475
  enabledByDefault: true
1501
1476
  });
1502
- specs.push({
1503
- entity: "snooze",
1504
- platform: "select",
1505
- label: "Snooze notifications",
1506
- writable: true,
1507
- options: SNOOZE_OPTIONS,
1508
- enabledByDefault: true
1509
- });
1510
1477
  for (const macro of EXPORTED_MACROS) {
1511
1478
  const label = humanise(macro);
1512
1479
  specs.push({
@@ -3666,21 +3633,6 @@ function resolveCommand(target, entity, value) {
3666
3633
  }
3667
3634
  };
3668
3635
  }
3669
- if (entity === "snooze") {
3670
- if (!SNOOZE_OPTIONS.includes(value)) return {
3671
- ok: false,
3672
- reason: "bad-value"
3673
- };
3674
- const minutes = SNOOZE_MINUTES[value];
3675
- return {
3676
- ok: true,
3677
- command: {
3678
- kind: "snooze",
3679
- deviceId: target.deviceId,
3680
- minutes: minutes ?? null
3681
- }
3682
- };
3683
- }
3684
3636
  return {
3685
3637
  ok: false,
3686
3638
  reason: "unknown-entity"
@@ -3779,8 +3731,7 @@ function isCameraCommand(command) {
3779
3731
  case "camera-function":
3780
3732
  case "reboot":
3781
3733
  case "ptz-move":
3782
- case "ptz-preset":
3783
- case "snooze": return true;
3734
+ case "ptz-preset": return true;
3784
3735
  default: return false;
3785
3736
  }
3786
3737
  }
@@ -5122,38 +5073,6 @@ function projectAudioWindow(deviceKey, input) {
5122
5073
  });
5123
5074
  return values;
5124
5075
  }
5125
- /** Inverse of `SNOOZE_MINUTES` — a minute count back to the option label. */
5126
- var SNOOZE_OPTION_BY_MINUTES = new Map(Object.entries(SNOOZE_MINUTES).map(([option, minutes]) => [minutes, option]));
5127
- /**
5128
- * The `snooze` select's STATE. The authority is
5129
- * `notificationRules.listSnoozes`, which carries `startedAt`/`expiresAt` but
5130
- * not the preset label an operator picked — there is nowhere to store that
5131
- * label, and there must not be: it is reconstructed from the two timestamps
5132
- * every time, so it can never drift from the authority.
5133
- *
5134
- * No active snooze publishes `Off`, never silence: Home Assistant reads a
5135
- * missing state update as "the last value still holds", so a select gone
5136
- * quiet after a snooze expires would keep showing the expired duration
5137
- * forever.
5138
- *
5139
- * A duration the select's closed vocabulary cannot express — a snooze the
5140
- * viewer created with an arbitrary minute count — publishes NOTHING.
5141
- * Rounding it to the nearest preset would let Home Assistant re-send a
5142
- * duration the operator never chose; `unknown` is the honest state.
5143
- */
5144
- function projectSnooze(deviceKey, window) {
5145
- if (window === null) return [{
5146
- topic: stateTopic(deviceKey, "snooze"),
5147
- value: "Off"
5148
- }];
5149
- const minutes = Math.round((window.expiresAt - window.startedAt) / 6e4);
5150
- const option = SNOOZE_OPTION_BY_MINUTES.get(minutes);
5151
- if (option === void 0) return [];
5152
- return [{
5153
- topic: stateTopic(deviceKey, "snooze"),
5154
- value: option
5155
- }];
5156
- }
5157
5076
  /**
5158
5077
  * `status` — the entity D62 exists for. A camera an operator switched off and
5159
5078
  * a camera that is genuinely broken must never paint the same picture in Home
@@ -5312,12 +5231,6 @@ function ruleTriggerEntity(ruleId, suffix) {
5312
5231
  }
5313
5232
  function notificationCenterSpecs(input) {
5314
5233
  return [
5315
- {
5316
- entity: "snoozed",
5317
- platform: "binary_sensor",
5318
- label: "Notifications snoozed",
5319
- icon: "mdi:bell-sleep"
5320
- },
5321
5234
  {
5322
5235
  entity: "rules_enabled",
5323
5236
  platform: "sensor",
@@ -5327,21 +5240,6 @@ function notificationCenterSpecs(input) {
5327
5240
  },
5328
5241
  (
5329
5242
  /**
5330
- * The GLOBAL snooze, as a control (Task D4) — the half `snoozed` (above)
5331
- * never had. Its state is NOT projected here: `ha-export.addon.ts` pushes
5332
- * it through the SAME `projectSnooze` a camera's snooze uses, so the rule
5333
- * lives in one place — two expressions of it in two files is how
5334
- * `audio_detected` went mute for a year.
5335
- */
5336
- {
5337
- entity: "snooze",
5338
- platform: "select",
5339
- label: "Snooze all notifications",
5340
- writable: true,
5341
- options: SNOOZE_OPTIONS
5342
- }),
5343
- (
5344
- /**
5345
5243
  * The last trigger, six entities: five facts plus its picture — "conoscere
5346
5244
  * gli ultimi trigger, le ultime immagini... e da quale label" (operator,
5347
5245
  * 2026-08-25). ENABLED by default like every other entity on this device
@@ -5662,10 +5560,6 @@ function projectSynthetic(input, nowMs) {
5662
5560
  const srv = syntheticDeviceKeyFor(SERVER_STABLE_ID);
5663
5561
  const online = input.nodes.filter((node) => node.online);
5664
5562
  const values = [
5665
- {
5666
- topic: stateTopic(nc, "snoozed"),
5667
- value: String(input.snoozed)
5668
- },
5669
5563
  {
5670
5564
  topic: stateTopic(nc, "rules_enabled"),
5671
5565
  value: String(input.rules.filter((rule) => rule.enabled).length)
@@ -6501,16 +6395,6 @@ var HaExportAddon = class extends BaseAddon {
6501
6395
  });
6502
6396
  /** `deviceManager.listBindableCapsForDeviceType` — once per TYPE, not per device. */
6503
6397
  const bindableCapsByType = /* @__PURE__ */ new Map();
6504
- /**
6505
- * `notificationRules.listSnoozes` — ONE read for the whole pass, shared
6506
- * by every camera's `snooze` select (Task B1) and the synthetic
6507
- * `snooze` select on *Notification Center* (Task D4): two expressions
6508
- * of the same read is how `audio_detected` went mute for a year.
6509
- */
6510
- const snoozes = await this.ctx.api.notificationRules.listSnoozes.query({}).then((r) => r.snoozes).catch((err) => {
6511
- this.ctx.logger.warn("ha-export: could not read snoozes, snooze selects go unknown", { meta: { error: errMsg(err) } });
6512
- return [];
6513
- });
6514
6398
  const exported = /* @__PURE__ */ new Map();
6515
6399
  const keyByDeviceId = /* @__PURE__ */ new Map();
6516
6400
  const allCaps = /* @__PURE__ */ new Set();
@@ -6603,7 +6487,7 @@ var HaExportAddon = class extends BaseAddon {
6603
6487
  * `brokerIds` is every enabled broker, because they belong to the server
6604
6488
  * and not to a membership the operator picks per camera.
6605
6489
  */
6606
- const synthetic = await this.buildSynthetic(snoozes, byId);
6490
+ const synthetic = await this.buildSynthetic(byId);
6607
6491
  if (synthetic !== null) for (const plan of syntheticPlans(synthetic)) {
6608
6492
  exported.set(plan.deviceKey, {
6609
6493
  plan,
@@ -6643,16 +6527,8 @@ var HaExportAddon = class extends BaseAddon {
6643
6527
  }
6644
6528
  });
6645
6529
  await this.announce(exported);
6646
- await this.pushFullState(exported, snapshots, snoozes);
6647
- if (synthetic !== null) {
6648
- /**
6649
- * The global snooze's STATE (Task D4) rides the SAME `projectSnooze`
6650
- * a camera's `snooze` select uses, over the SAME `snoozes` read this
6651
- * pass already made — one expression of the rule, not two.
6652
- */
6653
- const ncKey = syntheticDeviceKeyFor(NOTIFICATION_CENTER_STABLE_ID);
6654
- await this.pushSynthetic([...projectSynthetic(synthetic, Date.now()), ...projectSnooze(ncKey, latestGlobalSnoozeWindow(snoozes))], [...enabled]);
6655
- }
6530
+ await this.pushFullState(exported, snapshots);
6531
+ if (synthetic !== null) await this.pushSynthetic(projectSynthetic(synthetic, Date.now()), [...enabled]);
6656
6532
  this.ctx.logger.info("ha-export: reconciled", { meta: {
6657
6533
  reason,
6658
6534
  brokers: enabled.length,
@@ -6686,7 +6562,7 @@ var HaExportAddon = class extends BaseAddon {
6686
6562
  * Returns `null` only when NOTHING answered, so the devices are not
6687
6563
  * announced empty on a hub that is still booting.
6688
6564
  */
6689
- async buildSynthetic(snoozes, byId) {
6565
+ async buildSynthetic(byId) {
6690
6566
  const rules = await this.ctx.api.notificationRules.listRules.query({}).then((r) => r.rules.map((rule) => ({
6691
6567
  id: rule.id,
6692
6568
  name: rule.name,
@@ -6695,8 +6571,6 @@ var HaExportAddon = class extends BaseAddon {
6695
6571
  this.ctx.logger.warn("ha-export: could not read notification rules", { meta: { error: errMsg(err) } });
6696
6572
  return null;
6697
6573
  });
6698
- /** Reuses the ONE `listSnoozes` read the pass already made (Task B1/D4). */
6699
- const anySnoozed = snoozes.length > 0;
6700
6574
  const topology = await this.ctx.api.nodes.topology.query().catch((err) => {
6701
6575
  this.ctx.logger.warn("ha-export: could not read the cluster topology", { meta: { error: errMsg(err) } });
6702
6576
  return null;
@@ -6720,7 +6594,6 @@ var HaExportAddon = class extends BaseAddon {
6720
6594
  const { nodes, oldestFootageMs } = await this.buildSyntheticNodes(topology ?? []);
6721
6595
  return {
6722
6596
  rules: effectiveRules,
6723
- snoozed: anySnoozed,
6724
6597
  lastTrigger,
6725
6598
  lastTriggerByRule,
6726
6599
  nodes,
@@ -6902,9 +6775,9 @@ var HaExportAddon = class extends BaseAddon {
6902
6775
  }
6903
6776
  }
6904
6777
  }
6905
- async pushFullState(exported, snapshots, snoozes) {
6778
+ async pushFullState(exported, snapshots) {
6906
6779
  for (const entry of exported.values()) try {
6907
- const values = await this.currentStateFor(entry, snapshots[String(entry.deviceId)], snoozes);
6780
+ const values = await this.currentStateFor(entry, snapshots[String(entry.deviceId)]);
6908
6781
  this.push(entry.deviceId, values);
6909
6782
  } catch (err) {
6910
6783
  this.ctx.logger.warn("ha-export: could not read current state for a device", {
@@ -6915,7 +6788,7 @@ var HaExportAddon = class extends BaseAddon {
6915
6788
  for (const link of this.links.values()) await link.client.flush();
6916
6789
  }
6917
6790
  /** Everything readable without waiting for an event. */
6918
- async currentStateFor(entry, snapshotRaw, snoozes) {
6791
+ async currentStateFor(entry, snapshotRaw) {
6919
6792
  const key = entry.plan.deviceKey;
6920
6793
  const values = [];
6921
6794
  const snapshot = toSnapshot(snapshotRaw);
@@ -6995,16 +6868,6 @@ var HaExportAddon = class extends BaseAddon {
6995
6868
  */
6996
6869
  const cameraStatus = this.cameraStatusByDeviceId.get(entry.deviceId);
6997
6870
  if (cameraStatus !== void 0) values.push(...projectCameraStatus(key, toCameraStatusInput(cameraStatus)));
6998
- /**
6999
- * `snooze` (Task B1) — reconstructed from `notificationRules.listSnoozes`,
7000
- * never stored: the select's state is the SAME authority the write path
7001
- * (`applySnooze`) already uses.
7002
- */
7003
- const snoozeValues = projectSnooze(key, latestDeviceSnoozeWindow(snoozes, entry.deviceId));
7004
- if (snoozeValues.length === 0) {
7005
- 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 } });
7006
- }
7007
- values.push(...snoozeValues);
7008
6871
  const occupancy = await this.ctx.api.zoneAnalytics.getCurrentSnapshot.query({ deviceId: entry.deviceId });
7009
6872
  if (occupancy !== null) values.push(...projectZoneOccupancy(key, {
7010
6873
  zones: occupancy.zones.map((zone) => ({
@@ -7496,38 +7359,6 @@ var HaExportAddon = class extends BaseAddon {
7496
7359
  * disagree with the admin UI is worse than no knob (D62).
7497
7360
  */
7498
7361
  if (isSyntheticDeviceKey(parsed.deviceKey)) {
7499
- /**
7500
- * The global snooze select (Task D4) — the OTHER control this branch
7501
- * routes, alongside the per-rule switches below. Same rule as the
7502
- * camera's own snooze (`applySnooze`): the export writes
7503
- * `createSnooze`/`cancelSnooze` directly, `scope: 'all'`, never a
7504
- * store of its own.
7505
- */
7506
- if (parsed.deviceKey === syntheticDeviceKeyFor("synthetic-notification-center") && parsed.entity === "snooze") {
7507
- if (!SNOOZE_OPTIONS.includes(value)) {
7508
- this.ctx.logger.warn("ha-export: dropping a global snooze command, unknown option", { meta: {
7509
- topic,
7510
- value
7511
- } });
7512
- reply.status(422);
7513
- reply.send({ error: "unroutable command" });
7514
- return;
7515
- }
7516
- const minutes = SNOOZE_MINUTES[value] ?? null;
7517
- try {
7518
- await this.applyGlobalSnooze(minutes);
7519
- reply.status(200);
7520
- reply.send({});
7521
- } catch (err) {
7522
- this.ctx.logger.warn("ha-export: could not apply the global snooze from Home Assistant", { meta: {
7523
- minutes,
7524
- error: errMsg(err)
7525
- } });
7526
- reply.status(422);
7527
- reply.send({ error: "command not applied" });
7528
- }
7529
- return;
7530
- }
7531
7362
  const ruleId = ruleIdFromEntity(parsed.entity, this.syntheticRules);
7532
7363
  if (ruleId === null) {
7533
7364
  this.ctx.logger.warn("ha-export: dropping a synthetic command with no authority behind it", { meta: {
@@ -7697,7 +7528,6 @@ var HaExportAddon = class extends BaseAddon {
7697
7528
  await device.ptz.goToPreset({ presetId: preset.id });
7698
7529
  return true;
7699
7530
  }
7700
- case "snooze": return await this.applySnooze(command.deviceId, command.minutes);
7701
7531
  }
7702
7532
  } catch (err) {
7703
7533
  /**
@@ -7723,78 +7553,6 @@ var HaExportAddon = class extends BaseAddon {
7723
7553
  });
7724
7554
  return false;
7725
7555
  }
7726
- async applySnooze(deviceId, minutes) {
7727
- if (minutes === null) {
7728
- const { snoozes } = await this.ctx.api.notificationRules.listSnoozes.query({});
7729
- const mine = snoozes.filter((snooze) => snooze.deviceId === deviceId);
7730
- for (const snooze of mine) await this.ctx.api.notificationRules.cancelSnooze.mutate({ snoozeId: snooze.id });
7731
- this.ctx.logger.info("ha-export: cancelled snoozes from Home Assistant", {
7732
- tags: { deviceId },
7733
- meta: { cancelled: mine.length }
7734
- });
7735
- } else {
7736
- await this.ctx.api.notificationRules.createSnooze.mutate({ snooze: {
7737
- scope: "device",
7738
- deviceId,
7739
- durationMinutes: minutes
7740
- } });
7741
- this.ctx.logger.info("ha-export: snoozed from Home Assistant", {
7742
- tags: { deviceId },
7743
- meta: { minutes }
7744
- });
7745
- }
7746
- /**
7747
- * Republish right away — the select is otherwise stale for up to
7748
- * `reconcileIntervalSec` after a write Home Assistant itself made
7749
- * (Task B1 passo 4).
7750
- */
7751
- await this.republishSnooze(deviceId);
7752
- return true;
7753
- }
7754
- /** Push `snooze`'s current state for one camera, outside the reconcile cadence. */
7755
- async republishSnooze(deviceId) {
7756
- const key = this.keyByDeviceId.get(deviceId);
7757
- if (key === void 0) return;
7758
- try {
7759
- const { snoozes } = await this.ctx.api.notificationRules.listSnoozes.query({});
7760
- this.push(deviceId, projectSnooze(key, latestDeviceSnoozeWindow(snoozes, deviceId)));
7761
- } catch (err) {
7762
- this.ctx.logger.warn("ha-export: could not republish snooze after a write from Home Assistant", {
7763
- tags: { deviceId },
7764
- meta: { error: errMsg(err) }
7765
- });
7766
- }
7767
- }
7768
- /**
7769
- * The global `select` on *Notification Center* (Task D4) — `scope: 'all'`,
7770
- * the same authority `applySnooze` already writes for a camera. `Off`
7771
- * cancels every global snooze, exactly as a camera's `Off` cancels its own.
7772
- */
7773
- async applyGlobalSnooze(minutes) {
7774
- if (minutes === null) {
7775
- const { snoozes } = await this.ctx.api.notificationRules.listSnoozes.query({});
7776
- const mine = snoozes.filter((snooze) => snooze.scope === "all");
7777
- for (const snooze of mine) await this.ctx.api.notificationRules.cancelSnooze.mutate({ snoozeId: snooze.id });
7778
- this.ctx.logger.info("ha-export: cancelled the global snooze from Home Assistant", { meta: { cancelled: mine.length } });
7779
- } else {
7780
- await this.ctx.api.notificationRules.createSnooze.mutate({ snooze: {
7781
- scope: "all",
7782
- durationMinutes: minutes
7783
- } });
7784
- this.ctx.logger.info("ha-export: snoozed globally from Home Assistant", { meta: { minutes } });
7785
- }
7786
- await this.republishGlobalSnooze();
7787
- }
7788
- /** Push the global `snooze` select's current state, outside the reconcile cadence. */
7789
- async republishGlobalSnooze() {
7790
- try {
7791
- const { snoozes } = await this.ctx.api.notificationRules.listSnoozes.query({});
7792
- const ncKey = syntheticDeviceKeyFor(NOTIFICATION_CENTER_STABLE_ID);
7793
- await this.pushSynthetic(projectSnooze(ncKey, latestGlobalSnoozeWindow(snoozes)), this.enabledBrokerIds());
7794
- } catch (err) {
7795
- this.ctx.logger.warn("ha-export: could not republish the global snooze after a write from Home Assistant", { meta: { error: errMsg(err) } });
7796
- }
7797
- }
7798
7556
  /**
7799
7557
  * A public, signed, expiring route Home Assistant can fetch bytes from.
7800
7558
  *
@@ -8092,10 +7850,6 @@ var HaExportAddon = class extends BaseAddon {
8092
7850
  {
8093
7851
  label: "Capabilities unclassified",
8094
7852
  value: this.unclassified.length === 0 ? "none" : this.unclassified.join(", ")
8095
- },
8096
- {
8097
- label: "Snooze options",
8098
- value: SNOOZE_OPTIONS.join(", ")
8099
7853
  }
8100
7854
  ],
8101
7855
  note: "CamStack pushes to the CamStack custom component in Home Assistant. Home Assistant never polls the hub for a value; when the heartbeat stops, every entity is marked unavailable."
@@ -8188,29 +7942,6 @@ function toCameraStatusInput(status) {
8188
7942
  recording: status.recording === null ? null : { active: status.recording.active }
8189
7943
  };
8190
7944
  }
8191
- /**
8192
- * The ACTIVE window for one scope, from a flat `listSnoozes` answer.
8193
- *
8194
- * "Active" is `expiresAt` in the future — `listSnoozes` never sweeps expired
8195
- * rows (expiry is a comparison, not a job), so a reader that skipped this
8196
- * would keep publishing a duration that ended hours ago. When several windows
8197
- * match, the one expiring LATEST wins: that is the duration still in effect.
8198
- */
8199
- function latestSnoozeWindow(snoozes, matches, now) {
8200
- const latest = snoozes.filter((s) => matches(s) && s.expiresAt > now).reduce((top, s) => top === null || s.expiresAt > top.expiresAt ? s : top, null);
8201
- return latest === null ? null : {
8202
- startedAt: latest.startedAt,
8203
- expiresAt: latest.expiresAt
8204
- };
8205
- }
8206
- /** The camera's own `scope: 'device'` snoozes (Task B1). */
8207
- function latestDeviceSnoozeWindow(snoozes, deviceId) {
8208
- return latestSnoozeWindow(snoozes, (s) => s.scope === "device" && s.deviceId === deviceId, Date.now());
8209
- }
8210
- /** The global `scope: 'all'` snooze (Task D4). */
8211
- function latestGlobalSnoozeWindow(snoozes) {
8212
- return latestSnoozeWindow(snoozes, (s) => s.scope === "all", Date.now());
8213
- }
8214
7945
  function toCountRecord(raw) {
8215
7946
  if (raw === null || typeof raw !== "object") return {};
8216
7947
  const out = {};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@camstack/addon-provider-homeassistant",
3
- "version": "1.2.80",
3
+ "version": "1.2.81",
4
4
  "description": "Home Assistant device provider addon for CamStack",
5
5
  "keywords": [
6
6
  "camstack",