@camstack/addon-post-analysis 1.2.322 → 1.2.324

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.
Files changed (15) hide show
  1. package/dist/{clip-model-registry-BFbP9U7z.js → clip-model-registry-BFLeI0dr.js} +5683 -5032
  2. package/dist/{clip-model-registry-CwK2SLQD.mjs → clip-model-registry-CshjlVTF.mjs} +5450 -4835
  3. package/dist/embedding-encoder/index.js +1 -1
  4. package/dist/embedding-encoder/index.mjs +1 -1
  5. package/dist/pipeline-analytics/_stub.js +2 -2
  6. package/dist/pipeline-analytics/{_virtual_mf-localSharedImportMap___mfe_internal__addon_pipeline_analytics_widgets-Dmt5eh93.mjs → _virtual_mf-localSharedImportMap___mfe_internal__addon_pipeline_analytics_widgets-BiD7k7AM.mjs} +2 -2
  7. package/dist/pipeline-analytics/_virtual_mf___mfe_internal__addon_pipeline_analytics_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-CBG_lEGV.mjs +26 -0
  8. package/dist/pipeline-analytics/_virtual_mf___mfe_internal__addon_pipeline_analytics_widgets__loadShare___mf_0_camstack_mf_1_ui_mf_2_library__loadShare__.js-Bnb-yu_h.mjs +26 -0
  9. package/dist/pipeline-analytics/{hostInit-DtKO_7UT.mjs → hostInit-D0L1yv82.mjs} +2 -2
  10. package/dist/pipeline-analytics/index.js +745 -137
  11. package/dist/pipeline-analytics/index.mjs +745 -137
  12. package/dist/pipeline-analytics/remoteEntry.js +1 -1
  13. package/package.json +1 -1
  14. package/dist/pipeline-analytics/_virtual_mf___mfe_internal__addon_pipeline_analytics_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-mzYK6V9S.mjs +0 -26
  15. package/dist/pipeline-analytics/_virtual_mf___mfe_internal__addon_pipeline_analytics_widgets__loadShare___mf_0_camstack_mf_1_ui_mf_2_library__loadShare__.js-CuOcvcK7.mjs +0 -26
@@ -2,7 +2,7 @@ Object.defineProperties(exports, {
2
2
  __esModule: { value: true },
3
3
  [Symbol.toStringTag]: { value: "Module" }
4
4
  });
5
- const require_clip_model_registry = require("../clip-model-registry-BFbP9U7z.js");
5
+ const require_clip_model_registry = require("../clip-model-registry-BFLeI0dr.js");
6
6
  let node_fs = require("node:fs");
7
7
  node_fs = require_clip_model_registry.__toESM(node_fs, 1);
8
8
  let node_path = require("node:path");
@@ -484,26 +484,37 @@ function buildArmAnnouncement(coverage, deviceName, bypassed = []) {
484
484
  };
485
485
  }
486
486
  /**
487
- * "Attivi: 2 — Bypassati: Finestra veranda."
487
+ * "Attivi: Porta retro, Cancello — Bypassati: Finestra veranda."
488
488
  *
489
- * The bypassed sensors are named and the armed ones are COUNTED, not listed:
490
- * this sentence's subject is the exception, and burying it under thirteen
491
- * camera names is how an operator stops reading the arm notification. The
492
- * count pluralises on what IS armed, like every other arm variant.
489
+ * Both halves are named: the bypassed sensors are why the operator pressed
490
+ * Arm anyway, and the remaining ones are what is still promised. An
491
+ * all-devices mode has no finite list to name — it uses a dedicated key.
493
492
  */
494
493
  function armedWithBypass(coverage, bypassed, deviceName) {
495
494
  const skipped = new Set(coverage.skippedDevices.map((s) => s.deviceId));
496
495
  const excluded = new Set(bypassed);
497
- const armed = coverage.allDevices ? coverage.ruleCount : coverage.deviceIds.filter((id) => !skipped.has(id) && !excluded.has(id)).length;
496
+ const bypassedNames = bypassed.map(deviceName).join(", ");
497
+ if (coverage.allDevices) return {
498
+ bodyKey: "alarm.arm.body.bypassedAll",
499
+ textParams: {
500
+ rules: `${coverage.ruleCount}`,
501
+ mode: coverage.mode,
502
+ bypassedNames
503
+ },
504
+ textCount: coverage.ruleCount
505
+ };
506
+ const armedIds = coverage.deviceIds.filter((id) => !skipped.has(id) && !excluded.has(id));
507
+ const names = armedIds.map(deviceName).join(", ");
498
508
  return {
499
509
  bodyKey: "alarm.arm.body.bypassed",
500
510
  textParams: {
501
511
  rules: `${coverage.ruleCount}`,
502
512
  mode: coverage.mode,
503
- devices: `${armed}`,
504
- bypassedNames: bypassed.map(deviceName).join(", ")
513
+ devices: `${armedIds.length}`,
514
+ names,
515
+ bypassedNames
505
516
  },
506
- textCount: armed
517
+ textCount: armedIds.length
507
518
  };
508
519
  }
509
520
  /**
@@ -945,7 +956,7 @@ function grantMeta(grant) {
945
956
  ...common,
946
957
  ruleId: grant.ruleId,
947
958
  rule: grant.ruleName,
948
- sequence: grant.sequence
959
+ sequenceId: grant.sequenceId
949
960
  };
950
961
  case "snooze": return {
951
962
  ...common,
@@ -1098,10 +1109,11 @@ var NcActionPlane = class {
1098
1109
  * Anything short of fully secure is open: a cover stopped half-way, a lock that
1099
1110
  * jammed, a cover still closing when the arm is decided, a switch that is on.
1100
1111
  *
1101
- * PRECEDENCE, when one device carries more than one opening slice: `contact`
1102
- * first, then `cover`, then `lock-control`, then `switch`. The contact is the
1103
- * physical truth about the opening — a reed switch on the frame says the window
1104
- * is open even when the motor's last reported word says `closed`.
1112
+ * PRECEDENCE, when one device carries more than one opening slice: any OPEN
1113
+ * reading wins over a secure one (a reed that says open beats a motor that
1114
+ * still says `closed`). Among opens, a controllable cap (`cover` / `lock-control`
1115
+ * / `switch`) wins so secure-on-arm knows what to actuate; among secures the
1116
+ * contact wins as the physical "closed" when the motor has not moved yet.
1105
1117
  */
1106
1118
  /** The one state word, per cap, that counts as secure. Everything else is open. */
1107
1119
  var COVER_SECURE = "closed";
@@ -1214,6 +1226,11 @@ function readSwitch(runtime) {
1214
1226
  }
1215
1227
  /** D630's table over one device's runtime slices — see the header for the three answers. */
