@camstack/addon-post-analysis 1.2.321 → 1.2.323

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-BCx3qCL5.js → clip-model-registry-8IMzj7jJ.js} +4542 -3562
  2. package/dist/{clip-model-registry-Dnm9dfy0.mjs → clip-model-registry-BENGs4rw.mjs} +5926 -5036
  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-CPcZN3mA.mjs → _virtual_mf-localSharedImportMap___mfe_internal__addon_pipeline_analytics_widgets-CGo6RmK3.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-Dt-jaGuG.mjs → hostInit-D5UUhDKa.mjs} +2 -2
  10. package/dist/pipeline-analytics/index.js +941 -108
  11. package/dist/pipeline-analytics/index.mjs +941 -108
  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-CGUnHrR_.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-CVH5tT7I.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-BCx3qCL5.js");
5
+ const require_clip_model_registry = require("../clip-model-registry-8IMzj7jJ.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");
@@ -945,7 +945,7 @@ function grantMeta(grant) {
945
945
  ...common,
946
946
  ruleId: grant.ruleId,
947
947
  rule: grant.ruleName,
948
- sequence: grant.sequence
948
+ sequenceId: grant.sequenceId
949
949
  };
950
950
  case "snooze": return {
951
951
  ...common,
@@ -1948,6 +1948,21 @@ var ncAlarmPanelSchema = require_clip_model_registry.object({
1948
1948
  exitDelaySec: require_clip_model_registry.number().optional(),
1949
1949
  entryDelaySec: require_clip_model_registry.number().optional(),
1950
1950
  triggeredDurationSec: require_clip_model_registry.number().optional()
1951
+ })).default([]),
1952
+ /**
1953
+ * Active alarm configuration profile id. Empty / absent on a blob older than
1954
+ * profiles → seeded to Default on first read via {@link ensureAlarmProfiles}.
1955
+ */
1956
+ activeProfileId: require_clip_model_registry.string().default(require_clip_model_registry.NC_ALARM_DEFAULT_PROFILE_ID),
1957
+ /**
1958
+ * Named settings blobs. Loose on purpose — each profile's settings are
1959
+ * re-validated through {@link settingsBlobFromPersisted} so a corrupt entry
1960
+ * drops rather than taking down the panel.
1961
+ */
1962
+ profiles: require_clip_model_registry.array(require_clip_model_registry.object({
1963
+ id: require_clip_model_registry.string(),
1964
+ name: require_clip_model_registry.string(),
1965
+ settings: require_clip_model_registry.record(require_clip_model_registry.string(), require_clip_model_registry.unknown())
1951
1966
  })).default([])
1952
1967
  });
1953
1968
  /**
@@ -1970,6 +1985,106 @@ function modeDeviceListFromPersisted(raw) {
1970
1985
  return lists;
1971
1986
  }
1972
1987
  /**
1988
+ * Rebuild a trusted {@link NcAlarmSettings} from a persisted profile blob.
1989
+ * Unknown / corrupt fields fall back to system-safe empties via the flat-key
1990
+ * readers the panel already uses for the live settings.
1991
+ */
1992
+ function settingsBlobFromPersisted(raw) {
1993
+ const exit = typeof raw["exitDelaySec"] === "number" ? raw["exitDelaySec"] : DEFAULT_DELAYS.exitDelaySec;
1994
+ const entry = typeof raw["entryDelaySec"] === "number" ? raw["entryDelaySec"] : DEFAULT_DELAYS.entryDelaySec;
1995
+ const triggered = typeof raw["triggeredDurationSec"] === "number" ? raw["triggeredDurationSec"] : DEFAULT_DELAYS.triggeredDurationSec;
1996
+ const announceTargets = Array.isArray(raw["announceTargets"]) ? raw["announceTargets"].filter((t) => typeof t === "string") : [];
1997
+ const lists = (key) => {
1998
+ const v = raw[key];
1999
+ if (!Array.isArray(v)) return [];
2000
+ const rows = [];
2001
+ for (const item of v) {
2002
+ if (typeof item !== "object" || item === null) continue;
2003
+ const rec = item;
2004
+ if (typeof rec["mode"] !== "string" || !Array.isArray(rec["deviceIds"])) continue;
2005
+ rows.push({
2006
+ mode: rec["mode"],
2007
+ deviceIds: rec["deviceIds"].filter((id) => typeof id === "number")
2008
+ });
2009
+ }
2010
+ return modeDeviceListFromPersisted(rows);
2011
+ };
2012
+ const modeDelaysRaw = Array.isArray(raw["modeDelays"]) ? raw["modeDelays"] : [];
2013
+ const modeDelaysLoose = [];
2014
+ for (const item of modeDelaysRaw) {
2015
+ if (typeof item !== "object" || item === null) continue;
2016
+ const rec = item;
2017
+ if (typeof rec["mode"] !== "string") continue;
2018
+ const entry = { mode: rec["mode"] };
2019
+ if (typeof rec["exitDelaySec"] === "number") entry.exitDelaySec = rec["exitDelaySec"];
2020
+ if (typeof rec["entryDelaySec"] === "number") entry.entryDelaySec = rec["entryDelaySec"];
2021
+ if (typeof rec["triggeredDurationSec"] === "number") entry.triggeredDurationSec = rec["triggeredDurationSec"];
2022
+ modeDelaysLoose.push(entry);
2023
+ }
2024
+ return {
2025
+ exitDelaySec: Math.max(0, Math.min(600, Math.trunc(exit))),
2026
+ entryDelaySec: Math.max(0, Math.min(600, Math.trunc(entry))),
2027
+ triggeredDurationSec: Math.max(0, Math.min(3600, Math.trunc(triggered))),
2028
+ announceTargets,
2029
+ nonBlocking: lists("nonBlocking"),
2030
+ blocking: lists("blocking"),
2031
+ secureOnArmOff: lists("secureOnArmOff"),
2032
+ modeDelays: modeDelaysFromPersisted(modeDelaysLoose).entries
2033
+ };
2034
+ }
2035
+ function settingsToPersisted(settings) {
2036
+ return {
2037
+ exitDelaySec: settings.exitDelaySec,
2038
+ entryDelaySec: settings.entryDelaySec,
2039
+ triggeredDurationSec: settings.triggeredDurationSec,
2040
+ announceTargets: [...settings.announceTargets],
2041
+ nonBlocking: settings.nonBlocking.map((l) => ({
2042
+ mode: l.mode,
2043
+ deviceIds: [...l.deviceIds]
2044
+ })),
2045
+ blocking: settings.blocking.map((l) => ({
2046
+ mode: l.mode,
2047
+ deviceIds: [...l.deviceIds]
2048
+ })),
2049
+ secureOnArmOff: settings.secureOnArmOff.map((l) => ({
2050
+ mode: l.mode,
2051
+ deviceIds: [...l.deviceIds]
2052
+ })),
2053
+ modeDelays: settings.modeDelays.map((e) => ({ ...e }))
2054
+ };
2055
+ }
2056
+ function profilesStateFromPersisted(activeId, rows, current) {
2057
+ const profiles = rows.filter((r) => r.id.length > 0 && r.name.trim().length > 0).map((r) => ({
2058
+ id: r.id,
2059
+ name: r.name,
2060
+ settings: settingsBlobFromPersisted(r.settings)
2061
+ }));
2062
+ return require_clip_model_registry.ensureAlarmProfiles(profiles.length > 0 ? {
2063
+ activeId,
2064
+ profiles
2065
+ } : null, current);
2066
+ }
2067
+ function profilesToPersisted(state) {
2068
+ return {
2069
+ activeProfileId: state.activeId,
2070
+ profiles: state.profiles.map((p) => ({
2071
+ id: p.id,
2072
+ name: p.name,
2073
+ settings: settingsToPersisted(p.settings)
2074
+ }))
2075
+ };
2076
+ }
2077
+ function profilesViewOf(state) {
2078
+ return {
2079
+ activeId: state.activeId,
2080
+ items: state.profiles.map((p) => ({
2081
+ id: p.id,
2082
+ name: p.name,
2083
+ isDefault: require_clip_model_registry.isDefaultAlarmProfileId(p.id)
2084
+ }))
2085
+ };
2086
+ }
2087
+ /**
1973
2088
  * The log line for a trigger the state machine ignored, per state. `trigger`
1974
2089
  * ignores four: disarmed, arming (the exit delay), and pending / triggered
1975
2090
  * (already counting down or going off — a second sensor must not restart it).
@@ -2217,7 +2332,134 @@ var NcAlarmPanelDevice = class extends require_clip_model_registry.BaseDevice {
2217
2332
  changed: membershipChanged
2218
2333
  }
2219
2334
  });
2220
- return this.settings();
2335
+ const settled = this.settings();
2336
+ await this.persistProfilesState(require_clip_model_registry.syncActiveAlarmProfileSettings(this.readProfilesState(), settled));
2337
+ return settled;
2338
+ }
2339
+ /** Named profiles + which one is active — for {@link NcAlarmConfig.profiles}. */
2340
+ profilesView() {
2341
+ return profilesViewOf(this.readProfilesState());
2342
+ }
2343
+ /**
2344
+ * Create a named profile (system defaults, or clone). Does not activate.
2345
+ * `newId` is assigned by the caller (usually `randomUUID`).
2346
+ */
2347
+ async createProfile(input) {
2348
+ const result = require_clip_model_registry.createAlarmProfile(this.readProfilesState(), {
2349
+ name: input.name,
2350
+ id: input.newId,
2351
+ source: input.source
2352
+ });
2353
+ if (result.kind === "refused") throw this.profilesRefuseError(result.reason, result.detail);
2354
+ await this.persistProfilesState(result.state);
2355
+ this.ctx.logger.info("alarm profile created", {
2356
+ tags: { deviceId: this.id },
2357
+ meta: {
2358
+ profileId: input.newId,
2359
+ name: input.name.trim(),
2360
+ source: input.source.kind
2361
+ }
2362
+ });
2363
+ return profilesViewOf(result.state);
2364
+ }
2365
+ async renameProfile(id, name) {
2366
+ const result = require_clip_model_registry.renameAlarmProfile(this.readProfilesState(), {
2367
+ id,
2368
+ name
2369
+ });
2370
+ if (result.kind === "refused") throw this.profilesRefuseError(result.reason, result.detail);
2371
+ await this.persistProfilesState(result.state);
2372
+ this.ctx.logger.info("alarm profile renamed", {
2373
+ tags: { deviceId: this.id },
2374
+ meta: {
2375
+ profileId: id,
2376
+ name: name.trim()
2377
+ }
2378
+ });
2379
+ return profilesViewOf(result.state);
2380
+ }
2381
+ /**
2382
+ * Delete a non-default profile. If it was active, Default becomes active and
2383
+ * its settings are applied to the flat keys (arm path).
2384
+ */
2385
+ async deleteProfile(id) {
2386
+ const before = this.readProfilesState();
2387
+ const result = require_clip_model_registry.deleteAlarmProfile(before, id);
2388
+ if (result.kind === "refused") throw this.profilesRefuseError(result.reason, result.detail);
2389
+ const switched = before.activeId === id;
2390
+ await this.persistProfilesState(result.state);
2391
+ if (switched) {
2392
+ const def = result.state.profiles.find((p) => p.id === require_clip_model_registry.NC_ALARM_DEFAULT_PROFILE_ID);
2393
+ if (def !== void 0) await this.replaceFlatSettings(def.settings);
2394
+ }
2395
+ this.ctx.logger.info("alarm profile deleted", {
2396
+ tags: { deviceId: this.id },
2397
+ meta: {
2398
+ profileId: id,
2399
+ activatedDefault: switched
2400
+ }
2401
+ });
2402
+ return profilesViewOf(result.state);
2403
+ }
2404
+ /**
2405
+ * Make a profile active and apply its blob onto the flat settings the arm
2406
+ * path reads.
2407
+ */
2408
+ async activateProfile(id) {
2409
+ const result = require_clip_model_registry.activateAlarmProfile(require_clip_model_registry.syncActiveAlarmProfileSettings(this.readProfilesState(), this.settings()), id);
2410
+ if (result.kind === "refused") throw this.profilesRefuseError(result.reason, result.detail);
2411
+ const next = result.state.profiles.find((p) => p.id === id);
2412
+ if (next === void 0) throw this.profilesRefuseError("not-found", id);
2413
+ await this.persistProfilesState(result.state);
2414
+ await this.replaceFlatSettings(next.settings);
2415
+ this.ctx.logger.info("alarm profile activated", {
2416
+ tags: { deviceId: this.id },
2417
+ meta: {
2418
+ profileId: id,
2419
+ name: next.name
2420
+ }
2421
+ });
2422
+ return profilesViewOf(result.state);
2423
+ }
2424
+ readProfilesState() {
2425
+ return profilesStateFromPersisted(this.config.get("activeProfileId"), this.config.get("profiles"), this.settings());
2426
+ }
2427
+ async persistProfilesState(state) {
2428
+ const persisted = profilesToPersisted(state);
2429
+ await Promise.all([this.config.set("activeProfileId", persisted.activeProfileId), this.config.set("profiles", persisted.profiles)]);
2430
+ }
2431
+ /** Write a full settings blob onto the flat keys the arm path reads. */
2432
+ async replaceFlatSettings(settings) {
2433
+ const blob = require_clip_model_registry.cloneAlarmSettings(settings);
2434
+ await Promise.all([
2435
+ this.config.set("exitDelaySec", blob.exitDelaySec),
2436
+ this.config.set("entryDelaySec", blob.entryDelaySec),
2437
+ this.config.set("triggeredDurationSec", blob.triggeredDurationSec),
2438
+ this.config.set("announceTargets", [...blob.announceTargets]),
2439
+ this.config.set("nonBlocking", blob.nonBlocking.map((l) => ({
2440
+ mode: l.mode,
2441
+ deviceIds: [...l.deviceIds]
2442
+ }))),
2443
+ this.config.set("blocking", blob.blocking.map((l) => ({
2444
+ mode: l.mode,
2445
+ deviceIds: [...l.deviceIds]
2446
+ }))),
2447
+ this.config.set("secureOnArmOff", blob.secureOnArmOff.map((l) => ({
2448
+ mode: l.mode,
2449
+ deviceIds: [...l.deviceIds]
2450
+ }))),
2451
+ this.config.set("modeDelays", blob.modeDelays.map((e) => ({ ...e })))
2452
+ ]);
2453
+ }
2454
+ profilesRefuseError(reason, detail) {
2455
+ switch (reason) {
2456
+ case "name-empty": return /* @__PURE__ */ new Error("alarm profile name must not be empty");
2457
+ case "name-taken": return /* @__PURE__ */ new Error(`an alarm profile named '${detail ?? ""}' already exists`);
2458
+ case "cannot-delete-default": return /* @__PURE__ */ new Error("the Default alarm profile cannot be deleted");
2459
+ case "clone-source-missing": return /* @__PURE__ */ new Error(`alarm profile '${detail ?? ""}' was not found to clone`);
2460
+ case "not-found": return /* @__PURE__ */ new Error(`alarm profile '${detail ?? ""}' was not found`);
2461
+ default: return /* @__PURE__ */ new Error(`alarm profile operation refused (${reason})`);
2462
+ }
2221
2463
  }