1216
1228
  function alarmOpeningFrom(runtime) {
1229
+ let openControllable = null;
1230
+ let openAny = null;
1231
+ let secureContact = null;
1232
+ let secureControllable = null;
1233
+ let secureAny = null;
1217
1234
  let firstUnknown = null;
1218
1235
  for (const read of [
1219
1236
  readContact(runtime),
@@ -1221,10 +1238,21 @@ function alarmOpeningFrom(runtime) {
1221
1238
  readLock(runtime),
1222
1239
  readSwitch(runtime)
1223
1240
  ]) {
1224
- if (read.kind === "reading") return read;
1225
- if (read.kind === "unknown" && firstUnknown === null) firstUnknown = read;
1241
+ if (read.kind === "unknown") {
1242
+ if (firstUnknown === null) firstUnknown = read;
1243
+ continue;
1244
+ }
1245
+ if (read.kind !== "reading") continue;
1246
+ if (read.reading === "open") {
1247
+ if (require_clip_model_registry.isAlarmControllableCap(read.cap) && openControllable === null) openControllable = read;
1248
+ if (openAny === null) openAny = read;
1249
+ continue;
1250
+ }
1251
+ if (read.cap === "contact" && secureContact === null) secureContact = read;
1252
+ if (require_clip_model_registry.isAlarmControllableCap(read.cap) && secureControllable === null) secureControllable = read;
1253
+ if (secureAny === null) secureAny = read;
1226
1254
  }
1227
- return firstUnknown ?? NOT_OPENING;
1255
+ return openControllable ?? openAny ?? secureContact ?? secureControllable ?? secureAny ?? firstUnknown ?? NOT_OPENING;
1228
1256
  }
1229
1257
  /**
1230
1258
  * The sensor-event kind (`CAP_TO_KIND` in `@camstack/types`) → the cap whose
@@ -2750,12 +2778,6 @@ var NcAlarmPanelDevice = class extends require_clip_model_registry.BaseDevice {
2750
2778
  error: String(err)
2751
2779
  };
2752
2780
  }
2753
- const toSecure = openings.open.filter((d) => require_clip_model_registry.alarmDeviceSecureOnArm({
2754
- controllable: d.controllable,
2755
- deviceId: d.deviceId,
2756
- secureOnArmOff: this.secureOnArmOffFor(mode)
2757
- }));
2758
- if (toSecure.length > 0) this.armGuard?.secureOnArm(toSecure);
2759
2781
  const verdict = decideArm({
2760
2782
  mode,
2761
2783
  open: openings.open,
@@ -2763,6 +2785,14 @@ var NcAlarmPanelDevice = class extends require_clip_model_registry.BaseDevice {
2763
2785
  blocking: this.blockingFor(mode),
2764
2786
  ...acknowledged !== void 0 ? { acknowledged } : {}
2765
2787
  });
2788
+ if (verdict.kind === "arm") {
2789
+ const toSecure = openings.open.filter((d) => require_clip_model_registry.alarmDeviceSecureOnArm({
2790
+ controllable: d.controllable,
2791
+ deviceId: d.deviceId,
2792
+ secureOnArmOff: this.secureOnArmOffFor(mode)
2793
+ }));
2794
+ if (toSecure.length > 0) this.armGuard?.secureOnArm(toSecure);
2795
+ }
2766
2796
  return {
2767
2797
  kind: "read",
2768
2798
  openings,
@@ -2971,6 +3001,7 @@ var NcAlarmPanelDevice = class extends require_clip_model_registry.BaseDevice {
2971
3001
  status(now = Date.now()) {
2972
3002
  return {
2973
3003
  state: stateAt(this.machine, now),
3004
+ targetMode: armModeOf(this.machine.target, NC_ALARM_MODES),
2974
3005
  availableModes: [...NC_ALARM_MODES],
2975
3006
  requiresCode: false,
2976
3007
  lastChangedAt: this.machine.changedAt
@@ -5068,6 +5099,27 @@ function planDenseRanges(input) {
5068
5099
  }
5069
5100
  //#endregion
5070
5101
  //#region src/notification-center/text-compose.ts
5102
+ /**
5103
+ * The COMPOSITE variables — phrases assembled from catalog fragments before
5104
+ * the one and only render pass.
5105
+ *
5106
+ * A notification body is not a sentence with slots. "person (John) on Front
5107
+ * door in gate" contains three sub-phrases whose SHAPE differs per language
5108
+ * and whose presence is conditional: the label parentheses, the zone clause,
5109
+ * the plural noun. Expressing that with `{{class}}{{label}}{{zones}}` and
5110
+ * three scalars pushes the grammar into the template, where a translator
5111
+ * cannot fix it. So each becomes ONE variable — `{{subject}}`, `{{inZones}}`,
5112
+ * `{{op}}`, `{{detectionSummary}}` — composed here from its own keys and handed
5113
+ * to {@link renderTemplate} as a finished string.
5114
+ *
5115
+ * **Fragments see the SCALARS only.** Never the composites. That is what makes
5116
+ * the depth exactly one and not "one so far": a fragment key edited to say
5117
+ * `{{inZones}}` renders empty rather than starting a second round, and
5118
+ * `renderTemplate` never re-scans what it inserted, so a composite's own value
5119
+ * is printed verbatim whatever it contains.
5120
+ *
5121
+ * Pure: a catalog in, a string out. No clock, no store, no I/O.
5122
+ */
5071
5123
  /** One normalisation, used on both sides of every class lookup — a pipeline
5072
5124
  * that emitted `Person` must find the key written `class.person`. */
5073
5125
  function normaliseClass(className) {
@@ -5076,24 +5128,35 @@ function normaliseClass(className) {
5076
5128
  /**
5077
5129
  * The noun for a class, in the count's plural category.
5078
5130
  *
5079
- * A class no locale names falls through to `class.other`, which prints the RAW
5080
- * name: a custom model's class must appear in the sentence rather than vanish
5081
- * out of it, and a missing translation is a smaller defect than a missing
5082
- * detection.
5131
+ * Lookup order: the NC text catalog (`class.*`, plural-aware) → the shared
5132
+ * {@link eventKindLabel} map (viewer filters / EVENT_TAXONOMY) → `class.other`
5133
+ * with the RAW wire id. A custom model class must still appear rather than
5134
+ * vanish; a missing translation is a smaller defect than a missing detection.
5083
5135
  */
5084
5136
  function ncClassNoun(texts, className, count) {
5085
- const key = `class.${normaliseClass(className)}`;
5137
+ const normalised = normaliseClass(className);
5138
+ const key = `class.${normalised}`;
5086
5139
  if (texts.has(key)) return texts.text({
5087
5140
  key,
5088
5141
  count,
5089
5142
  vars: { name: className }
5090
5143
  });
5144
+ if (require_clip_model_registry.isEventKindLabelKey(normalised)) return require_clip_model_registry.eventKindLabel(normalised, texts.language);
5091
5145
  return texts.text({
5092
5146
  key: "class.other",
5093
5147
  count,
5094
5148
  vars: { name: className }
5095
5149
  });
5096
5150
  }
5151
+ /**
5152
+ * The parenthetical half of a labelled subject: a recognised person or plate
5153
+ * is printed verbatim; a classification (species, COCO sub, …) goes through
5154
+ * the shared event-kind map so "animal (cat)" becomes "animale (Gatto)".
5155
+ */
5156
+ function ncSubjectLabelText(texts, label, labelKind) {
5157
+ if (labelKind === "identity" || labelKind === "plate") return label;
5158
+ return require_clip_model_registry.eventKindLabel(label, texts.language);
5159
+ }
5097
5160
  /** `person (John)` / `persona (John)` — the thing the notification is about. */
5098
5161
  function ncSubjectText(texts, input) {
5099
5162
  const classNoun = ncClassNoun(texts, input.className, 1);
@@ -5105,7 +5168,7 @@ function ncSubjectText(texts, input) {
5105
5168
  key: subjectLabelKey(input.labelKind),
5106
5169
  vars: {
5107
5170
  classNoun,
5108
- label: input.label
5171
+ label: ncSubjectLabelText(texts, input.label, input.labelKind)
5109
5172
  }
5110
5173
  });
5111
5174
  }
@@ -8675,31 +8738,48 @@ function replaceConditionReferences(monitor, condition, reference) {
8675
8738
  /** The cap whose presence in a sequence makes the rule the alarm's trigger. */
8676
8739
  var ALARM_PANEL_CAP$1 = "alarm-panel";
8677
8740
  var ALARM_KINDS = new Set(require_clip_model_registry.NC_ALARM_SYSTEM_EVENT_KINDS);
8678
- function actuatesAlarmPanel(sequences) {
8679
- for (const sequence of sequences) for (const step of sequence.actions) if (step.kind === "cap" && step.cap === ALARM_PANEL_CAP$1) return true;
8741
+ function stepActuatesAlarm(step) {
8742
+ return step.kind === "cap" && step.cap === ALARM_PANEL_CAP$1;
8743
+ }
8744
+ function bindingActuatesAlarm(binding, sequenceActionsOf) {
8745
+ if (binding.kind === "do") return stepActuatesAlarm(binding.action);
8746
+ const steps = sequenceActionsOf?.(binding.sequenceId);
8747
+ if (steps === void 0) return false;
8748
+ return steps.some(stepActuatesAlarm);
8749
+ }
8750
+ function actionsActuateAlarm(rule, sequenceActionsOf) {
8751
+ const actions = rule.actions;
8752
+ if (actions === void 0) return false;
8753
+ const hooks = [
8754
+ actions.onActivation,
8755
+ actions.onDeactivation,
8756
+ actions.onTrigger,
8757
+ actions.onTriggerOff
8758
+ ];
8759
+ for (const bindings of hooks) for (const binding of bindings ?? []) if (bindingActuatesAlarm(binding, sequenceActionsOf)) return true;
8760
+ for (const button of actions.buttons ?? []) {
8761
+ const steps = sequenceActionsOf?.(button.sequenceId);
8762
+ if (steps !== void 0 && steps.some(stepActuatesAlarm)) return true;
8763
+ }
8680
8764
  return false;
8681
8765
  }
8682
8766
  /**
8683
8767
  * May a snooze window silence this rule?
8684
8768
  *
8685
- * `true` = never. Deliberately answered from the rule alone: the gate reads it
8686
- * on the delivery path, where a fallible lookup would be a read that can
8687
- * DESTROY the exemption (D49) — and the failure direction of this one is
8688
- * silencing an alarm.
8769
+ * `true` = never. Deliberately answered from the rule alone (plus an optional
8770
+ * sequence lookup): the gate reads it on the delivery path, where a fallible
8771
+ * lookup would be a read that can DESTROY the exemption (D49) — and the
8772
+ * failure direction of this one is silencing an alarm. A missing sequence
8773
+ * lookup means sequence-ref bindings cannot prove exemption; Do bindings and
8774
+ * system-event kinds still can.
8689
8775
  */
8690
- function isSnoozeExempt(rule) {
8776
+ function isSnoozeExempt(rule, sequenceActionsOf) {
8691
8777
  const kinds = rule.conditions.systemEvent?.kinds ?? [];
8692
8778
  for (const kind of kinds) if (ALARM_KINDS.has(kind)) return true;
8693
- return actuatesAlarmPanel(rule.actions?.onTrigger ?? []);
8779
+ return actionsActuateAlarm(rule, sequenceActionsOf);
8694
8780
  }
8695
8781
  //#endregion
8696
8782
  //#region src/notification-center/action-buttons.ts
8697
- /** Sequence names the rule actually declares. A button may only name one. */
8698
- function declaredSequences(actions) {
8699
- const names = /* @__PURE__ */ new Set();
8700
- for (const sequence of actions.onTrigger ?? []) names.add(sequence.name);
8701
- return names;
8702
- }
8703
8783
  /**
8704
8784
  * Build the notification's buttons.
8705
8785
  *
@@ -8712,18 +8792,18 @@ function buildActionButtons(input) {
8712
8792
  if (actions === void 0) return [];
8713
8793
  const buttons = actions.buttons ?? [];
8714
8794
  if (buttons.length === 0) return [];
8715
- const known = declaredSequences(actions);
8795
+ const known = input.knownSequenceIds;
8716
8796
  const seen = /* @__PURE__ */ new Set();
8717
8797
  const out = [];
8718
8798
  for (const button of buttons) {
8719
- if (!known.has(button.sequence)) continue;
8799
+ if (!known.has(button.sequenceId)) continue;
8720
8800
  if (seen.has(button.id)) continue;
8721
8801
  seen.add(button.id);
8722
8802
  out.push({
8723
8803
  id: button.id,
8724
8804
  label: button.label,
8725
8805
  url: input.mintUrl({
8726
- sequence: button.sequence,
8806
+ sequenceId: button.sequenceId,
8727
8807
  actionId: button.id
8728
8808
  }),
8729
8809
  ...button.icon !== void 0 ? { icon: button.icon } : {},
@@ -8733,18 +8813,17 @@ function buildActionButtons(input) {
8733
8813
  return out;
8734
8814
  }
8735
8815
  /**
8736
- * Which of a rule's buttons name a sequence it does not have.
8816
+ * Which of a rule's buttons name a sequence that is not known.
8737
8817
  *
8738
8818
  * Exported so the caller can LOG the drop. A button silently missing from a
8739
8819
  * notification is the exact shape of failure this repo keeps paying for — the
8740
8820
  * operator authored it, it never appeared, and nothing said why.
8741
8821
  */
8742
- function unresolvableButtons(actions) {
8822
+ function unresolvableButtons(actions, knownSequenceIds) {
8743
8823
  if (actions === void 0) return [];
8744
8824
  const buttons = actions.buttons ?? [];
8745
8825
  if (buttons.length === 0) return [];
8746
- const known = declaredSequences(actions);
8747
- return buttons.filter((b) => !known.has(b.sequence)).map((b) => `${b.id}→${b.sequence}`);
8826
+ return buttons.filter((b) => !knownSequenceIds.has(b.sequenceId)).map((b) => `${b.id}→${b.sequenceId}`);
8748
8827
  }
8749
8828
  /** How many snooze choices one notification may carry. The schema caps the
8750
8829
  * authored list at 4; this is the same bound applied to a stored rule that
@@ -8795,7 +8874,7 @@ function snoozeChoices(options) {
8795
8874
  * attention than the notification did.
8796
8875
  */
8797
8876
  function buildSnoozeButtons(input) {
8798
- if (isSnoozeExempt(input.rule)) return [];
8877
+ if (isSnoozeExempt(input.rule, input.sequenceActionsOf)) return [];
8799
8878
  return snoozeChoices(input.rule.snoozeOptions).map((minutes) => {
8800
8879
  const actionId = snoozeButtonId(minutes);
8801
8880
  return {
@@ -11084,10 +11163,11 @@ function matchesIdentityScope(subject, scope) {
11084
11163
  * Derived, never a stored flag: both rule editors prune condition keys to the
11085
11164
  * catalog on save, so a new key would be dropped by the first edit.
11086
11165
  */
11087
- function ruleBelongsToAlarm(rule) {
11166
+ function ruleBelongsToAlarm(rule, sequenceActionsOf) {
11088
11167
  return require_clip_model_registry.ruleBelongsToAlarm({
11089
11168
  conditions: rule.conditions,
11090
- ...rule.actions?.onTrigger !== void 0 ? { onTrigger: rule.actions.onTrigger } : {}
11169
+ ...rule.actions !== void 0 ? { actions: rule.actions } : {},
11170
+ ...sequenceActionsOf !== void 0 ? { sequenceActionsOf } : {}
11091
11171
  });
11092
11172
  }
11093
11173
  /**
@@ -12862,7 +12942,7 @@ function incomingFromAlarmTransition(input) {
12862
12942
  const kind = transition.kind;
12863
12943
  const keys = alarmTextKeys(transition, input.body);
12864
12944
  const sensor = transition.sensor;
12865
- const alarmSource = kind === "alarm-triggered" ? source : void 0;
12945
+ const alarmSource = (kind === "alarm-triggered" || kind === "alarm-pending") && source !== void 0 ? source : void 0;
12866
12946
  const facts = {
12867
12947
  eventId: `alarm:${kind}:${String(transition.at)}${sensor !== void 0 ? `:${String(sensor.deviceId)}` : ""}${transition.refusalId !== void 0 ? `:${transition.refusalId}` : ""}`,
12868
12948
  timestamp: transition.at,
@@ -12975,7 +13055,7 @@ function deriveTankBand(reading) {
12975
13055
  case null: return null;
12976
13056
  }
12977
13057
  }
12978
- function isRecord$7(value) {
13058
+ function isRecord$9(value) {
12979
13059
  return typeof value === "object" && value !== null && !Array.isArray(value);
12980
13060
  }
12981
13061
  function tankStatusOf(value) {
@@ -12991,11 +13071,11 @@ function vacuumTanksOf(data) {
12991
13071
  if (data["capName"] !== "vacuum-control") return null;
12992
13072
  const deviceId = data["deviceId"];
12993
13073
  const slice = data["slice"];
12994
- if (typeof deviceId !== "number" || !isRecord$7(slice)) return null;
13074
+ if (typeof deviceId !== "number" || !isRecord$9(slice)) return null;
12995
13075
  const tanks = [];
12996
13076
  for (const key of NC_TANK_KEYS) {
12997
13077
  const raw = slice[key];
12998
- if (!isRecord$7(raw)) continue;
13078
+ if (!isRecord$9(raw)) continue;
12999
13079
  const level = raw["level"];
13000
13080
  tanks.push({
13001
13081
  key,
@@ -14516,7 +14596,7 @@ var en_default = {
14516
14596
  "snooze.scope.anyRule": "A rule",
14517
14597
  "snooze.scope.rule": "“{{rule}}”",
14518
14598
  "alarm.arm.body.allDevices": "Active: all devices",
14519
- "alarm.arm.body.allDevicesExcept": "Active: all devices except {{count}}: {{names}}",
14599
+ "alarm.arm.body.allDevicesExcept": "Active: all devices except {{names}}",
14520
14600
  "alarm.arm.body.noDevices": "Active: no device restriction",
14521
14601
  "alarm.arm.body.devices": "Active: {{names}}",
14522
14602
  "alarm.arm.body.devicesSkipped": "Active: {{names}}\nExcluded: {{skippedNames}}",
@@ -14526,7 +14606,8 @@ var en_default = {
14526
14606
  "alarm.arming.body.uncovered": "Active: none — no rule uses this mode",
14527
14607
  "alarm.arming.body.allSkipped": "Active: none\nExcluded: {{names}}",
14528
14608
  "alarm.arming.body.skipped": "Excluded: {{names}}",
14529
- "alarm.arm.body.bypassed": "Active: {{devices}}\nBypassed: {{bypassedNames}}",
14609
+ "alarm.arm.body.bypassed": "Active: {{names}}\nBypassed: {{bypassedNames}}",
14610
+ "alarm.arm.body.bypassedAll": "Active: all devices\nBypassed: {{bypassedNames}}",
14530
14611
  "alarm.arming.body.bypassed": "Bypassed: {{bypassedNames}}",
14531
14612
  "alarm.armRefused.body": "Open: {{names}}",
14532
14613
  "alarm.armRefused.body.hint": "Open: {{names}}\nTap “Arm anyway” to arm with them excluded.",
@@ -14843,7 +14924,7 @@ var it_default = {
14843
14924
  "snooze.scope.anyRule": "Una regola",
14844
14925
  "snooze.scope.rule": "“{{rule}}”",
14845
14926
  "alarm.arm.body.allDevices": "Attivi: tutti i dispositivi",
14846
- "alarm.arm.body.allDevicesExcept": "Attivi: tutti i dispositivi tranne {{count}}: {{names}}",
14927
+ "alarm.arm.body.allDevicesExcept": "Attivi: tutti i dispositivi tranne {{names}}",
14847
14928
  "alarm.arm.body.noDevices": "Attivi: nessuna restrizione",
14848
14929
  "alarm.arm.body.devices": "Attivi: {{names}}",
14849
14930
  "alarm.arm.body.devicesSkipped": "Attivi: {{names}}\nEsclusi: {{skippedNames}}",
@@ -14853,7 +14934,8 @@ var it_default = {
14853
14934
  "alarm.arming.body.uncovered": "Attivi: nessuno — nessuna regola usa questa modalità",
14854
14935
  "alarm.arming.body.allSkipped": "Attivi: nessuno\nEsclusi: {{names}}",
14855
14936
  "alarm.arming.body.skipped": "Esclusi: {{names}}",
14856
- "alarm.arm.body.bypassed": "Attivi: {{devices}}\nBypassati: {{bypassedNames}}",
14937
+ "alarm.arm.body.bypassed": "Attivi: {{names}}\nBypassati: {{bypassedNames}}",
14938
+ "alarm.arm.body.bypassedAll": "Attivi: tutti i dispositivi\nBypassati: {{bypassedNames}}",
14857
14939
  "alarm.arming.body.bypassed": "Bypassati: {{bypassedNames}}",
14858
14940
  "alarm.armRefused.body": "Aperti: {{names}}",
14859
14941
  "alarm.armRefused.body.hint": "Aperti: {{names}}\nTocca “Arma comunque” per armare escludendoli.",
@@ -20333,6 +20415,350 @@ var NcDeviceMuteStore = class {
20333
20415
  }
20334
20416
  };
20335
20417
  //#endregion
20418
+ //#region src/notification-center/migrate-legacy-actions.ts
20419
+ function isRecord$8(value) {
20420
+ return typeof value === "object" && value !== null && !Array.isArray(value);
20421
+ }
20422
+ /** A legacy inline sequence row — detected by `name` + `actions` + `enabled`. */
20423
+ function asLegacySequence(value) {
20424
+ const parsed = require_clip_model_registry.NcLegacyRuleActionSequenceSchema.safeParse(value);
20425
+ return parsed.success ? parsed.data : null;
20426
+ }
20427
+ /**
20428
+ * True when `onTrigger` still looks like the pre-shared-sequence array of
20429
+ * named sequences (not an array of `{ kind: 'sequence' | 'do', … }` bindings).
20430
+ */
20431
+ function onTriggerLooksLegacy(onTrigger) {
20432
+ if (!Array.isArray(onTrigger) || onTrigger.length === 0) return false;
20433
+ const first = onTrigger[0];
20434
+ if (!isRecord$8(first)) return false;
20435
+ if (first["kind"] === "sequence" || first["kind"] === "do") return false;
20436
+ return asLegacySequence(first) !== null;
20437
+ }
20438
+ function legacyButtonSequenceName(button) {
20439
+ if (!isRecord$8(button)) return null;
20440
+ const name = button["sequence"];
20441
+ return typeof name === "string" && name.length > 0 ? name : null;
20442
+ }
20443
+ /**
20444
+ * Rewrite a raw `actions` block. Returns the (possibly unchanged) new-shape
20445
+ * actions and whether anything was materialised into the sequence store.
20446
+ */
20447
+ async function migrateLegacyRuleActions(actions, createSequence) {
20448
+ if (actions === void 0 || actions === null) return {
20449
+ actions: void 0,
20450
+ migrated: false
20451
+ };
20452
+ if (!isRecord$8(actions)) return {
20453
+ actions: void 0,
20454
+ migrated: false
20455
+ };
20456
+ if (!onTriggerLooksLegacy(actions["onTrigger"]) && actions["sequence"] === void 0) {
20457
+ const buttons = actions["buttons"];
20458
+ if (!(Array.isArray(buttons) && buttons.some((b) => legacyButtonSequenceName(b) !== null))) {
20459
+ const parsed = require_clip_model_registry.NcRuleActionsSchema.safeParse(actions);
20460
+ if (parsed.success) return {
20461
+ actions: parsed.data,
20462
+ migrated: false
20463
+ };
20464
+ return {
20465
+ actions: void 0,
20466
+ migrated: false
20467
+ };
20468
+ }
20469
+ }
20470
+ const legacySequences = [];
20471
+ const onTriggerRaw = actions["onTrigger"];
20472
+ if (Array.isArray(onTriggerRaw)) for (const entry of onTriggerRaw) {
20473
+ const seq = asLegacySequence(entry);
20474
+ if (seq !== null) legacySequences.push(seq);
20475
+ }
20476
+ const nameToId = /* @__PURE__ */ new Map();
20477
+ for (const sequence of legacySequences) {
20478
+ const key = sequence.name.trim();
20479
+ if (key === "" || nameToId.has(key)) continue;
20480
+ const created = await createSequence({
20481
+ name: sequence.name,
20482
+ ...sequence.minDelaySec !== void 0 ? { minDelaySec: sequence.minDelaySec } : {},
20483
+ actions: [...sequence.actions]
20484
+ });
20485
+ nameToId.set(key, created.id);
20486
+ }
20487
+ const onTriggerBindings = [];
20488
+ for (const sequence of legacySequences) {
20489
+ if (!sequence.enabled) continue;
20490
+ const id = nameToId.get(sequence.name.trim());
20491
+ if (id === void 0) continue;
20492
+ onTriggerBindings.push({
20493
+ kind: "sequence",
20494
+ sequenceId: id
20495
+ });
20496
+ }
20497
+ const buttonsRaw = actions["buttons"];
20498
+ const buttons = [];
20499
+ if (Array.isArray(buttonsRaw)) for (const button of buttonsRaw) {
20500
+ if (!isRecord$8(button)) continue;
20501
+ const id = typeof button["id"] === "string" ? button["id"] : "";
20502
+ const label = typeof button["label"] === "string" ? button["label"] : "";
20503
+ if (id === "" || label === "") continue;
20504
+ const existingId = typeof button["sequenceId"] === "string" && button["sequenceId"].length > 0 ? button["sequenceId"] : void 0;
20505
+ const legacyName = legacyButtonSequenceName(button);
20506
+ const sequenceId = existingId ?? (legacyName !== null ? nameToId.get(legacyName.trim()) : void 0);
20507
+ if (sequenceId === void 0) continue;
20508
+ buttons.push({
20509
+ id,
20510
+ label,
20511
+ sequenceId,
20512
+ ...typeof button["icon"] === "string" ? { icon: button["icon"] } : {},
20513
+ ...typeof button["destructive"] === "boolean" ? { destructive: button["destructive"] } : {}
20514
+ });
20515
+ }
20516
+ const next = {
20517
+ ...Array.isArray(actions["onActivation"]) ? { onActivation: actions["onActivation"] } : {},
20518
+ ...Array.isArray(actions["onDeactivation"]) ? { onDeactivation: actions["onDeactivation"] } : {},
20519
+ ...onTriggerBindings.length > 0 ? { onTrigger: onTriggerBindings } : {},
20520
+ ...Array.isArray(actions["onTriggerOff"]) ? { onTriggerOff: actions["onTriggerOff"] } : {},
20521
+ ...buttons.length > 0 ? { buttons } : {}
20522
+ };
20523
+ const parsed = require_clip_model_registry.NcRuleActionsSchema.safeParse(next);
20524
+ if (!parsed.success) return {
20525
+ actions: void 0,
20526
+ migrated: true
20527
+ };
20528
+ return {
20529
+ actions: (parsed.data.onActivation?.length ?? 0) === 0 && (parsed.data.onDeactivation?.length ?? 0) === 0 && (parsed.data.onTrigger?.length ?? 0) === 0 && (parsed.data.onTriggerOff?.length ?? 0) === 0 && (parsed.data.buttons?.length ?? 0) === 0 ? void 0 : parsed.data,
20530
+ migrated: true
20531
+ };
20532
+ }
20533
+ //#endregion
20534
+ //#region src/notification-center/sequence-store.ts
20535
+ /**
20536
+ * Shared action sequences for the notification centre.
20537
+ *
20538
+ * A sequence is a named, ordered run of steps (`wait` | `cap`) with its own
20539
+ * throttle. Rules and notification buttons bind to it by id — never by copying
20540
+ * the steps inline — so two rules that open the same gate share one budget and
20541
+ * one authoring surface.
20542
+ *
20543
+ * Mirrors {@link NcDeviceMuteStore}: declare the collection, mirror into RAM,
20544
+ * write-through so a failed persist never leaves a phantom sequence the next
20545
+ * boot will not know.
20546
+ */
20547
+ /**
20548
+ * @durable class=config owner=notification-center
20549
+ * write="an admin creates/edits/deletes a shared sequence via
20550
+ * `notificationRules.createSequence` / `updateSequence` / `deleteSequence`,
20551
+ * or the one-shot boot migration that materialises legacy inline sequences"
20552
+ * retention="none — a row goes only when an admin deletes it. Bounded by the
20553
+ * sequence set a human is willing to author."
20554
+ */
20555
+ var NC_SEQUENCES_COLLECTION = "notification-center:sequences";
20556
+ var NC_SEQUENCES_COLUMNS = [
20557
+ {
20558
+ name: "id",
20559
+ type: "TEXT",
20560
+ primaryKey: true,
20561
+ notNull: true
20562
+ },
20563
+ {
20564
+ name: "name",
20565
+ type: "TEXT",
20566
+ notNull: true
20567
+ },
20568
+ {
20569
+ name: "minDelaySec",
20570
+ type: "INTEGER"
20571
+ },
20572
+ {
20573
+ name: "actions",
20574
+ type: "JSON",
20575
+ notNull: true
20576
+ },
20577
+ {
20578
+ name: "createdAt",
20579
+ type: "INTEGER",
20580
+ notNull: true
20581
+ },
20582
+ {
20583
+ name: "updatedAt",
20584
+ type: "INTEGER",
20585
+ notNull: true
20586
+ }
20587
+ ];
20588
+ function isRecord$7(value) {
20589
+ return typeof value === "object" && value !== null && !Array.isArray(value);
20590
+ }
20591
+ function parseActions(raw) {
20592
+ if (!Array.isArray(raw) || raw.length === 0) return null;
20593
+ const out = [];
20594
+ for (const entry of raw) {
20595
+ const parsed = require_clip_model_registry.NcRuleActionSchema.safeParse(entry);
20596
+ if (!parsed.success) return null;
20597
+ out.push(parsed.data);
20598
+ }
20599
+ return out;
20600
+ }
20601
+ function rowToSequence(data) {
20602
+ const id = data["id"];
20603
+ const name = data["name"];
20604
+ if (typeof id !== "string" || id.length === 0) return null;
20605
+ if (typeof name !== "string" || name.length === 0) return null;
20606
+ const actions = parseActions(data["actions"]);
20607
+ if (actions === null) return null;
20608
+ const minDelayRaw = data["minDelaySec"];
20609
+ const minDelaySec = typeof minDelayRaw === "number" && Number.isInteger(minDelayRaw) ? minDelayRaw : void 0;
20610
+ const candidate = {
20611
+ id,
20612
+ name,
20613
+ ...minDelaySec !== void 0 ? { minDelaySec } : {},
20614
+ actions: [...actions]
20615
+ };
20616
+ const parsed = require_clip_model_registry.NcSharedSequenceSchema.safeParse(candidate);
20617
+ return parsed.success ? parsed.data : null;
20618
+ }
20619
+ var NcSequenceStore = class {
20620
+ byId = /* @__PURE__ */ new Map();
20621
+ store;
20622
+ logger;
20623
+ now;
20624
+ constructor(deps) {
20625
+ this.store = deps.store;
20626
+ this.logger = deps.logger;
20627
+ this.now = deps.now ?? (() => Date.now());
20628
+ }
20629
+ static async declare(store) {
20630
+ await store.declareCollection.mutate({
20631
+ collection: NC_SEQUENCES_COLLECTION,
20632
+ columns: [...NC_SEQUENCES_COLUMNS]
20633
+ });
20634
+ }
20635
+ /**
20636
+ * (Re)hydrate. A failed load KEEPS what is already in memory — dropping every
20637
+ * sequence on a transient store error would leave every button and binding
20638
+ * pointing at nothing until the next successful load.
20639
+ */
20640
+ async load() {
20641
+ try {
20642
+ const rows = await this.store.query.query({
20643
+ collection: NC_SEQUENCES_COLLECTION,
20644
+ filter: { limit: 1e4 }
20645
+ });
20646
+ const next = /* @__PURE__ */ new Map();
20647
+ let skipped = 0;
20648
+ for (const row of rows) {
20649
+ if (!isRecord$7(row.data)) {
20650
+ skipped += 1;
20651
+ continue;
20652
+ }
20653
+ const sequence = rowToSequence(row.data);
20654
+ if (sequence === null) {
20655
+ skipped += 1;
20656
+ continue;
20657
+ }
20658
+ next.set(sequence.id, sequence);
20659
+ }
20660
+ this.byId.clear();
20661
+ for (const [id, sequence] of next) this.byId.set(id, sequence);
20662
+ if (skipped > 0) this.logger.warn("sequence rows skipped as malformed — bindings to them will miss", { meta: { skipped } });
20663
+ } catch (err) {
20664
+ this.logger.warn("sequence load failed — keeping the sequences already in memory", { meta: {
20665
+ error: String(err),
20666
+ sequences: this.byId.size
20667
+ } });
20668
+ }
20669
+ }
20670
+ list() {
20671
+ return [...this.byId.values()].toSorted((a, b) => a.name.localeCompare(b.name));
20672
+ }
20673
+ get(sequenceId) {
20674
+ return this.byId.get(sequenceId) ?? null;
20675
+ }
20676
+ /** Hot-path existence check for button minting. */
20677
+ has(sequenceId) {
20678
+ return this.byId.has(sequenceId);
20679
+ }
20680
+ knownIds() {
20681
+ return new Set(this.byId.keys());
20682
+ }
20683
+ async create(input) {
20684
+ const now = this.now();
20685
+ const sequence = {
20686
+ id: (0, node_crypto.randomUUID)(),
20687
+ name: input.name,
20688
+ ...input.minDelaySec !== void 0 ? { minDelaySec: input.minDelaySec } : {},
20689
+ actions: [...input.actions]
20690
+ };
20691
+ await this.persist(sequence, now, now);
20692
+ this.byId.set(sequence.id, sequence);
20693
+ return sequence;
20694
+ }
20695
+ /**
20696
+ * Create with a CALLER-chosen id — used by the legacy migration so a rewrite
20697
+ * can name the sequence before rewriting the rule that points at it.
20698
+ */
20699
+ async createWithId(sequence) {
20700
+ const now = this.now();
20701
+ const stored = {
20702
+ id: sequence.id,
20703
+ name: sequence.name,
20704
+ ...sequence.minDelaySec !== void 0 ? { minDelaySec: sequence.minDelaySec } : {},
20705
+ actions: [...sequence.actions]
20706
+ };
20707
+ await this.persist(stored, now, now);
20708
+ this.byId.set(stored.id, stored);
20709
+ return stored;
20710
+ }
20711
+ async update(sequenceId, input) {
20712
+ if (this.byId.get(sequenceId) === void 0) throw new Error(`sequence ${sequenceId} does not exist`);
20713
+ const now = this.now();
20714
+ const sequence = {
20715
+ id: sequenceId,
20716
+ name: input.name,
20717
+ ...input.minDelaySec !== void 0 ? { minDelaySec: input.minDelaySec } : {},
20718
+ actions: [...input.actions]
20719
+ };
20720
+ const createdAt = await this.readCreatedAt(sequenceId, now);
20721
+ await this.persist(sequence, createdAt, now);
20722
+ this.byId.set(sequenceId, sequence);
20723
+ return sequence;
20724
+ }
20725
+ async delete(sequenceId) {
20726
+ await this.store.delete.mutate({
20727
+ collection: NC_SEQUENCES_COLLECTION,
20728
+ key: sequenceId
20729
+ });
20730
+ this.byId.delete(sequenceId);
20731
+ }
20732
+ async readCreatedAt(sequenceId, fallback) {
20733
+ try {
20734
+ const raw = (await this.store.query.query({
20735
+ collection: NC_SEQUENCES_COLLECTION,
20736
+ filter: {
20737
+ where: { id: sequenceId },
20738
+ limit: 1
20739
+ }
20740
+ }))[0]?.data["createdAt"];
20741
+ return typeof raw === "number" && Number.isFinite(raw) ? raw : fallback;
20742
+ } catch {
20743
+ return fallback;
20744
+ }
20745
+ }
20746
+ async persist(sequence, createdAt, updatedAt) {
20747
+ await this.store.set.mutate({
20748
+ collection: NC_SEQUENCES_COLLECTION,
20749
+ key: sequence.id,
20750
+ value: {
20751
+ id: sequence.id,
20752
+ name: sequence.name,
20753
+ ...sequence.minDelaySec !== void 0 ? { minDelaySec: sequence.minDelaySec } : {},
20754
+ actions: sequence.actions,
20755
+ createdAt,
20756
+ updatedAt
20757
+ }
20758
+ });
20759
+ }
20760
+ };
20761
+ //#endregion
20336
20762
  //#region src/notification-center/device-prune.ts
20337
20763
  function judgeList(list, isDead) {
20338
20764
  const dead = list.filter(isDead);
@@ -20446,58 +20872,49 @@ function pruneRuleConditions(conditions, isDead, notes) {
20446
20872
  if (sceneDevice !== void 0 && isDead(sceneDevice)) notes.kept("conditions.scene", [String(sceneDevice)]);
20447
20873
  return changed ? next : null;
20448
20874
  }
20449
- /** A sequence's device steps, dead ones dropped. `null` when none was dead. */
20450
- function pruneSequence(sequence, isDead) {
20451
- const dead = [];
20452
- const kept = sequence.actions.filter((step) => {
20453
- if (step.kind !== "cap" || !isDead(step.deviceId)) return true;
20454
- dead.push(step.deviceId);
20455
- return false;
20456
- });
20457
- if (dead.length === 0) return null;
20458
- const anyDeviceStep = kept.some((step) => step.kind === "cap");
20459
- const [first, ...rest] = kept;
20460
- if (!anyDeviceStep || first === void 0) return {
20461
- next: "removed",
20462
- dead
20463
- };
20464
- return {
20465
- next: {
20466
- ...sequence,
20467
- actions: [first, ...rest]
20468
- },
20469
- dead
20875
+ /** A Do binding whose device is dead is dropped; sequence refs are left alone
20876
+ * (shared sequences are pruned on their own store, not per-rule). */
20877
+ function pruneHookBindings(bindings, field, isDead, notes) {
20878
+ if (bindings === void 0) return {
20879
+ next: void 0,
20880
+ changed: false
20470
20881
  };
20471
- }
20472
- /** The actions after a device prune — `null` when no step moved. */
20473
- function pruneRuleActions(actions, isDead, notes) {
20474
- const sequences = actions.onTrigger;
20475
- if (sequences === void 0) return null;
20882
+ const kept = [];
20476
20883
  let changed = false;
20477
- const removedNames = /* @__PURE__ */ new Set();
20478
- const nextSequences = [];
20479
- for (const sequence of sequences) {
20480
- const pruned = pruneSequence(sequence, isDead);
20481
- if (pruned === null) {
20482
- nextSequences.push(sequence);
20884
+ for (const binding of bindings) {
20885
+ if (binding.kind === "sequence") {
20886
+ kept.push(binding);
20483
20887
  continue;
20484
20888
  }
20485
- changed = true;
20486
- const field = `actions.onTrigger.${sequence.name}`;
20487
- if (pruned.next === "removed") {
20488
- removedNames.add(sequence.name);
20489
- notes.removed(field, deviceSubjects(pruned.dead), "sequence-removed");
20490
- } else {
20491
- nextSequences.push(pruned.next);
20492
- notes.removed(field, deviceSubjects(pruned.dead));
20889
+ if (binding.action.kind === "cap" && isDead(binding.action.deviceId)) {
20890
+ changed = true;
20891
+ notes.removed(field, deviceSubjects([binding.action.deviceId]), "binding-removed");
20892
+ continue;
20493
20893
  }
20894
+ kept.push(binding);
20494
20895
  }
20495
- if (!changed) return null;
20496
- const buttons = actions.buttons;
20896
+ if (!changed) return {
20897
+ next: bindings,
20898
+ changed: false
20899
+ };
20900
+ return {
20901
+ next: kept.length > 0 ? kept : void 0,
20902
+ changed: true
20903
+ };
20904
+ }
20905
+ /** The actions after a device prune — `null` when no binding moved. */
20906
+ function pruneRuleActions(actions, isDead, notes) {
20907
+ const onActivation = pruneHookBindings(actions.onActivation, "actions.onActivation", isDead, notes);
20908
+ const onDeactivation = pruneHookBindings(actions.onDeactivation, "actions.onDeactivation", isDead, notes);
20909
+ const onTrigger = pruneHookBindings(actions.onTrigger, "actions.onTrigger", isDead, notes);
20910
+ const onTriggerOff = pruneHookBindings(actions.onTriggerOff, "actions.onTriggerOff", isDead, notes);
20911
+ if (!onActivation.changed && !onDeactivation.changed && !onTrigger.changed && !onTriggerOff.changed) return null;
20497
20912
  return {
20498
- ...actions,
20499
- onTrigger: nextSequences,
20500
- ...buttons !== void 0 ? { buttons: buttons.filter((button) => !removedNames.has(button.sequence)) } : {}
20913
+ ...onActivation.next !== void 0 ? { onActivation: [...onActivation.next] } : {},
20914
+ ...onDeactivation.next !== void 0 ? { onDeactivation: [...onDeactivation.next] } : {},
20915
+ ...onTrigger.next !== void 0 ? { onTrigger: [...onTrigger.next] } : {},
20916
+ ...onTriggerOff.next !== void 0 ? { onTriggerOff: [...onTriggerOff.next] } : {},
20917
+ ...actions.buttons !== void 0 ? { buttons: actions.buttons } : {}
20501
20918
  };
20502
20919
  }
20503
20920
  function planRuleDevices(rule, isDead, guard) {
@@ -24732,6 +25149,10 @@ function buildTemplateVars(texts, entry, deviceName, zoneLabels, timeZone) {
24732
25149
  return {
24733
25150
  camera: deviceName,
24734
25151
  class: detection?.className ?? (systemEvent !== void 0 ? "" : subject.className),
25152
+ classLabel: (() => {
25153
+ const wire = detection?.className ?? (systemEvent !== void 0 ? "" : subject.className);
25154
+ return wire.length === 0 ? "" : require_clip_model_registry.eventKindLabel(wire, texts.language);
25155
+ })(),
24735
25156
  label: detection?.label ?? subject.label ?? "",
24736
25157
  zones: zoneLabels.join(", "),
24737
25158
  subject: detection?.className !== void 0 ? ncSubjectText(texts, {
@@ -28873,9 +29294,32 @@ function lastRunRow(data) {
28873
29294
  }
28874
29295
  //#endregion
28875
29296
  //#region src/notification-center/rule-actions.ts
29297
+ function wrapDo(action) {
29298
+ const name = action.kind === "wait" ? `wait ${String(action.seconds)}s` : `${action.cap}.${action.method}`;
29299
+ return {
29300
+ id: `do:${name}`,
29301
+ name,
29302
+ enabled: true,
29303
+ actions: [action],
29304
+ throttle: false
29305
+ };
29306
+ }
29307
+ function resolveBinding(binding, lookup) {
29308
+ if (binding.kind === "do") return wrapDo(binding.action);
29309
+ const sequence = lookup(binding.sequenceId);
29310
+ if (sequence === null) return { missing: binding.sequenceId };
29311
+ return {
29312
+ id: sequence.id,
29313
+ name: sequence.name,
29314
+ enabled: true,
29315
+ ...sequence.minDelaySec !== void 0 ? { minDelaySec: sequence.minDelaySec } : {},
29316
+ actions: sequence.actions,
29317
+ throttle: true
29318
+ };
29319
+ }
28876
29320
  var NcRuleActionRunner = class {
28877
29321
  deps;
28878
- /** Last run per `${ruleId}:${sequenceName}` — the throttle's memory. */
29322
+ /** Last run per `sequenceId` — the throttle's memory (shared across rules). */
28879
29323
  lastRunAt = /* @__PURE__ */ new Map();
28880
29324
  now;
28881
29325
  sleep;
@@ -28885,11 +29329,49 @@ var NcRuleActionRunner = class {
28885
29329
  this.sleep = deps.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
28886
29330
  }
28887
29331
  /**
28888
- * Run every enabled sequence, in order.
29332
+ * Resolve bindings via the sequence store and run each, in order.
28889
29333
  *
28890
29334
  * Never throws: an actuation that fails must not cost the NOTIFICATION about
28891
- * the very same event. The operator finds out from the log and from the
28892
- * device not having moved — not from silence on both.
29335
+ * the very same event.
29336
+ */
29337
+ async runBindings(input) {
29338
+ const sequences = [];
29339
+ const missing = [];
29340
+ for (const binding of input.bindings) {
29341
+ const resolved = resolveBinding(binding, input.lookup);
29342
+ if ("missing" in resolved) {
29343
+ this.deps.logger.warn("rule action binding skipped — sequence missing", {
29344
+ tags: { deviceId: input.deviceId },
29345
+ meta: {
29346
+ ruleId: input.ruleId,
29347
+ sequenceId: resolved.missing
29348
+ }
29349
+ });
29350
+ missing.push({
29351
+ id: resolved.missing,
29352
+ name: resolved.missing,
29353
+ ran: false,
29354
+ stepsRun: 0,
29355
+ skippedReason: "missing"
29356
+ });
29357
+ continue;
29358
+ }
29359
+ sequences.push(resolved);
29360
+ }
29361
+ const ran = await this.run({
29362
+ ruleId: input.ruleId,
29363
+ ruleName: input.ruleName,
29364
+ deviceId: input.deviceId,
29365
+ sequences,
29366
+ ...input.origin !== void 0 ? { origin: input.origin } : {}
29367
+ });
29368
+ return [...missing, ...ran];
29369
+ }
29370
+ /**
29371
+ * Run every enabled sequence, in order.
29372
+ *
29373
+ * Prefer {@link runBindings} at call sites that hold bindings. This remains
29374
+ * for button grants (force-enabled shared sequence) and tests.
28893
29375
  */
28894
29376
  async run(input) {
28895
29377
  const out = [];
@@ -28898,38 +29380,41 @@ var NcRuleActionRunner = class {
28898
29380
  }
28899
29381
  async runOne(input, sequence) {
28900
29382
  if (!sequence.enabled) return {
29383
+ id: sequence.id,
28901
29384
  name: sequence.name,
28902
29385
  ran: false,
28903
29386
  stepsRun: 0,
28904
29387
  skippedReason: "disabled"
28905
29388
  };
28906
29389
  if (sequence.actions.length === 0) return {
29390
+ id: sequence.id,
28907
29391
  name: sequence.name,
28908
29392
  ran: false,
28909
29393
  stepsRun: 0,
28910
29394
  skippedReason: "empty"
28911
29395
  };
28912
- const key = `${input.ruleId}:${sequence.name}`;
28913
29396
  const now = this.now();
28914
- const minDelayMs = (sequence.minDelaySec ?? 0) * 1e3;
28915
- const last = this.lastRunAt.get(key);
29397
+ const minDelayMs = sequence.throttle ? (sequence.minDelaySec ?? 0) * 1e3 : 0;
29398
+ const last = this.lastRunAt.get(sequence.id);
28916
29399
  if (minDelayMs > 0 && last !== void 0 && now - last < minDelayMs) {
28917
29400
  this.deps.logger.debug("rule action sequence throttled", {
28918
29401
  tags: { deviceId: input.deviceId },
28919
29402
  meta: {
28920
29403
  ruleId: input.ruleId,
29404
+ sequenceId: sequence.id,
28921
29405
  sequence: sequence.name,
28922
29406
  sinceLastMs: now - last
28923
29407
  }
28924
29408
  });
28925
29409
  return {
29410
+ id: sequence.id,
28926
29411
  name: sequence.name,
28927
29412
  ran: false,
28928
29413
  stepsRun: 0,
28929
29414
  skippedReason: "throttled"
28930
29415
  };
28931
29416
  }
28932
- this.lastRunAt.set(key, now);
29417
+ if (sequence.throttle) this.lastRunAt.set(sequence.id, now);
28933
29418
  let stepsRun = 0;
28934
29419
  for (const [index, action] of sequence.actions.entries()) try {
28935
29420
  await this.step(action, input.origin);
@@ -28940,6 +29425,7 @@ var NcRuleActionRunner = class {
28940
29425
  meta: {
28941
29426
  ruleId: input.ruleId,
28942
29427
  rule: input.ruleName,
29428
+ sequenceId: sequence.id,
28943
29429
  sequence: sequence.name,
28944
29430
  step: index,
28945
29431
  action: action.kind === "cap" ? `${action.cap}.${action.method}` : "wait",
@@ -28947,6 +29433,7 @@ var NcRuleActionRunner = class {
28947
29433
  }
28948
29434
  });
28949
29435
  return {
29436
+ id: sequence.id,
28950
29437
  name: sequence.name,
28951
29438
  ran: true,
28952
29439
  stepsRun,
@@ -28958,11 +29445,13 @@ var NcRuleActionRunner = class {
28958
29445
  meta: {
28959
29446
  ruleId: input.ruleId,
28960
29447
  rule: input.ruleName,
29448
+ sequenceId: sequence.id,
28961
29449
  sequence: sequence.name,
28962
29450
  steps: stepsRun
28963
29451
  }
28964
29452
  });
28965
29453
  return {
29454
+ id: sequence.id,
28966
29455
  name: sequence.name,
28967
29456
  ran: true,
28968
29457
  stepsRun
@@ -28983,6 +29472,17 @@ var NcRuleActionRunner = class {
28983
29472
  });
28984
29473
  }
28985
29474
  };
29475
+ /** Force-enable a shared sequence for a button tap (always runs, still throttled). */
29476
+ function asForcedSequence(sequence) {
29477
+ return {
29478
+ id: sequence.id,
29479
+ name: sequence.name,
29480
+ enabled: true,
29481
+ ...sequence.minDelaySec !== void 0 ? { minDelaySec: sequence.minDelaySec } : {},
29482
+ actions: sequence.actions,
29483
+ throttle: true
29484
+ };
29485
+ }
28986
29486
  //#endregion
28987
29487
  //#region src/notification-center/rule-ai-analyst.ts
28988
29488
  /**
@@ -30829,7 +31329,7 @@ function snoozeVerdict(input, deps) {
30829
31329
  });
30830
31330
  if (active === null) return { kind: "clear" };
30831
31331
  const rule = deps.ruleFor(input.ruleId);
30832
- if (rule !== null && isSnoozeExempt(rule)) return {
31332
+ if (rule !== null && isSnoozeExempt(rule, deps.sequenceActionsOf)) return {
30833
31333
  kind: "exempt",
30834
31334
  snooze: active
30835
31335
  };
@@ -36630,13 +37130,16 @@ function frozenOccupancy(rule, occ) {
36630
37130
  function fireRefFor(rule, subject, kind) {
36631
37131
  const hasEventMedia = kind === "object-event" || kind === "package-event";
36632
37132
  const className = subject.classNames[0];
37133
+ const linkedOwners = subject.trackId === void 0 && subject.linkedStills !== void 0 ? linkedMediaOwners(subject.linkedStills, linkedCameraMode(rule.media)) : void 0;
37134
+ const trackId = subject.trackId ?? linkedOwners?.trackId;
36633
37135
  return {
36634
37136
  ruleId: rule.id,
36635
37137
  ruleName: rule.name,
36636
37138
  deviceId: subject.deviceId,
36637
37139
  ...subject.sourceDeviceId !== void 0 ? { sourceDeviceId: subject.sourceDeviceId } : {},
36638
37140
  recordId: subject.recordId,
36639
- ...subject.trackId !== void 0 ? { trackId: subject.trackId } : {},
37141
+ ...trackId !== void 0 ? { trackId } : {},
37142
+ ...linkedOwners !== void 0 && linkedOwners.extraStills.length > 0 ? { extraStills: linkedOwners.extraStills.map((x) => ({ ...x })) } : {},
36640
37143
  ...hasEventMedia ? { eventId: subject.recordId } : {},
36641
37144
  timestamp: subject.timestamp,
36642
37145
  ...className !== void 0 ? { className } : {},
@@ -36941,6 +37444,8 @@ var NotificationCenter = class {
36941
37444
  snoozes;
36942
37445
  /** The permanent per-camera notification off switch — see `device-mute-store.ts`. */
36943
37446
  deviceMutes;
37447
+ /** Shared action sequences rules and buttons bind to by id. */
37448
+ sequences;
36944
37449
  /** The durable episode rows behind the sustained-state alerts. */
36945
37450
  alertStore;
36946
37451
  /** Battery band → notification, and the reminder sweep. */
@@ -37179,7 +37684,8 @@ var NotificationCenter = class {
37179
37684
  const drafts = [];
37180
37685
  const sequenceButtons = this.actionRunner === null || stored === null ? [] : buildActionButtons({
37181
37686
  actions: stored.actions,
37182
- mintUrl: ({ sequence, actionId }) => {
37687
+ knownSequenceIds: this.sequences.knownIds(),
37688
+ mintUrl: ({ sequenceId, actionId }) => {
37183
37689
  const id = (0, node_crypto.randomUUID)();
37184
37690
  drafts.push({
37185
37691
  id,
@@ -37189,7 +37695,7 @@ var NotificationCenter = class {
37189
37695
  id,
37190
37696
  ruleId: input.ruleId,
37191
37697
  ruleName: input.ruleName,
37192
- sequence,
37698
+ sequenceId,
37193
37699
  deviceId: input.deviceId,
37194
37700
  actionId,
37195
37701
  targetId: input.targetId,
@@ -37201,6 +37707,7 @@ var NotificationCenter = class {
37201
37707
  });
37202
37708
  const snoozeButtons = input.ownerUserId === void 0 || stored === null ? [] : buildSnoozeButtons({
37203
37709
  rule: stored,
37710
+ sequenceActionsOf: this.sequenceActionsOf,
37204
37711
  label: (minutes) => this.snoozeButtonLabel(minutes),
37205
37712
  mintUrl: ({ actionId, minutes }) => {
37206
37713
  const id = (0, node_crypto.randomUUID)();
@@ -37441,7 +37948,7 @@ var NotificationCenter = class {
37441
37948
  */
37442
37949
  async applyGrantedSnooze(grant) {
37443
37950
  const rule = this.rules.get(grant.ruleId);
37444
- if (rule !== null && isSnoozeExempt(rule)) throw new Error(`"${grant.ruleName}" is an alarm rule and cannot be snoozed`);
37951
+ if (rule !== null && isSnoozeExempt(rule, this.sequenceActionsOf)) throw new Error(`"${grant.ruleName}" is an alarm rule and cannot be snoozed`);
37445
37952
  const snooze = await this.snoozes.create({
37446
37953
  userId: grant.userId,
37447
37954
  scope: "device",
@@ -37740,16 +38247,13 @@ var NotificationCenter = class {
37740
38247
  if (this.actionRunner === null) throw new Error("no actuator is wired on this node");
37741
38248
  const rule = this.rules.get(grant.ruleId);
37742
38249
  if (rule === null) throw new Error(`rule ${grant.ruleId} no longer exists`);
37743
- const sequence = (rule.actions?.onTrigger ?? []).find((s) => s.name === grant.sequence);
37744
- if (sequence === void 0) throw new Error(`sequence "${grant.sequence}" no longer exists on rule ${rule.name}`);
38250
+ const sequence = this.sequences.get(grant.sequenceId);
38251
+ if (sequence === null) throw new Error(`sequence "${grant.sequenceId}" no longer exists`);
37745
38252
  const [outcome] = await this.actionRunner.run({
37746
38253
  ruleId: rule.id,
37747
38254
  ruleName: rule.name,
37748
38255
  deviceId: grant.deviceId,
37749
- sequences: [{
37750
- ...sequence,
37751
- enabled: true
37752
- }]
38256
+ sequences: [asForcedSequence(sequence)]
37753
38257
  });
37754
38258
  if (outcome === void 0) throw new Error("the sequence did not run");
37755
38259
  if (outcome.failedAt !== void 0) throw new Error(`step ${String(outcome.failedAt)} of "${sequence.name}" failed`);
@@ -37868,6 +38372,11 @@ var NotificationCenter = class {
37868
38372
  logger: this.logger.child("device-mute"),
37869
38373
  ...deps.now !== void 0 ? { now: deps.now } : {}
37870
38374
  });
38375
+ this.sequences = new NcSequenceStore({
38376
+ store: deps.store,
38377
+ logger: this.logger.child("sequences"),
38378
+ ...deps.now !== void 0 ? { now: deps.now } : {}
38379
+ });
37871
38380
  this.alertStore = new NcAlertStore({
37872
38381
  store: deps.store,
37873
38382
  logger: this.logger.child("device-alerts")
@@ -38134,6 +38643,7 @@ var NotificationCenter = class {
38134
38643
  await NcStateSummaryService.declare(store);
38135
38644
  await NcSnoozeStore.declare(store);
38136
38645
  await NcDeviceMuteStore.declare(store);
38646
+ await NcSequenceStore.declare(store);
38137
38647
  await TimelapseStore.declare(store);
38138
38648
  await NcSummaryStore.declare(store);
38139
38649
  await NcCalendarEventsStore.declare(store);
@@ -38195,6 +38705,8 @@ var NotificationCenter = class {
38195
38705
  sinceStartMs: now - t0
38196
38706
  } });
38197
38707
  };
38708
+ await timed("sequences", () => this.sequences.load());
38709
+ await timed("migrateLegacyActions", () => this.migrateLegacyRuleActions());
38198
38710
  await Promise.all([
38199
38711
  timed("texts", () => this.reloadTexts()),
38200
38712
  timed("rules", () => this.rules.load()),
@@ -39494,7 +40006,32 @@ var NotificationCenter = class {
39494
40006
  return { success: true };
39495
40007
  },
39496
40008
  setRuleEnabled: async ({ ruleId, enabled }) => {
39497
- await this.rules.setEnabled(ruleId, enabled);
40009
+ const updated = await this.rules.setEnabled(ruleId, enabled);
40010
+ const bindings = enabled ? updated.actions?.onActivation : updated.actions?.onDeactivation;
40011
+ if (bindings !== void 0 && bindings.length > 0 && this.actionRunner !== null) this.actionRunner.runBindings({
40012
+ ruleId: updated.id,
40013
+ ruleName: updated.name,
40014
+ deviceId: this.deviceIdForRuleActions(updated),
40015
+ bindings,
40016
+ lookup: (id) => this.sequences.get(id)
40017
+ });
40018
+ return { success: true };
40019
+ },
40020
+ listSequences: async () => ({ sequences: [...this.sequences.list()] }),
40021
+ getSequence: async ({ sequenceId }) => ({ sequence: this.sequences.get(sequenceId) }),
40022
+ createSequence: async ({ sequence }) => {
40023
+ const created = await this.sequences.create(sequence);
40024
+ this.logger.info("notification sequence created", { meta: { sequenceId: created.id } });
40025
+ return { sequence: created };
40026
+ },
40027
+ updateSequence: async ({ sequenceId, sequence }) => {
40028
+ const updated = await this.sequences.update(sequenceId, sequence);
40029
+ this.logger.info("notification sequence updated", { meta: { sequenceId } });
40030
+ return { sequence: updated };
40031
+ },
40032
+ deleteSequence: async ({ sequenceId }) => {
40033
+ await this.sequences.delete(sequenceId);
40034
+ this.logger.info("notification sequence deleted", { meta: { sequenceId } });
39498
40035
  return { success: true };
39499
40036
  },
39500
40037
  testRule: async ({ rule, lookbackMinutes }) => ({ results: [...await this.dryRun(rule, lookbackMinutes)] }),
@@ -40228,10 +40765,11 @@ var NotificationCenter = class {
40228
40765
  const alarmSource = subject.systemEvent?.alarmSource;
40229
40766
  const sourceRule = alarmSource !== void 0 ? this.rules.get(alarmSource.ruleId) : null;
40230
40767
  const media = sourceRule !== null ? mediaPolicyFields(sourceRule, void 0) : { media: "none" };
40768
+ const mediaDeviceId = alarmSource?.deviceId ?? panel.deviceId;
40231
40769
  const entries = targets.map((targetId) => ({
40232
40770
  ruleId: NC_ALARM_ANNOUNCE_RULE_ID,
40233
40771
  targetId,
40234
- deviceId: panel.deviceId,
40772
+ deviceId: mediaDeviceId,
40235
40773
  recordKind: "system-event",
40236
40774
  recordId: subject.recordId,
40237
40775
  payload: {
@@ -40240,8 +40778,9 @@ var NotificationCenter = class {
40240
40778
  priority: 2,
40241
40779
  ...media,
40242
40780
  subject: {
40243
- deviceId: panel.deviceId,
40781
+ deviceId: mediaDeviceId,
40244
40782
  ...alarmSource?.trackId !== void 0 ? { trackId: alarmSource.trackId } : {},
40783
+ ...alarmSource?.extraStills !== void 0 && alarmSource.extraStills.length > 0 ? { extraStills: alarmSource.extraStills.map((x) => ({ ...x })) } : {},
40245
40784
  ...alarmSource?.eventId !== void 0 ? { eventId: alarmSource.eventId } : {},
40246
40785
  className: "alarm",
40247
40786
  zones: [],
@@ -40991,7 +41530,7 @@ var NotificationCenter = class {
40991
41530
  } : {}
40992
41531
  }
40993
41532
  });
40994
- const unresolvable = unresolvableButtons(rule.actions);
41533
+ const unresolvable = unresolvableButtons(rule.actions, this.sequences.knownIds());
40995
41534
  if (unresolvable.length > 0) this.logger.warn("rule buttons reference sequences that do not exist — not sent", {
40996
41535
  tags: { deviceId: subject.deviceId },
40997
41536
  meta: {
@@ -41118,20 +41657,85 @@ var NotificationCenter = class {
41118
41657
  return this.groupStore.list(query);
41119
41658
  }
41120
41659
  /**
41121
- * Run this rule's `onTrigger` sequences for this match.
41660
+ * Materialise legacy inline `onTrigger` sequences into the shared store and
41661
+ * rewrite each rule's actions to bindings + `buttons.sequenceId`. Runs on RAW
41662
+ * rows before {@link NcRuleStore.load} so Zod never skips them.
41663
+ */
41664
+ async migrateLegacyRuleActions() {
41665
+ let rows;
41666
+ try {
41667
+ rows = await this.deps.store.query.query({
41668
+ collection: NC_RULES_COLLECTION,
41669
+ filter: { limit: 1e4 }
41670
+ });
41671
+ } catch (err) {
41672
+ this.logger.warn("legacy actions migration skipped — could not read rules", { meta: { error: String(err) } });
41673
+ return;
41674
+ }
41675
+ let migrated = 0;
41676
+ for (const row of rows) {
41677
+ const ruleBlob = row.data["rule"];
41678
+ if (typeof ruleBlob !== "object" || ruleBlob === null || Array.isArray(ruleBlob)) continue;
41679
+ const rule = { ...ruleBlob };
41680
+ const ruleId = typeof rule["id"] === "string" ? rule["id"] : void 0;
41681
+ if (ruleId === void 0) continue;
41682
+ const result = await migrateLegacyRuleActions(rule["actions"], async (input) => {
41683
+ return { id: (await this.sequences.create(input)).id };
41684
+ });
41685
+ if (!result.migrated) continue;
41686
+ if (result.actions !== void 0) rule["actions"] = result.actions;
41687
+ else delete rule["actions"];
41688
+ const { id: _rowId, ...columns } = row.data;
41689
+ await this.deps.store.set.mutate({
41690
+ collection: NC_RULES_COLLECTION,
41691
+ key: ruleId,
41692
+ value: {
41693
+ ...columns,
41694
+ rule
41695
+ }
41696
+ });
41697
+ migrated += 1;
41698
+ }
41699
+ if (migrated > 0) this.logger.info("migrated legacy inline rule actions into shared sequences", { meta: {
41700
+ rules: migrated,
41701
+ sequences: this.sequences.list().length
41702
+ } });
41703
+ }
41704
+ /**
41705
+ * True when this match is a clear / leave edge — the subject for
41706
+ * `onTriggerOff`. Raise / enter / ordinary matches use `onTrigger`.
41707
+ */
41708
+ isTriggerOffSubject(subject) {
41709
+ if (subject.sensorRaised === false) return true;
41710
+ if (subject.occupancy !== void 0 && subject.occupancy.occupied === false) return true;
41711
+ return false;
41712
+ }
41713
+ /** Device id for activation/deactivation log tags — first scoped device, else 0. */
41714
+ deviceIdForRuleActions(rule) {
41715
+ const devices = rule.conditions.devices;
41716
+ if (devices !== void 0 && devices.length > 0) {
41717
+ const first = devices[0];
41718
+ if (first !== void 0) return first;
41719
+ }
41720
+ return 0;
41721
+ }
41722
+ sequenceActionsOf = (sequenceId) => this.sequences.get(sequenceId)?.actions;
41723
+ /**
41724
+ * Run this rule's onTrigger / onTriggerOff bindings for this match.
41122
41725
  *
41123
41726
  * Called BEFORE the cooldown gate — see the call site for why. Everything
41124
41727
  * here is fire-and-forget and nothing may throw into the evaluation.
41125
41728
  */
41126
41729
  runRuleActions(rule, subject, kind, controls) {
41127
- const sequences = rule.actions?.onTrigger;
41128
- if (sequences === void 0 || sequences.length === 0) return;
41129
41730
  if (this.actionRunner === null || controls.runActions === false) return;
41130
- this.actionRunner.run({
41731
+ const bindings = this.isTriggerOffSubject(subject) ? rule.actions?.onTriggerOff : rule.actions?.onTrigger;
41732
+ if (bindings === void 0 || bindings.length === 0) return;
41733
+ this.actionRunner.runBindings({
41131
41734
  ruleId: rule.id,
41132
41735
  ruleName: rule.name,
41133
41736
  deviceId: subject.deviceId,
41134
- sequences,
41737
+ bindings,
41738
+ lookup: (id) => this.sequences.get(id),
41135
41739
  origin: fireRefFor(rule, subject, kind)
41136
41740
  });
41137
41741
  }
@@ -41261,7 +41865,10 @@ var NotificationCenter = class {
41261
41865
  ...target.params !== void 0 ? { params: target.params } : {},
41262
41866
  subject: {
41263
41867
  deviceId: subject.deviceId,
41264
- ...alarmSource?.trackId !== void 0 ? { trackId: alarmSource.trackId } : linkedOwners !== void 0 ? {
41868
+ ...alarmSource?.trackId !== void 0 ? {
41869
+ trackId: alarmSource.trackId,
41870
+ ...alarmSource.extraStills !== void 0 && alarmSource.extraStills.length > 0 ? { extraStills: alarmSource.extraStills.map((x) => ({ ...x })) } : {}
41871
+ } : linkedOwners !== void 0 ? {
41265
41872
  trackId: linkedOwners.trackId,
41266
41873
  ...linkedOwners.extraStills.length > 0 ? { extraStills: linkedOwners.extraStills.map((x) => ({ ...x })) } : {},
41267
41874
  analysisTiles: linkedOwners.analysisTiles.map((x) => ({ ...x }))
@@ -42035,7 +42642,8 @@ var NotificationCenter = class {
42035
42642
  snoozeDecision(input) {
42036
42643
  const verdict = snoozeVerdict(input, {
42037
42644
  activeFor: (query) => this.snoozes.activeFor(query),
42038
- ruleFor: (ruleId) => this.rules.get(ruleId)
42645
+ ruleFor: (ruleId) => this.rules.get(ruleId),
42646
+ sequenceActionsOf: this.sequenceActionsOf
42039
42647
  });
42040
42648
  if (verdict.kind === "exempt") this.logger.info("snooze does not apply — alarm rules are never silenced", {
42041
42649
  tags: { deviceId: input.deviceId },