2222
2464
  /**
2223
2465
  * Refuse a mode this panel does not offer — logged by reason, then thrown. A membership edit
@@ -2950,6 +3192,11 @@ function ncAlarmPanelPort(panel) {
2950
3192
  deviceId: panel.id,
2951
3193
  settings: () => panel.settings(),
2952
3194
  applySettings: (patch) => panel.applySettings(patch),
3195
+ profilesView: () => panel.profilesView(),
3196
+ createProfile: (input) => panel.createProfile(input),
3197
+ renameProfile: (id, name) => panel.renameProfile(id, name),
3198
+ deleteProfile: (id) => panel.deleteProfile(id),
3199
+ activateProfile: (id) => panel.activateProfile(id),
2953
3200
  availableModes: () => NC_ALARM_MODES,
2954
3201
  noteRuleTrigger: (source) => panel.noteRuleTrigger(source),
2955
3202
  excludedDevices: () => panel.excludedDevices(),
@@ -4821,6 +5068,27 @@ function planDenseRanges(input) {
4821
5068
  }
4822
5069
  //#endregion
4823
5070
  //#region src/notification-center/text-compose.ts
5071
+ /**
5072
+ * The COMPOSITE variables — phrases assembled from catalog fragments before
5073
+ * the one and only render pass.
5074
+ *
5075
+ * A notification body is not a sentence with slots. "person (John) on Front
5076
+ * door in gate" contains three sub-phrases whose SHAPE differs per language
5077
+ * and whose presence is conditional: the label parentheses, the zone clause,
5078
+ * the plural noun. Expressing that with `{{class}}{{label}}{{zones}}` and
5079
+ * three scalars pushes the grammar into the template, where a translator
5080
+ * cannot fix it. So each becomes ONE variable — `{{subject}}`, `{{inZones}}`,
5081
+ * `{{op}}`, `{{detectionSummary}}` — composed here from its own keys and handed
5082
+ * to {@link renderTemplate} as a finished string.
5083
+ *
5084
+ * **Fragments see the SCALARS only.** Never the composites. That is what makes
5085
+ * the depth exactly one and not "one so far": a fragment key edited to say
5086
+ * `{{inZones}}` renders empty rather than starting a second round, and
5087
+ * `renderTemplate` never re-scans what it inserted, so a composite's own value
5088
+ * is printed verbatim whatever it contains.
5089
+ *
5090
+ * Pure: a catalog in, a string out. No clock, no store, no I/O.
5091
+ */
4824
5092
  /** One normalisation, used on both sides of every class lookup — a pipeline
4825
5093
  * that emitted `Person` must find the key written `class.person`. */
4826
5094
  function normaliseClass(className) {
@@ -4829,24 +5097,35 @@ function normaliseClass(className) {
4829
5097
  /**
4830
5098
  * The noun for a class, in the count's plural category.
4831
5099
  *
4832
- * A class no locale names falls through to `class.other`, which prints the RAW
4833
- * name: a custom model's class must appear in the sentence rather than vanish
4834
- * out of it, and a missing translation is a smaller defect than a missing
4835
- * detection.
5100
+ * Lookup order: the NC text catalog (`class.*`, plural-aware) → the shared
5101
+ * {@link eventKindLabel} map (viewer filters / EVENT_TAXONOMY) → `class.other`
5102
+ * with the RAW wire id. A custom model class must still appear rather than
5103
+ * vanish; a missing translation is a smaller defect than a missing detection.
4836
5104
  */
4837
5105
  function ncClassNoun(texts, className, count) {
4838
- const key = `class.${normaliseClass(className)}`;
5106
+ const normalised = normaliseClass(className);
5107
+ const key = `class.${normalised}`;
4839
5108
  if (texts.has(key)) return texts.text({
4840
5109
  key,
4841
5110
  count,
4842
5111
  vars: { name: className }
4843
5112
  });
5113
+ if (require_clip_model_registry.isEventKindLabelKey(normalised)) return require_clip_model_registry.eventKindLabel(normalised, texts.language);
4844
5114
  return texts.text({
4845
5115
  key: "class.other",
4846
5116
  count,
4847
5117
  vars: { name: className }
4848
5118
  });
4849
5119
  }
5120
+ /**
5121
+ * The parenthetical half of a labelled subject: a recognised person or plate
5122
+ * is printed verbatim; a classification (species, COCO sub, …) goes through
5123
+ * the shared event-kind map so "animal (cat)" becomes "animale (Gatto)".
5124
+ */
5125
+ function ncSubjectLabelText(texts, label, labelKind) {
5126
+ if (labelKind === "identity" || labelKind === "plate") return label;
5127
+ return require_clip_model_registry.eventKindLabel(label, texts.language);
5128
+ }
4850
5129
  /** `person (John)` / `persona (John)` — the thing the notification is about. */
4851
5130
  function ncSubjectText(texts, input) {
4852
5131
  const classNoun = ncClassNoun(texts, input.className, 1);
@@ -4858,7 +5137,7 @@ function ncSubjectText(texts, input) {
4858
5137
  key: subjectLabelKey(input.labelKind),
4859
5138
  vars: {
4860
5139
  classNoun,
4861
- label: input.label
5140
+ label: ncSubjectLabelText(texts, input.label, input.labelKind)
4862
5141
  }
4863
5142
  });
4864
5143
  }
@@ -8428,31 +8707,48 @@ function replaceConditionReferences(monitor, condition, reference) {
8428
8707
  /** The cap whose presence in a sequence makes the rule the alarm's trigger. */
8429
8708
  var ALARM_PANEL_CAP$1 = "alarm-panel";
8430
8709
  var ALARM_KINDS = new Set(require_clip_model_registry.NC_ALARM_SYSTEM_EVENT_KINDS);
8431
- function actuatesAlarmPanel(sequences) {
8432
- for (const sequence of sequences) for (const step of sequence.actions) if (step.kind === "cap" && step.cap === ALARM_PANEL_CAP$1) return true;
8710
+ function stepActuatesAlarm(step) {
8711
+ return step.kind === "cap" && step.cap === ALARM_PANEL_CAP$1;
8712
+ }
8713
+ function bindingActuatesAlarm(binding, sequenceActionsOf) {
8714
+ if (binding.kind === "do") return stepActuatesAlarm(binding.action);
8715
+ const steps = sequenceActionsOf?.(binding.sequenceId);
8716
+ if (steps === void 0) return false;
8717
+ return steps.some(stepActuatesAlarm);
8718
+ }
8719
+ function actionsActuateAlarm(rule, sequenceActionsOf) {
8720
+ const actions = rule.actions;
8721
+ if (actions === void 0) return false;
8722
+ const hooks = [
8723
+ actions.onActivation,
8724
+ actions.onDeactivation,
8725
+ actions.onTrigger,
8726
+ actions.onTriggerOff
8727
+ ];
8728
+ for (const bindings of hooks) for (const binding of bindings ?? []) if (bindingActuatesAlarm(binding, sequenceActionsOf)) return true;
8729
+ for (const button of actions.buttons ?? []) {
8730
+ const steps = sequenceActionsOf?.(button.sequenceId);
8731
+ if (steps !== void 0 && steps.some(stepActuatesAlarm)) return true;
8732
+ }
8433
8733
  return false;
8434
8734
  }
8435
8735
  /**
8436
8736
  * May a snooze window silence this rule?
8437
8737
  *
8438
- * `true` = never. Deliberately answered from the rule alone: the gate reads it
8439
- * on the delivery path, where a fallible lookup would be a read that can
8440
- * DESTROY the exemption (D49) — and the failure direction of this one is
8441
- * silencing an alarm.
8738
+ * `true` = never. Deliberately answered from the rule alone (plus an optional
8739
+ * sequence lookup): the gate reads it on the delivery path, where a fallible
8740
+ * lookup would be a read that can DESTROY the exemption (D49) — and the
8741
+ * failure direction of this one is silencing an alarm. A missing sequence
8742
+ * lookup means sequence-ref bindings cannot prove exemption; Do bindings and
8743
+ * system-event kinds still can.
8442
8744
  */
8443
- function isSnoozeExempt(rule) {
8745
+ function isSnoozeExempt(rule, sequenceActionsOf) {
8444
8746
  const kinds = rule.conditions.systemEvent?.kinds ?? [];
8445
8747
  for (const kind of kinds) if (ALARM_KINDS.has(kind)) return true;
8446
- return actuatesAlarmPanel(rule.actions?.onTrigger ?? []);
8748
+ return actionsActuateAlarm(rule, sequenceActionsOf);
8447
8749
  }
8448
8750
  //#endregion
8449
8751
  //#region src/notification-center/action-buttons.ts
8450
- /** Sequence names the rule actually declares. A button may only name one. */
8451
- function declaredSequences(actions) {
8452
- const names = /* @__PURE__ */ new Set();
8453
- for (const sequence of actions.onTrigger ?? []) names.add(sequence.name);
8454
- return names;
8455
- }
8456
8752
  /**
8457
8753
  * Build the notification's buttons.
8458
8754
  *
@@ -8465,18 +8761,18 @@ function buildActionButtons(input) {
8465
8761
  if (actions === void 0) return [];
8466
8762
  const buttons = actions.buttons ?? [];
8467
8763
  if (buttons.length === 0) return [];
8468
- const known = declaredSequences(actions);
8764
+ const known = input.knownSequenceIds;
8469
8765
  const seen = /* @__PURE__ */ new Set();
8470
8766
  const out = [];
8471
8767
  for (const button of buttons) {
8472
- if (!known.has(button.sequence)) continue;
8768
+ if (!known.has(button.sequenceId)) continue;
8473
8769
  if (seen.has(button.id)) continue;
8474
8770
  seen.add(button.id);
8475
8771
  out.push({
8476
8772
  id: button.id,
8477
8773
  label: button.label,
8478
8774
  url: input.mintUrl({
8479
- sequence: button.sequence,
8775
+ sequenceId: button.sequenceId,
8480
8776
  actionId: button.id
8481
8777
  }),
8482
8778
  ...button.icon !== void 0 ? { icon: button.icon } : {},
@@ -8486,18 +8782,17 @@ function buildActionButtons(input) {
8486
8782
  return out;
8487
8783
  }
8488
8784
  /**
8489
- * Which of a rule's buttons name a sequence it does not have.
8785
+ * Which of a rule's buttons name a sequence that is not known.
8490
8786
  *
8491
8787
  * Exported so the caller can LOG the drop. A button silently missing from a
8492
8788
  * notification is the exact shape of failure this repo keeps paying for — the
8493
8789
  * operator authored it, it never appeared, and nothing said why.
8494
8790
  */
8495
- function unresolvableButtons(actions) {
8791
+ function unresolvableButtons(actions, knownSequenceIds) {
8496
8792
  if (actions === void 0) return [];
8497
8793
  const buttons = actions.buttons ?? [];
8498
8794
  if (buttons.length === 0) return [];
8499
- const known = declaredSequences(actions);
8500
- return buttons.filter((b) => !known.has(b.sequence)).map((b) => `${b.id}→${b.sequence}`);
8795
+ return buttons.filter((b) => !knownSequenceIds.has(b.sequenceId)).map((b) => `${b.id}→${b.sequenceId}`);
8501
8796
  }
8502
8797
  /** How many snooze choices one notification may carry. The schema caps the
8503
8798
  * authored list at 4; this is the same bound applied to a stored rule that
@@ -8548,7 +8843,7 @@ function snoozeChoices(options) {
8548
8843
  * attention than the notification did.
8549
8844
  */
8550
8845
  function buildSnoozeButtons(input) {
8551
- if (isSnoozeExempt(input.rule)) return [];
8846
+ if (isSnoozeExempt(input.rule, input.sequenceActionsOf)) return [];
8552
8847
  return snoozeChoices(input.rule.snoozeOptions).map((minutes) => {
8553
8848
  const actionId = snoozeButtonId(minutes);
8554
8849
  return {
@@ -10837,10 +11132,11 @@ function matchesIdentityScope(subject, scope) {
10837
11132
  * Derived, never a stored flag: both rule editors prune condition keys to the
10838
11133
  * catalog on save, so a new key would be dropped by the first edit.
10839
11134
  */
10840
- function ruleBelongsToAlarm(rule) {
11135
+ function ruleBelongsToAlarm(rule, sequenceActionsOf) {
10841
11136
  return require_clip_model_registry.ruleBelongsToAlarm({
10842
11137
  conditions: rule.conditions,
10843
- ...rule.actions?.onTrigger !== void 0 ? { onTrigger: rule.actions.onTrigger } : {}
11138
+ ...rule.actions !== void 0 ? { actions: rule.actions } : {},
11139
+ ...sequenceActionsOf !== void 0 ? { sequenceActionsOf } : {}
10844
11140
  });
10845
11141
  }
10846
11142
  /**
@@ -12728,7 +13024,7 @@ function deriveTankBand(reading) {
12728
13024
  case null: return null;
12729
13025
  }
12730
13026
  }
12731
- function isRecord$7(value) {
13027
+ function isRecord$9(value) {
12732
13028
  return typeof value === "object" && value !== null && !Array.isArray(value);
12733
13029
  }
12734
13030
  function tankStatusOf(value) {
@@ -12744,11 +13040,11 @@ function vacuumTanksOf(data) {
12744
13040
  if (data["capName"] !== "vacuum-control") return null;
12745
13041
  const deviceId = data["deviceId"];
12746
13042
  const slice = data["slice"];
12747
- if (typeof deviceId !== "number" || !isRecord$7(slice)) return null;
13043
+ if (typeof deviceId !== "number" || !isRecord$9(slice)) return null;
12748
13044
  const tanks = [];
12749
13045
  for (const key of NC_TANK_KEYS) {
12750
13046
  const raw = slice[key];
12751
- if (!isRecord$7(raw)) continue;
13047
+ if (!isRecord$9(raw)) continue;
12752
13048
  const level = raw["level"];
12753
13049
  tanks.push({
12754
13050
  key,
@@ -20086,6 +20382,350 @@ var NcDeviceMuteStore = class {
20086
20382
  }
20087
20383
  };
20088
20384
  //#endregion
20385
+ //#region src/notification-center/migrate-legacy-actions.ts
20386
+ function isRecord$8(value) {
20387
+ return typeof value === "object" && value !== null && !Array.isArray(value);
20388
+ }
20389
+ /** A legacy inline sequence row — detected by `name` + `actions` + `enabled`. */
20390
+ function asLegacySequence(value) {
20391
+ const parsed = require_clip_model_registry.NcLegacyRuleActionSequenceSchema.safeParse(value);
20392
+ return parsed.success ? parsed.data : null;
20393
+ }
20394
+ /**
20395
+ * True when `onTrigger` still looks like the pre-shared-sequence array of
20396
+ * named sequences (not an array of `{ kind: 'sequence' | 'do', … }` bindings).
20397
+ */
20398
+ function onTriggerLooksLegacy(onTrigger) {
20399
+ if (!Array.isArray(onTrigger) || onTrigger.length === 0) return false;
20400
+ const first = onTrigger[0];
20401
+ if (!isRecord$8(first)) return false;
20402
+ if (first["kind"] === "sequence" || first["kind"] === "do") return false;
20403
+ return asLegacySequence(first) !== null;
20404
+ }
20405
+ function legacyButtonSequenceName(button) {
20406
+ if (!isRecord$8(button)) return null;
20407
+ const name = button["sequence"];
20408
+ return typeof name === "string" && name.length > 0 ? name : null;
20409
+ }
20410
+ /**
20411
+ * Rewrite a raw `actions` block. Returns the (possibly unchanged) new-shape
20412
+ * actions and whether anything was materialised into the sequence store.
20413
+ */
20414
+ async function migrateLegacyRuleActions(actions, createSequence) {
20415
+ if (actions === void 0 || actions === null) return {
20416
+ actions: void 0,
20417
+ migrated: false
20418
+ };
20419
+ if (!isRecord$8(actions)) return {
20420
+ actions: void 0,
20421
+ migrated: false
20422
+ };
20423
+ if (!onTriggerLooksLegacy(actions["onTrigger"]) && actions["sequence"] === void 0) {
20424
+ const buttons = actions["buttons"];
20425
+ if (!(Array.isArray(buttons) && buttons.some((b) => legacyButtonSequenceName(b) !== null))) {
20426
+ const parsed = require_clip_model_registry.NcRuleActionsSchema.safeParse(actions);
20427
+ if (parsed.success) return {
20428
+ actions: parsed.data,
20429
+ migrated: false
20430
+ };
20431
+ return {
20432
+ actions: void 0,
20433
+ migrated: false
20434
+ };
20435
+ }
20436
+ }
20437
+ const legacySequences = [];
20438
+ const onTriggerRaw = actions["onTrigger"];
20439
+ if (Array.isArray(onTriggerRaw)) for (const entry of onTriggerRaw) {
20440
+ const seq = asLegacySequence(entry);
20441
+ if (seq !== null) legacySequences.push(seq);
20442
+ }
20443
+ const nameToId = /* @__PURE__ */ new Map();
20444
+ for (const sequence of legacySequences) {
20445
+ const key = sequence.name.trim();
20446
+ if (key === "" || nameToId.has(key)) continue;
20447
+ const created = await createSequence({
20448
+ name: sequence.name,
20449
+ ...sequence.minDelaySec !== void 0 ? { minDelaySec: sequence.minDelaySec } : {},
20450
+ actions: [...sequence.actions]
20451
+ });
20452
+ nameToId.set(key, created.id);
20453
+ }
20454
+ const onTriggerBindings = [];
20455
+ for (const sequence of legacySequences) {
20456
+ if (!sequence.enabled) continue;
20457
+ const id = nameToId.get(sequence.name.trim());
20458
+ if (id === void 0) continue;
20459
+ onTriggerBindings.push({
20460
+ kind: "sequence",
20461
+ sequenceId: id
20462
+ });
20463
+ }
20464
+ const buttonsRaw = actions["buttons"];
20465
+ const buttons = [];
20466
+ if (Array.isArray(buttonsRaw)) for (const button of buttonsRaw) {
20467
+ if (!isRecord$8(button)) continue;
20468
+ const id = typeof button["id"] === "string" ? button["id"] : "";
20469
+ const label = typeof button["label"] === "string" ? button["label"] : "";
20470
+ if (id === "" || label === "") continue;
20471
+ const existingId = typeof button["sequenceId"] === "string" && button["sequenceId"].length > 0 ? button["sequenceId"] : void 0;
20472
+ const legacyName = legacyButtonSequenceName(button);
20473
+ const sequenceId = existingId ?? (legacyName !== null ? nameToId.get(legacyName.trim()) : void 0);
20474
+ if (sequenceId === void 0) continue;
20475
+ buttons.push({
20476
+ id,
20477
+ label,
20478
+ sequenceId,
20479
+ ...typeof button["icon"] === "string" ? { icon: button["icon"] } : {},
20480
+ ...typeof button["destructive"] === "boolean" ? { destructive: button["destructive"] } : {}
20481
+ });
20482
+ }
20483
+ const next = {
20484
+ ...Array.isArray(actions["onActivation"]) ? { onActivation: actions["onActivation"] } : {},
20485
+ ...Array.isArray(actions["onDeactivation"]) ? { onDeactivation: actions["onDeactivation"] } : {},
20486
+ ...onTriggerBindings.length > 0 ? { onTrigger: onTriggerBindings } : {},
20487
+ ...Array.isArray(actions["onTriggerOff"]) ? { onTriggerOff: actions["onTriggerOff"] } : {},
20488
+ ...buttons.length > 0 ? { buttons } : {}
20489
+ };
20490
+ const parsed = require_clip_model_registry.NcRuleActionsSchema.safeParse(next);
20491
+ if (!parsed.success) return {
20492
+ actions: void 0,
20493
+ migrated: true
20494
+ };
20495
+ return {
20496
+ 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,
20497
+ migrated: true
20498
+ };
20499
+ }
20500
+ //#endregion
20501
+ //#region src/notification-center/sequence-store.ts
20502
+ /**
20503
+ * Shared action sequences for the notification centre.
20504
+ *
20505
+ * A sequence is a named, ordered run of steps (`wait` | `cap`) with its own
20506
+ * throttle. Rules and notification buttons bind to it by id — never by copying
20507
+ * the steps inline — so two rules that open the same gate share one budget and
20508
+ * one authoring surface.
20509
+ *
20510
+ * Mirrors {@link NcDeviceMuteStore}: declare the collection, mirror into RAM,
20511
+ * write-through so a failed persist never leaves a phantom sequence the next
20512
+ * boot will not know.
20513
+ */
20514
+ /**
20515
+ * @durable class=config owner=notification-center
20516
+ * write="an admin creates/edits/deletes a shared sequence via
20517
+ * `notificationRules.createSequence` / `updateSequence` / `deleteSequence`,
20518
+ * or the one-shot boot migration that materialises legacy inline sequences"
20519
+ * retention="none — a row goes only when an admin deletes it. Bounded by the
20520
+ * sequence set a human is willing to author."
20521
+ */
20522
+ var NC_SEQUENCES_COLLECTION = "notification-center:sequences";
20523
+ var NC_SEQUENCES_COLUMNS = [
20524
+ {
20525
+ name: "id",
20526
+ type: "TEXT",
20527
+ primaryKey: true,
20528
+ notNull: true
20529
+ },
20530
+ {
20531
+ name: "name",
20532
+ type: "TEXT",
20533
+ notNull: true
20534
+ },
20535
+ {
20536
+ name: "minDelaySec",
20537
+ type: "INTEGER"
20538
+ },
20539
+ {
20540
+ name: "actions",
20541
+ type: "JSON",
20542
+ notNull: true
20543
+ },
20544
+ {
20545
+ name: "createdAt",
20546
+ type: "INTEGER",
20547
+ notNull: true
20548
+ },
20549
+ {
20550
+ name: "updatedAt",
20551
+ type: "INTEGER",
20552
+ notNull: true
20553
+ }
20554
+ ];
20555
+ function isRecord$7(value) {
20556
+ return typeof value === "object" && value !== null && !Array.isArray(value);
20557
+ }
20558
+ function parseActions(raw) {
20559
+ if (!Array.isArray(raw) || raw.length === 0) return null;
20560
+ const out = [];
20561
+ for (const entry of raw) {
20562
+ const parsed = require_clip_model_registry.NcRuleActionSchema.safeParse(entry);
20563
+ if (!parsed.success) return null;
20564
+ out.push(parsed.data);
20565
+ }
20566
+ return out;
20567
+ }
20568
+ function rowToSequence(data) {
20569
+ const id = data["id"];
20570
+ const name = data["name"];
20571
+ if (typeof id !== "string" || id.length === 0) return null;
20572
+ if (typeof name !== "string" || name.length === 0) return null;
20573
+ const actions = parseActions(data["actions"]);
20574
+ if (actions === null) return null;
20575
+ const minDelayRaw = data["minDelaySec"];
20576
+ const minDelaySec = typeof minDelayRaw === "number" && Number.isInteger(minDelayRaw) ? minDelayRaw : void 0;
20577
+ const candidate = {
20578
+ id,
20579
+ name,
20580
+ ...minDelaySec !== void 0 ? { minDelaySec } : {},
20581
+ actions: [...actions]
20582
+ };
20583
+ const parsed = require_clip_model_registry.NcSharedSequenceSchema.safeParse(candidate);
20584
+ return parsed.success ? parsed.data : null;
20585
+ }
20586
+ var NcSequenceStore = class {
20587
+ byId = /* @__PURE__ */ new Map();
20588
+ store;
20589
+ logger;
20590
+ now;
20591
+ constructor(deps) {
20592
+ this.store = deps.store;
20593
+ this.logger = deps.logger;
20594
+ this.now = deps.now ?? (() => Date.now());
20595
+ }
20596
+ static async declare(store) {
20597
+ await store.declareCollection.mutate({
20598
+ collection: NC_SEQUENCES_COLLECTION,
20599
+ columns: [...NC_SEQUENCES_COLUMNS]
20600
+ });
20601
+ }
20602
+ /**
20603
+ * (Re)hydrate. A failed load KEEPS what is already in memory — dropping every
20604
+ * sequence on a transient store error would leave every button and binding
20605
+ * pointing at nothing until the next successful load.
20606
+ */
20607
+ async load() {
20608
+ try {
20609
+ const rows = await this.store.query.query({
20610
+ collection: NC_SEQUENCES_COLLECTION,
20611
+ filter: { limit: 1e4 }
20612
+ });
20613
+ const next = /* @__PURE__ */ new Map();
20614
+ let skipped = 0;
20615
+ for (const row of rows) {
20616
+ if (!isRecord$7(row.data)) {
20617
+ skipped += 1;
20618
+ continue;
20619
+ }
20620
+ const sequence = rowToSequence(row.data);
20621
+ if (sequence === null) {
20622
+ skipped += 1;
20623
+ continue;
20624
+ }
20625
+ next.set(sequence.id, sequence);
20626
+ }
20627
+ this.byId.clear();
20628
+ for (const [id, sequence] of next) this.byId.set(id, sequence);
20629
+ if (skipped > 0) this.logger.warn("sequence rows skipped as malformed — bindings to them will miss", { meta: { skipped } });
20630
+ } catch (err) {
20631
+ this.logger.warn("sequence load failed — keeping the sequences already in memory", { meta: {
20632
+ error: String(err),
20633
+ sequences: this.byId.size
20634
+ } });
20635
+ }
20636
+ }
20637
+ list() {
20638
+ return [...this.byId.values()].toSorted((a, b) => a.name.localeCompare(b.name));
20639
+ }
20640
+ get(sequenceId) {
20641
+ return this.byId.get(sequenceId) ?? null;
20642
+ }
20643
+ /** Hot-path existence check for button minting. */
20644
+ has(sequenceId) {
20645
+ return this.byId.has(sequenceId);
20646
+ }
20647
+ knownIds() {
20648
+ return new Set(this.byId.keys());
20649
+ }
20650
+ async create(input) {
20651
+ const now = this.now();
20652
+ const sequence = {
20653
+ id: (0, node_crypto.randomUUID)(),
20654
+ name: input.name,
20655
+ ...input.minDelaySec !== void 0 ? { minDelaySec: input.minDelaySec } : {},
20656
+ actions: [...input.actions]
20657
+ };
20658
+ await this.persist(sequence, now, now);
20659
+ this.byId.set(sequence.id, sequence);
20660
+ return sequence;
20661
+ }
20662
+ /**
20663
+ * Create with a CALLER-chosen id — used by the legacy migration so a rewrite
20664
+ * can name the sequence before rewriting the rule that points at it.
20665
+ */
20666
+ async createWithId(sequence) {
20667
+ const now = this.now();
20668
+ const stored = {
20669
+ id: sequence.id,
20670
+ name: sequence.name,
20671
+ ...sequence.minDelaySec !== void 0 ? { minDelaySec: sequence.minDelaySec } : {},
20672
+ actions: [...sequence.actions]
20673
+ };
20674
+ await this.persist(stored, now, now);
20675
+ this.byId.set(stored.id, stored);
20676
+ return stored;
20677
+ }
20678
+ async update(sequenceId, input) {
20679
+ if (this.byId.get(sequenceId) === void 0) throw new Error(`sequence ${sequenceId} does not exist`);
20680
+ const now = this.now();
20681
+ const sequence = {
20682
+ id: sequenceId,
20683
+ name: input.name,
20684
+ ...input.minDelaySec !== void 0 ? { minDelaySec: input.minDelaySec } : {},
20685
+ actions: [...input.actions]
20686
+ };
20687
+ const createdAt = await this.readCreatedAt(sequenceId, now);
20688
+ await this.persist(sequence, createdAt, now);
20689
+ this.byId.set(sequenceId, sequence);
20690
+ return sequence;
20691
+ }
20692
+ async delete(sequenceId) {
20693
+ await this.store.delete.mutate({
20694
+ collection: NC_SEQUENCES_COLLECTION,
20695
+ key: sequenceId
20696
+ });
20697
+ this.byId.delete(sequenceId);
20698
+ }
20699
+ async readCreatedAt(sequenceId, fallback) {
20700
+ try {
20701
+ const raw = (await this.store.query.query({
20702
+ collection: NC_SEQUENCES_COLLECTION,
20703
+ filter: {
20704
+ where: { id: sequenceId },
20705
+ limit: 1
20706
+ }
20707
+ }))[0]?.data["createdAt"];
20708
+ return typeof raw === "number" && Number.isFinite(raw) ? raw : fallback;
20709
+ } catch {
20710
+ return fallback;
20711
+ }
20712
+ }
20713
+ async persist(sequence, createdAt, updatedAt) {
20714
+ await this.store.set.mutate({
20715
+ collection: NC_SEQUENCES_COLLECTION,
20716
+ key: sequence.id,
20717
+ value: {
20718
+ id: sequence.id,
20719
+ name: sequence.name,
20720
+ ...sequence.minDelaySec !== void 0 ? { minDelaySec: sequence.minDelaySec } : {},
20721
+ actions: sequence.actions,
20722
+ createdAt,
20723
+ updatedAt
20724
+ }
20725
+ });
20726
+ }
20727
+ };
20728
+ //#endregion
20089
20729
  //#region src/notification-center/device-prune.ts
20090
20730
  function judgeList(list, isDead) {
20091
20731
  const dead = list.filter(isDead);
@@ -20199,58 +20839,49 @@ function pruneRuleConditions(conditions, isDead, notes) {
20199
20839
  if (sceneDevice !== void 0 && isDead(sceneDevice)) notes.kept("conditions.scene", [String(sceneDevice)]);
20200
20840
  return changed ? next : null;
20201
20841
  }
20202
- /** A sequence's device steps, dead ones dropped. `null` when none was dead. */
20203
- function pruneSequence(sequence, isDead) {
20204
- const dead = [];
20205
- const kept = sequence.actions.filter((step) => {
20206
- if (step.kind !== "cap" || !isDead(step.deviceId)) return true;
20207
- dead.push(step.deviceId);
20208
- return false;
20209
- });
20210
- if (dead.length === 0) return null;
20211
- const anyDeviceStep = kept.some((step) => step.kind === "cap");
20212
- const [first, ...rest] = kept;
20213
- if (!anyDeviceStep || first === void 0) return {
20214
- next: "removed",
20215
- dead
20216
- };
20217
- return {
20218
- next: {
20219
- ...sequence,
20220
- actions: [first, ...rest]
20221
- },
20222
- dead
20842
+ /** A Do binding whose device is dead is dropped; sequence refs are left alone
20843
+ * (shared sequences are pruned on their own store, not per-rule). */
20844
+ function pruneHookBindings(bindings, field, isDead, notes) {
20845
+ if (bindings === void 0) return {
20846
+ next: void 0,
20847
+ changed: false
20223
20848
  };
20224
- }
20225
- /** The actions after a device prune — `null` when no step moved. */
20226
- function pruneRuleActions(actions, isDead, notes) {
20227
- const sequences = actions.onTrigger;
20228
- if (sequences === void 0) return null;
20849
+ const kept = [];
20229
20850
  let changed = false;
20230
- const removedNames = /* @__PURE__ */ new Set();
20231
- const nextSequences = [];
20232
- for (const sequence of sequences) {
20233
- const pruned = pruneSequence(sequence, isDead);
20234
- if (pruned === null) {
20235
- nextSequences.push(sequence);
20851
+ for (const binding of bindings) {
20852
+ if (binding.kind === "sequence") {
20853
+ kept.push(binding);
20236
20854
  continue;
20237
20855
  }
20238
- changed = true;
20239
- const field = `actions.onTrigger.${sequence.name}`;
20240
- if (pruned.next === "removed") {
20241
- removedNames.add(sequence.name);
20242
- notes.removed(field, deviceSubjects(pruned.dead), "sequence-removed");
20243
- } else {
20244
- nextSequences.push(pruned.next);
20245
- notes.removed(field, deviceSubjects(pruned.dead));
20856
+ if (binding.action.kind === "cap" && isDead(binding.action.deviceId)) {
20857
+ changed = true;
20858
+ notes.removed(field, deviceSubjects([binding.action.deviceId]), "binding-removed");
20859
+ continue;
20246
20860
  }
20861
+ kept.push(binding);
20247
20862
  }
20248
- if (!changed) return null;
20249
- const buttons = actions.buttons;
20863
+ if (!changed) return {
20864
+ next: bindings,
20865
+ changed: false
20866
+ };
20250
20867
  return {
20251
- ...actions,
20252
- onTrigger: nextSequences,
20253
- ...buttons !== void 0 ? { buttons: buttons.filter((button) => !removedNames.has(button.sequence)) } : {}
20868
+ next: kept.length > 0 ? kept : void 0,
20869
+ changed: true
20870
+ };
20871
+ }
20872
+ /** The actions after a device prune — `null` when no binding moved. */
20873
+ function pruneRuleActions(actions, isDead, notes) {
20874
+ const onActivation = pruneHookBindings(actions.onActivation, "actions.onActivation", isDead, notes);
20875
+ const onDeactivation = pruneHookBindings(actions.onDeactivation, "actions.onDeactivation", isDead, notes);
20876
+ const onTrigger = pruneHookBindings(actions.onTrigger, "actions.onTrigger", isDead, notes);
20877
+ const onTriggerOff = pruneHookBindings(actions.onTriggerOff, "actions.onTriggerOff", isDead, notes);
20878
+ if (!onActivation.changed && !onDeactivation.changed && !onTrigger.changed && !onTriggerOff.changed) return null;
20879
+ return {
20880
+ ...onActivation.next !== void 0 ? { onActivation: [...onActivation.next] } : {},
20881
+ ...onDeactivation.next !== void 0 ? { onDeactivation: [...onDeactivation.next] } : {},
20882
+ ...onTrigger.next !== void 0 ? { onTrigger: [...onTrigger.next] } : {},
20883
+ ...onTriggerOff.next !== void 0 ? { onTriggerOff: [...onTriggerOff.next] } : {},
20884
+ ...actions.buttons !== void 0 ? { buttons: actions.buttons } : {}
20254
20885
  };
20255
20886
  }
20256
20887
  function planRuleDevices(rule, isDead, guard) {
@@ -24485,6 +25116,10 @@ function buildTemplateVars(texts, entry, deviceName, zoneLabels, timeZone) {
24485
25116
  return {
24486
25117
  camera: deviceName,
24487
25118
  class: detection?.className ?? (systemEvent !== void 0 ? "" : subject.className),
25119
+ classLabel: (() => {
25120
+ const wire = detection?.className ?? (systemEvent !== void 0 ? "" : subject.className);
25121
+ return wire.length === 0 ? "" : require_clip_model_registry.eventKindLabel(wire, texts.language);
25122
+ })(),
24488
25123
  label: detection?.label ?? subject.label ?? "",
24489
25124
  zones: zoneLabels.join(", "),
24490
25125
  subject: detection?.className !== void 0 ? ncSubjectText(texts, {
@@ -28626,9 +29261,32 @@ function lastRunRow(data) {
28626
29261
  }
28627
29262
  //#endregion
28628
29263
  //#region src/notification-center/rule-actions.ts
29264
+ function wrapDo(action) {
29265
+ const name = action.kind === "wait" ? `wait ${String(action.seconds)}s` : `${action.cap}.${action.method}`;
29266
+ return {
29267
+ id: `do:${name}`,
29268
+ name,
29269
+ enabled: true,
29270
+ actions: [action],
29271
+ throttle: false
29272
+ };
29273
+ }
29274
+ function resolveBinding(binding, lookup) {
29275
+ if (binding.kind === "do") return wrapDo(binding.action);
29276
+ const sequence = lookup(binding.sequenceId);
29277
+ if (sequence === null) return { missing: binding.sequenceId };
29278
+ return {
29279
+ id: sequence.id,
29280
+ name: sequence.name,
29281
+ enabled: true,
29282
+ ...sequence.minDelaySec !== void 0 ? { minDelaySec: sequence.minDelaySec } : {},
29283
+ actions: sequence.actions,
29284
+ throttle: true
29285
+ };
29286
+ }
28629
29287
  var NcRuleActionRunner = class {
28630
29288
  deps;
28631
- /** Last run per `${ruleId}:${sequenceName}` — the throttle's memory. */
29289
+ /** Last run per `sequenceId` — the throttle's memory (shared across rules). */
28632
29290
  lastRunAt = /* @__PURE__ */ new Map();
28633
29291
  now;
28634
29292
  sleep;
@@ -28638,11 +29296,49 @@ var NcRuleActionRunner = class {
28638
29296
  this.sleep = deps.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
28639
29297
  }
28640
29298
  /**
28641
- * Run every enabled sequence, in order.
29299
+ * Resolve bindings via the sequence store and run each, in order.
28642
29300
  *
28643
29301
  * Never throws: an actuation that fails must not cost the NOTIFICATION about
28644
- * the very same event. The operator finds out from the log and from the
28645
- * device not having moved — not from silence on both.
29302
+ * the very same event.
29303
+ */
29304
+ async runBindings(input) {
29305
+ const sequences = [];
29306
+ const missing = [];
29307
+ for (const binding of input.bindings) {
29308
+ const resolved = resolveBinding(binding, input.lookup);
29309
+ if ("missing" in resolved) {
29310
+ this.deps.logger.warn("rule action binding skipped — sequence missing", {
29311
+ tags: { deviceId: input.deviceId },
29312
+ meta: {
29313
+ ruleId: input.ruleId,
29314
+ sequenceId: resolved.missing
29315
+ }
29316
+ });
29317
+ missing.push({
29318
+ id: resolved.missing,
29319
+ name: resolved.missing,
29320
+ ran: false,
29321
+ stepsRun: 0,
29322
+ skippedReason: "missing"
29323
+ });
29324
+ continue;
29325
+ }
29326
+ sequences.push(resolved);
29327
+ }
29328
+ const ran = await this.run({
29329
+ ruleId: input.ruleId,
29330
+ ruleName: input.ruleName,
29331
+ deviceId: input.deviceId,
29332
+ sequences,
29333
+ ...input.origin !== void 0 ? { origin: input.origin } : {}
29334
+ });
29335
+ return [...missing, ...ran];
29336
+ }
29337
+ /**
29338
+ * Run every enabled sequence, in order.
29339
+ *
29340
+ * Prefer {@link runBindings} at call sites that hold bindings. This remains
29341
+ * for button grants (force-enabled shared sequence) and tests.
28646
29342
  */
28647
29343
  async run(input) {
28648
29344
  const out = [];
@@ -28651,38 +29347,41 @@ var NcRuleActionRunner = class {
28651
29347
  }
28652
29348
  async runOne(input, sequence) {
28653
29349
  if (!sequence.enabled) return {
29350
+ id: sequence.id,
28654
29351
  name: sequence.name,
28655
29352
  ran: false,
28656
29353
  stepsRun: 0,
28657
29354
  skippedReason: "disabled"
28658
29355
  };
28659
29356
  if (sequence.actions.length === 0) return {
29357
+ id: sequence.id,
28660
29358
  name: sequence.name,
28661
29359
  ran: false,
28662
29360
  stepsRun: 0,
28663
29361
  skippedReason: "empty"
28664
29362
  };
28665
- const key = `${input.ruleId}:${sequence.name}`;
28666
29363
  const now = this.now();
28667
- const minDelayMs = (sequence.minDelaySec ?? 0) * 1e3;
28668
- const last = this.lastRunAt.get(key);
29364
+ const minDelayMs = sequence.throttle ? (sequence.minDelaySec ?? 0) * 1e3 : 0;
29365
+ const last = this.lastRunAt.get(sequence.id);
28669
29366
  if (minDelayMs > 0 && last !== void 0 && now - last < minDelayMs) {
28670
29367
  this.deps.logger.debug("rule action sequence throttled", {
28671
29368
  tags: { deviceId: input.deviceId },
28672
29369
  meta: {
28673
29370
  ruleId: input.ruleId,
29371
+ sequenceId: sequence.id,
28674
29372
  sequence: sequence.name,
28675
29373
  sinceLastMs: now - last
28676
29374
  }
28677
29375
  });
28678
29376
  return {
29377
+ id: sequence.id,
28679
29378
  name: sequence.name,
28680
29379
  ran: false,
28681
29380
  stepsRun: 0,
28682
29381
  skippedReason: "throttled"
28683
29382
  };
28684
29383
  }
28685
- this.lastRunAt.set(key, now);
29384
+ if (sequence.throttle) this.lastRunAt.set(sequence.id, now);
28686
29385
  let stepsRun = 0;
28687
29386
  for (const [index, action] of sequence.actions.entries()) try {
28688
29387
  await this.step(action, input.origin);
@@ -28693,6 +29392,7 @@ var NcRuleActionRunner = class {
28693
29392
  meta: {
28694
29393
  ruleId: input.ruleId,
28695
29394
  rule: input.ruleName,
29395
+ sequenceId: sequence.id,
28696
29396
  sequence: sequence.name,
28697
29397
  step: index,
28698
29398
  action: action.kind === "cap" ? `${action.cap}.${action.method}` : "wait",
@@ -28700,6 +29400,7 @@ var NcRuleActionRunner = class {
28700
29400
  }
28701
29401
  });
28702
29402
  return {
29403
+ id: sequence.id,
28703
29404
  name: sequence.name,
28704
29405
  ran: true,
28705
29406
  stepsRun,
@@ -28711,11 +29412,13 @@ var NcRuleActionRunner = class {
28711
29412
  meta: {
28712
29413
  ruleId: input.ruleId,
28713
29414
  rule: input.ruleName,
29415
+ sequenceId: sequence.id,
28714
29416
  sequence: sequence.name,
28715
29417
  steps: stepsRun
28716
29418
  }
28717
29419
  });
28718
29420
  return {
29421
+ id: sequence.id,
28719
29422
  name: sequence.name,
28720
29423
  ran: true,
28721
29424
  stepsRun
@@ -28736,6 +29439,17 @@ var NcRuleActionRunner = class {
28736
29439
  });
28737
29440
  }
28738
29441
  };
29442
+ /** Force-enable a shared sequence for a button tap (always runs, still throttled). */
29443
+ function asForcedSequence(sequence) {
29444
+ return {
29445
+ id: sequence.id,
29446
+ name: sequence.name,
29447
+ enabled: true,
29448
+ ...sequence.minDelaySec !== void 0 ? { minDelaySec: sequence.minDelaySec } : {},
29449
+ actions: sequence.actions,
29450
+ throttle: true
29451
+ };
29452
+ }
28739
29453
  //#endregion
28740
29454
  //#region src/notification-center/rule-ai-analyst.ts
28741
29455
  /**
@@ -30582,7 +31296,7 @@ function snoozeVerdict(input, deps) {
30582
31296
  });
30583
31297
  if (active === null) return { kind: "clear" };
30584
31298
  const rule = deps.ruleFor(input.ruleId);
30585
- if (rule !== null && isSnoozeExempt(rule)) return {
31299
+ if (rule !== null && isSnoozeExempt(rule, deps.sequenceActionsOf)) return {
30586
31300
  kind: "exempt",
30587
31301
  snooze: active
30588
31302
  };
@@ -36694,6 +37408,8 @@ var NotificationCenter = class {
36694
37408
  snoozes;
36695
37409
  /** The permanent per-camera notification off switch — see `device-mute-store.ts`. */
36696
37410
  deviceMutes;
37411
+ /** Shared action sequences rules and buttons bind to by id. */
37412
+ sequences;
36697
37413
  /** The durable episode rows behind the sustained-state alerts. */
36698
37414
  alertStore;
36699
37415
  /** Battery band → notification, and the reminder sweep. */
@@ -36932,7 +37648,8 @@ var NotificationCenter = class {
36932
37648
  const drafts = [];
36933
37649
  const sequenceButtons = this.actionRunner === null || stored === null ? [] : buildActionButtons({
36934
37650
  actions: stored.actions,
36935
- mintUrl: ({ sequence, actionId }) => {
37651
+ knownSequenceIds: this.sequences.knownIds(),
37652
+ mintUrl: ({ sequenceId, actionId }) => {
36936
37653
  const id = (0, node_crypto.randomUUID)();
36937
37654
  drafts.push({
36938
37655
  id,
@@ -36942,7 +37659,7 @@ var NotificationCenter = class {
36942
37659
  id,
36943
37660
  ruleId: input.ruleId,
36944
37661
  ruleName: input.ruleName,
36945
- sequence,
37662
+ sequenceId,
36946
37663
  deviceId: input.deviceId,
36947
37664
  actionId,
36948
37665
  targetId: input.targetId,
@@ -36954,6 +37671,7 @@ var NotificationCenter = class {
36954
37671
  });
36955
37672
  const snoozeButtons = input.ownerUserId === void 0 || stored === null ? [] : buildSnoozeButtons({
36956
37673
  rule: stored,
37674
+ sequenceActionsOf: this.sequenceActionsOf,
36957
37675
  label: (minutes) => this.snoozeButtonLabel(minutes),
36958
37676
  mintUrl: ({ actionId, minutes }) => {
36959
37677
  const id = (0, node_crypto.randomUUID)();
@@ -37194,7 +37912,7 @@ var NotificationCenter = class {
37194
37912
  */
37195
37913
  async applyGrantedSnooze(grant) {
37196
37914
  const rule = this.rules.get(grant.ruleId);
37197
- if (rule !== null && isSnoozeExempt(rule)) throw new Error(`"${grant.ruleName}" is an alarm rule and cannot be snoozed`);
37915
+ if (rule !== null && isSnoozeExempt(rule, this.sequenceActionsOf)) throw new Error(`"${grant.ruleName}" is an alarm rule and cannot be snoozed`);
37198
37916
  const snooze = await this.snoozes.create({
37199
37917
  userId: grant.userId,
37200
37918
  scope: "device",
@@ -37493,16 +38211,13 @@ var NotificationCenter = class {
37493
38211
  if (this.actionRunner === null) throw new Error("no actuator is wired on this node");
37494
38212
  const rule = this.rules.get(grant.ruleId);
37495
38213
  if (rule === null) throw new Error(`rule ${grant.ruleId} no longer exists`);
37496
- const sequence = (rule.actions?.onTrigger ?? []).find((s) => s.name === grant.sequence);
37497
- if (sequence === void 0) throw new Error(`sequence "${grant.sequence}" no longer exists on rule ${rule.name}`);
38214
+ const sequence = this.sequences.get(grant.sequenceId);
38215
+ if (sequence === null) throw new Error(`sequence "${grant.sequenceId}" no longer exists`);
37498
38216
  const [outcome] = await this.actionRunner.run({
37499
38217
  ruleId: rule.id,
37500
38218
  ruleName: rule.name,
37501
38219
  deviceId: grant.deviceId,
37502
- sequences: [{
37503
- ...sequence,
37504
- enabled: true
37505
- }]
38220
+ sequences: [asForcedSequence(sequence)]
37506
38221
  });
37507
38222
  if (outcome === void 0) throw new Error("the sequence did not run");
37508
38223
  if (outcome.failedAt !== void 0) throw new Error(`step ${String(outcome.failedAt)} of "${sequence.name}" failed`);
@@ -37621,6 +38336,11 @@ var NotificationCenter = class {
37621
38336
  logger: this.logger.child("device-mute"),
37622
38337
  ...deps.now !== void 0 ? { now: deps.now } : {}
37623
38338
  });
38339
+ this.sequences = new NcSequenceStore({
38340
+ store: deps.store,
38341
+ logger: this.logger.child("sequences"),
38342
+ ...deps.now !== void 0 ? { now: deps.now } : {}
38343
+ });
37624
38344
  this.alertStore = new NcAlertStore({
37625
38345
  store: deps.store,
37626
38346
  logger: this.logger.child("device-alerts")
@@ -37887,6 +38607,7 @@ var NotificationCenter = class {
37887
38607
  await NcStateSummaryService.declare(store);
37888
38608
  await NcSnoozeStore.declare(store);
37889
38609
  await NcDeviceMuteStore.declare(store);
38610
+ await NcSequenceStore.declare(store);
37890
38611
  await TimelapseStore.declare(store);
37891
38612
  await NcSummaryStore.declare(store);
37892
38613
  await NcCalendarEventsStore.declare(store);
@@ -37948,6 +38669,8 @@ var NotificationCenter = class {
37948
38669
  sinceStartMs: now - t0
37949
38670
  } });
37950
38671
  };
38672
+ await timed("sequences", () => this.sequences.load());
38673
+ await timed("migrateLegacyActions", () => this.migrateLegacyRuleActions());
37951
38674
  await Promise.all([
37952
38675
  timed("texts", () => this.reloadTexts()),
37953
38676
  timed("rules", () => this.rules.load()),
@@ -39247,7 +39970,32 @@ var NotificationCenter = class {
39247
39970
  return { success: true };
39248
39971
  },
39249
39972
  setRuleEnabled: async ({ ruleId, enabled }) => {
39250
- await this.rules.setEnabled(ruleId, enabled);
39973
+ const updated = await this.rules.setEnabled(ruleId, enabled);
39974
+ const bindings = enabled ? updated.actions?.onActivation : updated.actions?.onDeactivation;
39975
+ if (bindings !== void 0 && bindings.length > 0 && this.actionRunner !== null) this.actionRunner.runBindings({
39976
+ ruleId: updated.id,
39977
+ ruleName: updated.name,
39978
+ deviceId: this.deviceIdForRuleActions(updated),
39979
+ bindings,
39980
+ lookup: (id) => this.sequences.get(id)
39981
+ });
39982
+ return { success: true };
39983
+ },
39984
+ listSequences: async () => ({ sequences: [...this.sequences.list()] }),
39985
+ getSequence: async ({ sequenceId }) => ({ sequence: this.sequences.get(sequenceId) }),
39986
+ createSequence: async ({ sequence }) => {
39987
+ const created = await this.sequences.create(sequence);
39988
+ this.logger.info("notification sequence created", { meta: { sequenceId: created.id } });
39989
+ return { sequence: created };
39990
+ },
39991
+ updateSequence: async ({ sequenceId, sequence }) => {
39992
+ const updated = await this.sequences.update(sequenceId, sequence);
39993
+ this.logger.info("notification sequence updated", { meta: { sequenceId } });
39994
+ return { sequence: updated };
39995
+ },
39996
+ deleteSequence: async ({ sequenceId }) => {
39997
+ await this.sequences.delete(sequenceId);
39998
+ this.logger.info("notification sequence deleted", { meta: { sequenceId } });
39251
39999
  return { success: true };
39252
40000
  },
39253
40001
  testRule: async ({ rule, lookbackMinutes }) => ({ results: [...await this.dryRun(rule, lookbackMinutes)] }),
@@ -39304,7 +40052,18 @@ var NotificationCenter = class {
39304
40052
  },
39305
40053
  getAlarmConfig: async () => this.alarmConfig(),
39306
40054
  getAlarmSensors: async (input) => this.alarmSensors(input),
39307
- setAlarmConfig: async ({ patch }) => this.setAlarmConfig(patch)
40055
+ setAlarmConfig: async ({ patch }) => this.setAlarmConfig(patch),
40056
+ createAlarmProfile: async ({ name, cloneFromId }) => this.mutateAlarmProfile((panel) => panel.createProfile({
40057
+ name,
40058
+ newId: (0, node_crypto.randomUUID)(),
40059
+ source: cloneFromId === void 0 ? { kind: "defaults" } : {
40060
+ kind: "clone",
40061
+ fromId: cloneFromId
40062
+ }
40063
+ })),
40064
+ renameAlarmProfile: async ({ id, name }) => this.mutateAlarmProfile((panel) => panel.renameProfile(id, name)),
40065
+ deleteAlarmProfile: async ({ id }) => this.mutateAlarmProfile((panel) => panel.deleteProfile(id)),
40066
+ activateAlarmProfile: async ({ id }) => this.mutateAlarmProfile((panel) => panel.activateProfile(id))
39308
40067
  };
39309
40068
  }
39310
40069
  /**
@@ -39597,6 +40356,13 @@ var NotificationCenter = class {
39597
40356
  });
39598
40357
  return this.alarmConfig();
39599
40358
  }
40359
+ /** Profile CRUD — each mutation returns the whole config (settings applied). */
40360
+ async mutateAlarmProfile(run) {
40361
+ const panel = this.alarmPanel;
40362
+ if (panel === null) throw new Error("this node has no alarm panel");
40363
+ await run(panel);
40364
+ return this.alarmConfig();
40365
+ }
39600
40366
  /**
39601
40367
  * The panel as it is NOW — see `alarm-live.ts`.
39602
40368
  *
@@ -39693,6 +40459,7 @@ var NotificationCenter = class {
39693
40459
  return {
39694
40460
  deviceId: panel.deviceId,
39695
40461
  settings: panel.settings(),
40462
+ profiles: panel.profilesView(),
39696
40463
  live: await this.alarmLive(panel),
39697
40464
  coverage: this.coverageFor(panel).map((c) => ({
39698
40465
  ...c,
@@ -40725,7 +41492,7 @@ var NotificationCenter = class {
40725
41492
  } : {}
40726
41493
  }
40727
41494
  });
40728
- const unresolvable = unresolvableButtons(rule.actions);
41495
+ const unresolvable = unresolvableButtons(rule.actions, this.sequences.knownIds());
40729
41496
  if (unresolvable.length > 0) this.logger.warn("rule buttons reference sequences that do not exist — not sent", {
40730
41497
  tags: { deviceId: subject.deviceId },
40731
41498
  meta: {
@@ -40852,20 +41619,85 @@ var NotificationCenter = class {
40852
41619
  return this.groupStore.list(query);
40853
41620
  }
40854
41621
  /**
40855
- * Run this rule's `onTrigger` sequences for this match.
41622
+ * Materialise legacy inline `onTrigger` sequences into the shared store and
41623
+ * rewrite each rule's actions to bindings + `buttons.sequenceId`. Runs on RAW
41624
+ * rows before {@link NcRuleStore.load} so Zod never skips them.
41625
+ */
41626
+ async migrateLegacyRuleActions() {
41627
+ let rows;
41628
+ try {
41629
+ rows = await this.deps.store.query.query({
41630
+ collection: NC_RULES_COLLECTION,
41631
+ filter: { limit: 1e4 }
41632
+ });
41633
+ } catch (err) {
41634
+ this.logger.warn("legacy actions migration skipped — could not read rules", { meta: { error: String(err) } });
41635
+ return;
41636
+ }
41637
+ let migrated = 0;
41638
+ for (const row of rows) {
41639
+ const ruleBlob = row.data["rule"];
41640
+ if (typeof ruleBlob !== "object" || ruleBlob === null || Array.isArray(ruleBlob)) continue;
41641
+ const rule = { ...ruleBlob };
41642
+ const ruleId = typeof rule["id"] === "string" ? rule["id"] : void 0;
41643
+ if (ruleId === void 0) continue;
41644
+ const result = await migrateLegacyRuleActions(rule["actions"], async (input) => {
41645
+ return { id: (await this.sequences.create(input)).id };
41646
+ });
41647
+ if (!result.migrated) continue;
41648
+ if (result.actions !== void 0) rule["actions"] = result.actions;
41649
+ else delete rule["actions"];
41650
+ const { id: _rowId, ...columns } = row.data;
41651
+ await this.deps.store.set.mutate({
41652
+ collection: NC_RULES_COLLECTION,
41653
+ key: ruleId,
41654
+ value: {
41655
+ ...columns,
41656
+ rule
41657
+ }
41658
+ });
41659
+ migrated += 1;
41660
+ }
41661
+ if (migrated > 0) this.logger.info("migrated legacy inline rule actions into shared sequences", { meta: {
41662
+ rules: migrated,
41663
+ sequences: this.sequences.list().length
41664
+ } });
41665
+ }
41666
+ /**
41667
+ * True when this match is a clear / leave edge — the subject for
41668
+ * `onTriggerOff`. Raise / enter / ordinary matches use `onTrigger`.
41669
+ */
41670
+ isTriggerOffSubject(subject) {
41671
+ if (subject.sensorRaised === false) return true;
41672
+ if (subject.occupancy !== void 0 && subject.occupancy.occupied === false) return true;
41673
+ return false;
41674
+ }
41675
+ /** Device id for activation/deactivation log tags — first scoped device, else 0. */
41676
+ deviceIdForRuleActions(rule) {
41677
+ const devices = rule.conditions.devices;
41678
+ if (devices !== void 0 && devices.length > 0) {
41679
+ const first = devices[0];
41680
+ if (first !== void 0) return first;
41681
+ }
41682
+ return 0;
41683
+ }
41684
+ sequenceActionsOf = (sequenceId) => this.sequences.get(sequenceId)?.actions;
41685
+ /**
41686
+ * Run this rule's onTrigger / onTriggerOff bindings for this match.
40856
41687
  *
40857
41688
  * Called BEFORE the cooldown gate — see the call site for why. Everything
40858
41689
  * here is fire-and-forget and nothing may throw into the evaluation.
40859
41690
  */
40860
41691
  runRuleActions(rule, subject, kind, controls) {
40861
- const sequences = rule.actions?.onTrigger;
40862
- if (sequences === void 0 || sequences.length === 0) return;
40863
41692
  if (this.actionRunner === null || controls.runActions === false) return;
40864
- this.actionRunner.run({
41693
+ const bindings = this.isTriggerOffSubject(subject) ? rule.actions?.onTriggerOff : rule.actions?.onTrigger;
41694
+ if (bindings === void 0 || bindings.length === 0) return;
41695
+ this.actionRunner.runBindings({
40865
41696
  ruleId: rule.id,
40866
41697
  ruleName: rule.name,
40867
41698
  deviceId: subject.deviceId,
40868
- sequences,
41699
+ bindings,
41700
+ lookup: (id) => this.sequences.get(id),
40869
41701
  origin: fireRefFor(rule, subject, kind)
40870
41702
  });
40871
41703
  }
@@ -41769,7 +42601,8 @@ var NotificationCenter = class {
41769
42601
  snoozeDecision(input) {
41770
42602
  const verdict = snoozeVerdict(input, {
41771
42603
  activeFor: (query) => this.snoozes.activeFor(query),
41772
- ruleFor: (ruleId) => this.rules.get(ruleId)
42604
+ ruleFor: (ruleId) => this.rules.get(ruleId),
42605
+ sequenceActionsOf: this.sequenceActionsOf
41773
42606
  });
41774
42607
  if (verdict.kind === "exempt") this.logger.info("snooze does not apply — alarm rules are never silenced", {
41775
42608
  tags: { deviceId: input.deviceId },