@camstack/addon-post-analysis 1.2.33 → 1.2.35

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,8 +1,211 @@
1
- import { $ as EventCategory, A as notificationRulesCapability, B as createEvent, C as cosineSimilarity, D as faceGalleryCapability, F as videoclipsCapability, G as array, H as isDeviceScopedCap, I as zoneAnalyticsCapability, J as number, K as boolean, L as errMsg, M as plateGalleryCapability, N as readDeviceStateFrom, P as subKindsOf, Q as unknown, R as BaseAddon, S as buildEventKindDescriptor, T as defineCustomActions, U as nodePin, V as hydrateSchema, W as _enum, X as record, Y as object, Z as string, _ as TimelapseRuleInputSchema, a as MACRO_LABELS, b as alarmPanelCapability, c as NcConditionDescriptorSchema, d as NcRuleSchema, f as NcSnoozeInputSchema, g as OpsLogEntrySchema, h as NcTaxonomySchema, i as EVENT_PAD_MS, j as pipelineAnalyticsCapability, k as kebabToCamel, l as NcRuleInputSchema, m as NcSnoozeSuppressedSchema, n as DEFAULT_EVENT_COLOR, o as NC_CONDITION_CATALOG, p as NcSnoozeSchema, q as literal, r as EVENT_KIND_BY_CAP, s as NC_TAXONOMY, t as BaseDevice, u as NcRulePatchSchema, v as TimelapseRuleSchema, w as customAction, x as audioMetricsCapability, y as addonWidgetsSourceCapability, z as DeviceType } from "../dist-D3Ht9Ae5.mjs";
1
+ import { $ as EventCategory, A as notificationRulesCapability, B as createEvent, C as cosineSimilarity, D as faceGalleryCapability, F as videoclipsCapability, G as array, H as isDeviceScopedCap, I as zoneAnalyticsCapability, J as number, K as boolean, L as errMsg, M as plateGalleryCapability, N as readDeviceStateFrom, P as subKindsOf, Q as unknown, R as BaseAddon, S as buildEventKindDescriptor, T as defineCustomActions, U as nodePin, V as hydrateSchema, W as _enum, X as record, Y as object, Z as string, _ as TimelapseRuleInputSchema, a as MACRO_LABELS, b as alarmPanelCapability, c as NcConditionDescriptorSchema, d as NcRuleSchema, f as NcSnoozeInputSchema, g as OpsLogEntrySchema, h as NcTaxonomySchema, i as EVENT_PAD_MS, j as pipelineAnalyticsCapability, k as kebabToCamel, l as NcRuleInputSchema, m as NcSnoozeSuppressedSchema, n as DEFAULT_EVENT_COLOR, o as NC_CONDITION_CATALOG, p as NcSnoozeSchema, q as literal, r as EVENT_KIND_BY_CAP, s as NC_TAXONOMY, t as BaseDevice, u as NcRulePatchSchema, v as TimelapseRuleSchema, w as customAction, x as audioMetricsCapability, y as addonWidgetsSourceCapability, z as DeviceType } from "../dist-nh2kuzSC.mjs";
2
2
  import { promises } from "node:fs";
3
3
  import path from "node:path";
4
4
  import { createHmac, randomUUID, timingSafeEqual } from "node:crypto";
5
5
  import sharp from "sharp";
6
+ //#region src/notification-center/action-token.ts
7
+ /**
8
+ * The authority behind a notification button.
9
+ *
10
+ * **Read this before changing anything here.** A button's URL travels through
11
+ * third-party infrastructure — ntfy's server, a push relay, whatever forwarded
12
+ * the message — and is therefore exactly as public as the notification is. The
13
+ * operator decided (2026-08-05) that the token ALONE authorises the action:
14
+ * there is no session check, and a tap does not identify who tapped. Whoever
15
+ * holds the link can run that one action. [D47](../../../../docs/decisions/adr-0047.md).
16
+ *
17
+ * Everything in this file exists to bound that blast radius, since the
18
+ * authorisation model does not:
19
+ *
20
+ * - **One token, one action, one notification.** The record carries the rule,
21
+ * the sequence and the device, so a token cannot be re-pointed at anything
22
+ * else. There is no "run arbitrary sequence" shape to abuse.
23
+ * - **Single use, claimed ATOMICALLY.** `claim()` is the only way to read a
24
+ * record and it marks it spent in the same synchronous step, so two taps
25
+ * arriving together cannot both open a gate. This is why the registry is a
26
+ * plain in-process Map and not the settings store: a read-then-write across
27
+ * an async store is a race that a physical actuator would pay for.
28
+ * - **Short-lived, and the expiry is a COMPARISON, not a sweeper.** A reaper
29
+ * that failed would leave tokens live indefinitely — the same reasoning the
30
+ * snooze policy is built on. The sweep here only reclaims memory.
31
+ * - **In-memory is deliberate.** Tokens do not survive a hub restart, so a
32
+ * link can never outlive the process that minted it. The cost is real and
33
+ * accepted: a deploy kills the buttons on notifications already delivered,
34
+ * which with a short TTL were nearly dead anyway.
35
+ */
36
+ /** Sign `(id, exp)` — the exact string the verifier recomputes. Same shape as
37
+ * the artifact plane's, deliberately: one signing convention in this addon. */
38
+ function signActionToken(secret, id, expMs) {
39
+ return createHmac("sha256", secret).update(`act:${id}:${expMs}`).digest("hex");
40
+ }
41
+ /**
42
+ * Verify a callback's `(id, exp, sig)`.
43
+ *
44
+ * Constant-time on the signature so a public route cannot be probed for it byte
45
+ * by byte, and expiry-checked BEFORE the compare so an expired link is refused
46
+ * even when its signature is perfect.
47
+ */
48
+ function verifyActionSignature(input) {
49
+ if (!Number.isFinite(input.exp) || input.exp <= input.nowMs) return false;
50
+ const expected = signActionToken(input.secret, input.id, input.exp);
51
+ const a = Buffer.from(expected, "utf8");
52
+ const b = Buffer.from(input.sig, "utf8");
53
+ if (a.length !== b.length) return false;
54
+ return timingSafeEqual(a, b);
55
+ }
56
+ /** Build the fully-qualified, signed callback URL for one button. */
57
+ function buildActionUrl(input) {
58
+ const sig = signActionToken(input.secret, input.id, input.expMs);
59
+ return `${input.baseUrl.endsWith("/") ? input.baseUrl.slice(0, -1) : input.baseUrl}${input.routePrefix.startsWith("/") ? input.routePrefix : `/${input.routePrefix}`}/${encodeURIComponent(input.id)}?exp=${input.expMs}&sig=${sig}`;
60
+ }
61
+ /**
62
+ * How long a button stays live.
63
+ *
64
+ * Fifteen minutes, not the artifact plane's 24 h. An image link is a read; this
65
+ * one moves something physical, and the window is the only thing limiting who
66
+ * can use a forwarded notification. An operator who wakes hours later opens the
67
+ * app — the button was never the path for that.
68
+ */
69
+ var DEFAULT_ACTION_TTL_MS = 15 * 6e4;
70
+ var NcActionTokenRegistry = class {
71
+ grants = /* @__PURE__ */ new Map();
72
+ now;
73
+ constructor(now) {
74
+ this.now = now ?? (() => Date.now());
75
+ }
76
+ /** Register a freshly minted grant. The caller owns id generation (it needs
77
+ * the id to build the URL before the record exists). */
78
+ put(grant) {
79
+ this.grants.set(grant.id, {
80
+ grant,
81
+ used: false
82
+ });
83
+ }
84
+ /**
85
+ * Take the grant, once.
86
+ *
87
+ * Synchronous and mutating in one step ON PURPOSE — see the header. Anything
88
+ * that awaited between the read and the mark would let two taps through, and
89
+ * the thing on the other end is a lock or a gate.
90
+ */
91
+ claim(id) {
92
+ const found = this.grants.get(id);
93
+ if (found === void 0) return {
94
+ ok: false,
95
+ reason: "unknown"
96
+ };
97
+ if (found.used) return {
98
+ ok: false,
99
+ reason: "already-used"
100
+ };
101
+ if (found.grant.expiresAt <= this.now()) return {
102
+ ok: false,
103
+ reason: "expired"
104
+ };
105
+ found.used = true;
106
+ return {
107
+ ok: true,
108
+ grant: found.grant
109
+ };
110
+ }
111
+ /** Drop records that can no longer be claimed. Memory hygiene only — an
112
+ * expired record is already refused by {@link claim}, so a sweep that never
113
+ * ran would cost bytes, never safety. */
114
+ sweep() {
115
+ const now = this.now();
116
+ let removed = 0;
117
+ for (const [id, entry] of this.grants) if (entry.used || entry.grant.expiresAt <= now) {
118
+ this.grants.delete(id);
119
+ removed += 1;
120
+ }
121
+ return removed;
122
+ }
123
+ /** Live (unclaimed, unexpired) token count — for the log line that tells an
124
+ * operator whether buttons are actually being minted. */
125
+ get liveCount() {
126
+ const now = this.now();
127
+ let n = 0;
128
+ for (const entry of this.grants.values()) if (!entry.used && entry.grant.expiresAt > now) n += 1;
129
+ return n;
130
+ }
131
+ };
132
+ //#endregion
133
+ //#region src/notification-center/action-plane.ts
134
+ /** What the human standing in front of the page is told. */
135
+ var FAILURE_COPY = {
136
+ unknown: "This button is no longer available.",
137
+ expired: "This button has expired. Open CamStack to do it there.",
138
+ "already-used": "Already done — this button had been used."
139
+ };
140
+ function page(title, detail) {
141
+ return `<!doctype html><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>${title}</title><style>body{font:16px/1.5 system-ui,sans-serif;margin:0;min-height:100vh;display:grid;place-items:center;background:#111;color:#eee}main{max-width:24rem;padding:2rem;text-align:center}h1{font-size:1.25rem;margin:0 0 .5rem}p{margin:0;color:#aaa}</style><main><h1>${title}</h1><p>${detail}</p></main>`;
142
+ }
143
+ var NcActionPlane = class {
144
+ deps;
145
+ now;
146
+ constructor(deps) {
147
+ this.deps = deps;
148
+ this.now = deps.now ?? (() => Date.now());
149
+ }
150
+ handler = async (req, res) => {
151
+ const url = new URL(req.url ?? "/", "http://placeholder");
152
+ const id = decodeURIComponent(url.pathname.split("/").filter(Boolean).pop() ?? "");
153
+ if (!verifyActionSignature({
154
+ secret: this.deps.secret,
155
+ id,
156
+ exp: Number(url.searchParams.get("exp")),
157
+ sig: url.searchParams.get("sig") ?? "",
158
+ nowMs: this.now()
159
+ })) {
160
+ this.deps.logger.warn("nc action callback refused — bad or expired signature", { meta: { id } });
161
+ this.send(res, 404, page("Not found", "This link is not valid."));
162
+ return;
163
+ }
164
+ const claim = this.deps.registry.claim(id);
165
+ if (!claim.ok) {
166
+ this.deps.logger.info("nc action callback not claimable", { meta: {
167
+ id,
168
+ reason: claim.reason
169
+ } });
170
+ this.send(res, 200, page("Nothing to do", FAILURE_COPY[claim.reason]));
171
+ return;
172
+ }
173
+ const grant = claim.grant;
174
+ try {
175
+ await this.deps.run(grant);
176
+ this.deps.logger.info("nc action ran from a notification button", {
177
+ tags: { deviceId: grant.deviceId },
178
+ meta: {
179
+ ruleId: grant.ruleId,
180
+ rule: grant.ruleName,
181
+ sequence: grant.sequence,
182
+ action: grant.actionId,
183
+ targetId: grant.targetId ?? null
184
+ }
185
+ });
186
+ this.send(res, 200, page("Done", `${grant.ruleName} — ${grant.sequence}`));
187
+ } catch (err) {
188
+ this.deps.logger.warn("nc action failed after the token was spent", {
189
+ tags: { deviceId: grant.deviceId },
190
+ meta: {
191
+ ruleId: grant.ruleId,
192
+ sequence: grant.sequence,
193
+ action: grant.actionId,
194
+ error: String(err)
195
+ }
196
+ });
197
+ this.send(res, 200, page("It did not work", "The action failed and this button is now spent. Open CamStack."));
198
+ }
199
+ };
200
+ send(res, status, html) {
201
+ res.writeHead(status, {
202
+ "content-type": "text/html; charset=utf-8",
203
+ "cache-control": "no-store"
204
+ });
205
+ res.end(html);
206
+ }
207
+ };
208
+ //#endregion
6
209
  //#region src/notification-center/artifact-url.ts
7
210
  /**
8
211
  * Signed, externally-reachable URLs for notification artifacts.
@@ -341,7 +544,132 @@ function mimeFromExtension(file) {
341
544
  return "application/octet-stream";
342
545
  }
343
546
  //#endregion
547
+ //#region src/notification-center/alarm/alarm-mode-coverage.ts
548
+ /**
549
+ * Whether the panel just BECAME armed, in the sense a person means.
550
+ *
551
+ * Two transitions reach an `armed_*` state and only one of them is an arm:
552
+ *
553
+ * - `arming`/`disarmed` → `armed_away` is somebody arming the alarm. Announce.
554
+ * - `triggered` → `armed_away` is the siren's duration ending and the panel
555
+ * re-arming itself. Announcing it would send "Away armed" in the middle of
556
+ * a break-in, seconds after the alarm notification, saying nothing new.
557
+ *
558
+ * `armed_home` → `armed_away` IS an arm: the operator changed mode, and the
559
+ * set of devices covered just changed with it — which is the whole content of
560
+ * the message.
561
+ *
562
+ * A null `previous` is the FIRST publish and never announces. A hub restarting
563
+ * while armed re-publishes `armed_away`, and nobody armed anything — every
564
+ * deploy would otherwise send one. Handled here rather than by the caller
565
+ * happening to wire the hook late, because "it works because of the order two
566
+ * unrelated things run in" is how this stops working.
567
+ */
568
+ function shouldAnnounceArm(previous, next) {
569
+ if (previous === null) return false;
570
+ if (!next.startsWith("armed_")) return false;
571
+ if (previous === next) return false;
572
+ if (previous === "triggered") return false;
573
+ return true;
574
+ }
575
+ /** The mode inside an `armed_<mode>` state, or null for any other state. */
576
+ function armModeOf(state, modes) {
577
+ for (const mode of modes) if (state === armedStateFor(mode)) return mode;
578
+ return null;
579
+ }
580
+ /** The `deviceState` word a rule must gate on for `mode` to cover it. */
581
+ function armedStateFor(mode) {
582
+ return `armed_${mode}`;
583
+ }
584
+ /**
585
+ * One entry per mode the panel offers, including modes nothing is gated on.
586
+ *
587
+ * Empty modes are KEPT rather than filtered: "Night arms nothing" is the single
588
+ * most useful thing this can tell an operator, and a list that omits it looks
589
+ * identical to a list where night is covered.
590
+ */
591
+ function alarmModeCoverage(rules, panelDeviceId, modes) {
592
+ return modes.map((mode) => coverageFor(rules, panelDeviceId, mode));
593
+ }
594
+ function coverageFor(rules, panelDeviceId, mode) {
595
+ const wanted = armedStateFor(mode);
596
+ const ids = /* @__PURE__ */ new Set();
597
+ let ruleCount = 0;
598
+ let allDevices = false;
599
+ for (const rule of rules) {
600
+ if (!rule.enabled) continue;
601
+ const gate = rule.conditions.deviceState;
602
+ if (gate === void 0 || gate.deviceId !== panelDeviceId) continue;
603
+ if (!gate.states.includes(wanted)) continue;
604
+ ruleCount += 1;
605
+ const scope = rule.conditions.devices;
606
+ if (scope === void 0 || scope.length === 0) {
607
+ allDevices = true;
608
+ continue;
609
+ }
610
+ for (const id of scope) ids.add(id);
611
+ }
612
+ return {
613
+ mode,
614
+ ruleCount,
615
+ allDevices,
616
+ deviceIds: [...ids].toSorted((a, b) => a - b)
617
+ };
618
+ }
619
+ /**
620
+ * "Away armed — 3 cameras: Front door, Garage, Gate."
621
+ *
622
+ * Names, not ids: this is read by a person standing at a door. An id that has
623
+ * no name falls back to `Device <id>` rather than being dropped — a silently
624
+ * shorter list would understate what is armed, which is the one error this
625
+ * message must not make.
626
+ *
627
+ * Returns null when the mode covers NOTHING and no rule is gated on it. A
628
+ * notification saying "Night armed" that is followed by nothing happening all
629
+ * night is worse than no notification: it is a false assurance. The caller
630
+ * still logs the arm.
631
+ */
632
+ function buildArmAnnouncement(coverage, deviceNames) {
633
+ if (coverage.ruleCount === 0) return null;
634
+ const title = `${modeLabel(coverage.mode)} armed`;
635
+ if (coverage.allDevices) return {
636
+ title,
637
+ body: `Every device is armed (${plural$1(coverage.ruleCount, "rule")}).`
638
+ };
639
+ if (coverage.deviceIds.length === 0) return {
640
+ title,
641
+ body: `${plural$1(coverage.ruleCount, "rule")}, no device restriction.`
642
+ };
643
+ const names = coverage.deviceIds.map((id) => deviceNames.get(id) ?? `Device ${id}`);
644
+ return {
645
+ title,
646
+ body: `${plural$1(names.length, "device")} armed: ${names.join(", ")}.`
647
+ };
648
+ }
649
+ function modeLabel(mode) {
650
+ return mode.charAt(0).toUpperCase() + mode.slice(1).replace(/_/g, " ");
651
+ }
652
+ function plural$1(n, one) {
653
+ return n === 1 ? `1 ${one}` : `${n} ${one}s`;
654
+ }
655
+ //#endregion
344
656
  //#region src/notification-center/alarm/alarm-state-machine.ts
657
+ /**
658
+ * Defaults an operator can change per panel. `home` has no exit delay on
659
+ * purpose — nobody is leaving — but the shared value is applied to every mode
660
+ * until per-mode delays are asked for.
661
+ *
662
+ * `triggeredDurationSec: 0` = "sound until somebody disarms it". That is what
663
+ * the panel did before the field existed, so an install that never opens the
664
+ * tab keeps behaving exactly as it did — a default that silently re-armed
665
+ * every existing alarm after N seconds would be a behaviour change nobody
666
+ * asked for.
667
+ */
668
+ var DEFAULT_DELAYS = {
669
+ exitDelaySec: 30,
670
+ entryDelaySec: 20,
671
+ triggeredDurationSec: 0
672
+ };
345
673
  var DISARMED = {
346
674
  target: "disarmed",
347
675
  fired: false,
@@ -358,6 +686,7 @@ function armedState(mode) {
358
686
  * answer is still correct the moment anyone asks.
359
687
  */
360
688
  function stateAt(m, now) {
689
+ if (m.clearAt !== void 0 && now >= m.clearAt) return m.target;
361
690
  if (m.fired) return "triggered";
362
691
  if (m.triggerAt !== void 0) return now >= m.triggerAt ? "triggered" : "pending";
363
692
  if (m.armedAt !== void 0) return now >= m.armedAt ? m.target : "arming";
@@ -411,19 +740,24 @@ function disarm(now) {
411
740
  * intruder walking past two sensors would otherwise postpone the alarm.
412
741
  */
413
742
  function trigger(m, delays, now) {
743
+ const state = stateAt(m, now);
744
+ if (state === "triggered" || state === "pending") return m;
414
745
  if (!isArmed(m, now)) return m;
415
- if (m.fired || m.triggerAt !== void 0) return m;
416
- if (delays.entryDelaySec <= 0) return {
417
- ...m,
418
- fired: true,
419
- changedAt: now
420
- };
746
+ const base = settle(m, now);
747
+ const firesAt = now + Math.max(0, delays.entryDelaySec) * 1e3;
421
748
  return {
422
- ...m,
423
- triggerAt: now + delays.entryDelaySec * 1e3,
749
+ ...base,
750
+ triggerAt: firesAt,
751
+ ...autoClear(delays, firesAt),
752
+ fired: false,
424
753
  changedAt: now
425
754
  };
426
755
  }
756
+ /** `clearAt` iff the operator asked for a bounded siren — see {@link AlarmDelays}. */
757
+ function autoClear(delays, firesAt) {
758
+ if (delays.triggeredDurationSec <= 0) return {};
759
+ return { clearAt: firesAt + delays.triggeredDurationSec * 1e3 };
760
+ }
427
761
  /**
428
762
  * Settle the machine so a stored value never depends on when it is read.
429
763
  *
@@ -432,10 +766,16 @@ function trigger(m, delays, now) {
432
766
  * than only in the reading of it.
433
767
  */
434
768
  function settle(m, now) {
769
+ if (m.clearAt !== void 0 && now >= m.clearAt) return {
770
+ target: m.target,
771
+ fired: false,
772
+ changedAt: m.clearAt
773
+ };
435
774
  if (m.triggerAt !== void 0 && now >= m.triggerAt) return {
436
775
  target: m.target,
437
776
  fired: true,
438
- changedAt: m.triggerAt
777
+ changedAt: m.triggerAt,
778
+ ...m.clearAt !== void 0 ? { clearAt: m.clearAt } : {}
439
779
  };
440
780
  if (m.armedAt !== void 0 && now >= m.armedAt) return {
441
781
  target: m.target,
@@ -464,21 +804,12 @@ function settle(m, now) {
464
804
  * and re-publish when a delay elapses.
465
805
  */
466
806
  /** Every arm mode a camstack-owned panel offers. */
467
- var MODES = [
807
+ var NC_ALARM_MODES = [
468
808
  "home",
469
809
  "away",
470
810
  "night"
471
811
  ];
472
812
  /**
473
- * Defaults an operator can change per panel. `home` has no exit delay on
474
- * purpose — nobody is leaving — but the shared value is applied to every mode
475
- * until per-mode delays are asked for.
476
- */
477
- var DEFAULT_DELAYS = {
478
- exitDelaySec: 30,
479
- entryDelaySec: 20
480
- };
481
- /**
482
813
  * How often the panel re-publishes while a delay is running.
483
814
  *
484
815
  * The state is computed from an INSTANT, so this tick only decides how quickly
@@ -494,30 +825,94 @@ var TICK_MS = 1e3;
494
825
  var ncAlarmPanelSchema = object({
495
826
  exitDelaySec: number().int().min(0).max(600).default(DEFAULT_DELAYS.exitDelaySec),
496
827
  entryDelaySec: number().int().min(0).max(600).default(DEFAULT_DELAYS.entryDelaySec),
828
+ triggeredDurationSec: number().int().min(0).max(3600).default(DEFAULT_DELAYS.triggeredDurationSec),
829
+ /** Send a notification when a mode takes effect. Off until asked for. */
830
+ announceArm: boolean().default(false),
831
+ /** Target ids that announcement goes to — see `NcAlarmSettingsSchema`. */
832
+ announceTargets: array(string()).default([]),
497
833
  machine: record(string(), unknown()).optional()
498
834
  });
835
+ /** `armed_home` and friends — the only targets a machine may settle into. */
836
+ function isArmTarget(value) {
837
+ return value === "disarmed" || typeof value === "string" && value.startsWith("armed_");
838
+ }
839
+ function optionalInstant(value) {
840
+ return typeof value === "number" && Number.isFinite(value) ? value : void 0;
841
+ }
842
+ /**
843
+ * Rebuild the machine from the persisted blob, or null when there is nothing
844
+ * trustworthy in it.
845
+ *
846
+ * A type GUARD, not a cast: the blob has survived a schema change once already
847
+ * (`clearAt` did not exist), and the failure mode of a cast here is an alarm
848
+ * that reports a state it cannot reach. Anything unrecognised falls back to
849
+ * DISARMED, which is the state an operator will notice.
850
+ *
851
+ * Exported for the test that proves a pre-`clearAt` blob still restores.
852
+ */
853
+ function machineFromPersisted(raw) {
854
+ if (raw === null || typeof raw !== "object") return null;
855
+ const rec = { ...raw };
856
+ if (!isArmTarget(rec["target"]) || typeof rec["changedAt"] !== "number") return null;
857
+ const armedAt = optionalInstant(rec["armedAt"]);
858
+ const triggerAt = optionalInstant(rec["triggerAt"]);
859
+ const clearAt = optionalInstant(rec["clearAt"]);
860
+ return {
861
+ target: rec["target"],
862
+ ...armedAt !== void 0 ? { armedAt } : {},
863
+ ...triggerAt !== void 0 ? { triggerAt } : {},
864
+ ...clearAt !== void 0 ? { clearAt } : {},
865
+ fired: rec["fired"] === true,
866
+ changedAt: rec["changedAt"]
867
+ };
868
+ }
499
869
  var NcAlarmPanelDevice = class extends BaseDevice {
500
870
  features = [];
501
871
  machine = DISARMED;
502
872
  delays = DEFAULT_DELAYS;
503
873
  ticker = null;
874
+ onArmed = null;
875
+ /**
876
+ * The state the last publish reported. Null until the first one, so the
877
+ * BOOT publish never announces: a hub restarting while armed would otherwise
878
+ * announce "Away armed" on every deploy.
879
+ */
880
+ lastPublished = null;
504
881
  constructor(ctx) {
505
882
  super(ctx, ncAlarmPanelSchema, { type: ctx.deviceMeta.type });
506
- this.delays = {
507
- exitDelaySec: this.config.get("exitDelaySec"),
508
- entryDelaySec: this.config.get("entryDelaySec")
509
- };
883
+ this.delays = this.readDelays();
510
884
  this.restore();
511
885
  this.registerAlarmCap();
512
886
  this.publish();
513
887
  }
514
- /** Fire a trigger at the panel. Called by the notification center when a rule
515
- * that arms the alarm matches. Ignored unless the panel is armed. */
516
- onRuleTriggered(now = Date.now()) {
517
- const next = trigger(this.machine, this.delays, now);
518
- if (next === this.machine) return;
519
- this.machine = next;
520
- this.publish(now);
888
+ /** The operator-visible configuration, as the cap serves it. */
889
+ settings() {
890
+ return {
891
+ ...this.readDelays(),
892
+ announceArm: this.config.get("announceArm"),
893
+ announceTargets: [...this.config.get("announceTargets")]
894
+ };
895
+ }
896
+ /**
897
+ * Apply an editor's patch and return what the panel settled on.
898
+ *
899
+ * The new delays take effect on the NEXT transition, never retroactively: a
900
+ * machine already counting down keeps the instants it was given. Re-deriving
901
+ * `armedAt` from a delay the operator changed mid-exit would move the arm
902
+ * instant under somebody who is walking out of the door.
903
+ */
904
+ async applySettings(patch) {
905
+ if (patch.exitDelaySec !== void 0) await this.config.set("exitDelaySec", patch.exitDelaySec);
906
+ if (patch.entryDelaySec !== void 0) await this.config.set("entryDelaySec", patch.entryDelaySec);
907
+ if (patch.triggeredDurationSec !== void 0) await this.config.set("triggeredDurationSec", patch.triggeredDurationSec);
908
+ if (patch.announceArm !== void 0) await this.config.set("announceArm", patch.announceArm);
909
+ if (patch.announceTargets !== void 0) await this.config.set("announceTargets", [...patch.announceTargets]);
910
+ this.delays = this.readDelays();
911
+ return this.settings();
912
+ }
913
+ /** Wire the arm announcement. See {@link NcAlarmArmedHook}. */
914
+ setArmedHook(hook) {
915
+ this.onArmed = hook;
521
916
  }
522
917
  /** Current lifecycle word — what the `deviceState` gate compares against. */
523
918
  currentState(now = Date.now()) {
@@ -540,19 +935,24 @@ var NcAlarmPanelDevice = class extends BaseDevice {
540
935
  this.publish();
541
936
  },
542
937
  trigger: async () => {
543
- this.machine = {
544
- ...this.machine,
545
- fired: true,
546
- changedAt: Date.now()
547
- };
548
- this.publish();
938
+ const now = Date.now();
939
+ const next = trigger(this.machine, this.delays, now);
940
+ if (next === this.machine) {
941
+ this.ctx.logger.info("alarm trigger ignored — the panel is not armed", {
942
+ tags: { deviceId: this.id },
943
+ meta: { state: stateAt(this.machine, now) }
944
+ });
945
+ return;
946
+ }
947
+ this.machine = next;
948
+ this.publish(now);
549
949
  }
550
950
  });
551
951
  }
552
952
  status(now = Date.now()) {
553
953
  return {
554
954
  state: stateAt(this.machine, now),
555
- availableModes: [...MODES],
955
+ availableModes: [...NC_ALARM_MODES],
556
956
  requiresCode: false,
557
957
  lastChangedAt: this.machine.changedAt
558
958
  };
@@ -566,11 +966,48 @@ var NcAlarmPanelDevice = class extends BaseDevice {
566
966
  */
567
967
  publish(now = Date.now()) {
568
968
  this.machine = settle(this.machine, now);
969
+ const state = stateAt(this.machine, now);
569
970
  this.runtimeState.setCapState("alarm-panel", this.status(now));
570
971
  this.persist();
571
- if (this.machine.armedAt !== void 0 || this.machine.triggerAt !== void 0) this.startTicking();
972
+ this.announceIfArmed(state);
973
+ if (this.machine.armedAt !== void 0 || this.machine.triggerAt !== void 0 || this.machine.clearAt !== void 0) this.startTicking();
572
974
  else this.stopTicking();
573
975
  }
976
+ /**
977
+ * Fire the arm hook on the transition INTO an armed mode.
978
+ *
979
+ * Never throws into the publish path: an announcement that could take the
980
+ * panel's own state update with it would make a notification failure look
981
+ * like an alarm failure.
982
+ */
983
+ announceIfArmed(state) {
984
+ const previous = this.lastPublished;
985
+ this.lastPublished = state;
986
+ const hook = this.onArmed;
987
+ if (hook === null) return;
988
+ if (!shouldAnnounceArm(previous, state)) return;
989
+ const mode = armModeOf(state, NC_ALARM_MODES);
990
+ if (mode === null) return;
991
+ try {
992
+ hook(mode);
993
+ } catch (err) {
994
+ this.ctx.logger.warn("alarm arm announcement failed", {
995
+ tags: { deviceId: this.id },
996
+ meta: {
997
+ mode,
998
+ error: String(err)
999
+ }
1000
+ });
1001
+ }
1002
+ }
1003
+ /** The three durations, read from the persisted config. */
1004
+ readDelays() {
1005
+ return {
1006
+ exitDelaySec: this.config.get("exitDelaySec"),
1007
+ entryDelaySec: this.config.get("entryDelaySec"),
1008
+ triggeredDurationSec: this.config.get("triggeredDurationSec")
1009
+ };
1010
+ }
574
1011
  startTicking() {
575
1012
  if (this.ticker !== null) return;
576
1013
  this.ticker = setInterval(() => this.publish(), TICK_MS);
@@ -587,17 +1024,9 @@ var NcAlarmPanelDevice = class extends BaseDevice {
587
1024
  this.config.set("machine", { ...this.machine });
588
1025
  }
589
1026
  restore() {
590
- const raw = this.config.get("machine");
591
- if (raw === null || typeof raw !== "object") return;
592
- const m = raw;
593
- if (typeof m.target !== "string" || typeof m.changedAt !== "number") return;
594
- this.machine = {
595
- target: m.target,
596
- ...typeof m.armedAt === "number" ? { armedAt: m.armedAt } : {},
597
- ...typeof m.triggerAt === "number" ? { triggerAt: m.triggerAt } : {},
598
- fired: m.fired === true,
599
- changedAt: m.changedAt
600
- };
1027
+ const restored = machineFromPersisted(this.config.get("machine"));
1028
+ if (restored === null) return;
1029
+ this.machine = restored;
601
1030
  }
602
1031
  };
603
1032
  //#endregion
@@ -4865,14 +5294,21 @@ function readSensorEventType(value) {
4865
5294
  const eventType = last["eventType"];
4866
5295
  return typeof eventType === "string" && eventType.length > 0 ? eventType : void 0;
4867
5296
  }
4868
- /** Build the subject for a `device-event` evaluation (a persisted SensorEvent —
4869
- * one row per linked camera; `deviceId` is the CAMERA). */
5297
+ /**
5298
+ * Build the subject for a `device-event` evaluation (a persisted SensorEvent —
5299
+ * one row per linked camera; `deviceId` is the CAMERA).
5300
+ *
5301
+ * BOTH ids ride along. The camera is what the notification shows; the sensor is
5302
+ * what the operator named in the rule. Carrying only the camera is what made a
5303
+ * sensor-scoped rule unmatched — see {@link NcRuleSubject.sourceDeviceId}.
5304
+ */
4870
5305
  function subjectFromSensorEvent(ev) {
4871
5306
  const eventType = readSensorEventType(ev.value);
4872
5307
  return {
4873
5308
  kind: "device-event",
4874
5309
  recordId: ev.id,
4875
5310
  deviceId: ev.deviceId,
5311
+ ...ev.sourceDeviceId !== ev.deviceId ? { sourceDeviceId: ev.sourceDeviceId } : {},
4876
5312
  timestamp: ev.timestamp,
4877
5313
  classNames: [],
4878
5314
  zones: [],
@@ -5104,7 +5540,7 @@ function evaluateRule(rule, subject, deviceState) {
5104
5540
  if (current === void 0) return fail("deviceState");
5105
5541
  if (!toLowerSet(c.deviceState.states).has(current.trim().toLowerCase())) return fail("deviceState");
5106
5542
  }
5107
- if (c.devices !== void 0 && c.devices.length > 0 && !c.devices.includes(subject.deviceId)) return fail("devices");
5543
+ if (c.devices !== void 0 && c.devices.length > 0 && !matchesDeviceScope(c.devices, subject)) return fail("devices");
5108
5544
  if (c.source !== void 0 && c.source !== "any") {
5109
5545
  if ((subject.source ?? "pipeline") !== c.source) return fail("source");
5110
5546
  }
@@ -5291,6 +5727,28 @@ function keysPerClass(rule, subject) {
5291
5727
  return rule.throttle.granularity === "per-class";
5292
5728
  }
5293
5729
  /** Stable cooldown key per the rule's throttle scope + class granularity. */
5730
+ /**
5731
+ * Does the rule's device scope cover this subject?
5732
+ *
5733
+ * EITHER id matches, and both are needed for the same rule to be authorable
5734
+ * the two ways an operator thinks about it:
5735
+ *
5736
+ * - "when anything happens on the front-door CAMERA" → the camera id, which
5737
+ * is what a sensor row is attributed to;
5738
+ * - "when the front-door CONTACT opens" → the sensor id, which is the device
5739
+ * the operator actually cares about and the one they pick in the Devices
5740
+ * tab. Before this, that rule matched nothing, ever, silently.
5741
+ *
5742
+ * Consequence worth knowing: a sensor linked to N cameras persists N rows, so
5743
+ * a SENSOR-scoped rule is evaluated N times for one door opening. The rule's
5744
+ * own throttle is what collapses that — `scope: 'rule'` gives one
5745
+ * notification, `scope: 'device'` gives one per camera, which is the right
5746
+ * choice when each carries its own picture.
5747
+ */
5748
+ function matchesDeviceScope(scope, subject) {
5749
+ if (scope.includes(subject.deviceId)) return true;
5750
+ return subject.sourceDeviceId !== void 0 && scope.includes(subject.sourceDeviceId);
5751
+ }
5294
5752
  function cooldownKey(rule, subject) {
5295
5753
  const first = subject.classNames[0];
5296
5754
  const classKey = keysPerClass(rule, subject) && first !== void 0 ? `:c:${first}` : "";
@@ -5467,7 +5925,7 @@ var NcDispatcher = class {
5467
5925
  return { ok: true };
5468
5926
  }
5469
5927
  }
5470
- const notification = await this.buildNotification(entry);
5928
+ const notification = await this.buildNotification(entry, target);
5471
5929
  try {
5472
5930
  const result = await this.deps.send({
5473
5931
  addonId: target.addonId,
@@ -5565,6 +6023,82 @@ var NcDispatcher = class {
5565
6023
  return false;
5566
6024
  }
5567
6025
  }
6026
+ /**
6027
+ * Send a Notification-Center-composed message to ONE target.
6028
+ *
6029
+ * Never throws and never retries — see {@link NcAnnouncementSendInput}. Every
6030
+ * way of not delivering produces a line: an announcement that silently did
6031
+ * not arrive is indistinguishable from the alarm not having armed, which is
6032
+ * the exact confusion this feature exists to remove.
6033
+ */
6034
+ async deliverAnnouncement(input) {
6035
+ const tags = input.deviceId !== void 0 ? { deviceId: input.deviceId } : void 0;
6036
+ let target;
6037
+ try {
6038
+ target = await this.resolveTarget(input.targetId);
6039
+ } catch (err) {
6040
+ this.deps.logger.warn("announcement: target catalog unreachable", {
6041
+ ...tags !== void 0 ? { tags } : {},
6042
+ meta: {
6043
+ reason: input.reason,
6044
+ targetId: input.targetId,
6045
+ error: String(err)
6046
+ }
6047
+ });
6048
+ return false;
6049
+ }
6050
+ if (target === null || !target.enabled) {
6051
+ this.deps.logger.warn("announcement: target gone or disabled", {
6052
+ ...tags !== void 0 ? { tags } : {},
6053
+ meta: {
6054
+ reason: input.reason,
6055
+ targetId: input.targetId
6056
+ }
6057
+ });
6058
+ return false;
6059
+ }
6060
+ try {
6061
+ const result = await this.deps.send({
6062
+ addonId: target.addonId,
6063
+ targetId: target.id,
6064
+ notification: {
6065
+ title: input.title,
6066
+ body: input.body,
6067
+ priority: 2,
6068
+ ...input.deviceId !== void 0 ? { deviceId: input.deviceId } : {}
6069
+ }
6070
+ });
6071
+ if (!result.success) {
6072
+ this.deps.logger.warn("announcement send failed", {
6073
+ ...tags !== void 0 ? { tags } : {},
6074
+ meta: {
6075
+ reason: input.reason,
6076
+ targetId: target.id,
6077
+ error: result.error
6078
+ }
6079
+ });
6080
+ return false;
6081
+ }
6082
+ this.deps.logger.info("announcement delivered", {
6083
+ ...tags !== void 0 ? { tags } : {},
6084
+ meta: {
6085
+ reason: input.reason,
6086
+ target: target.name
6087
+ }
6088
+ });
6089
+ return true;
6090
+ } catch (err) {
6091
+ this.deps.logger.warn("announcement send threw", {
6092
+ ...tags !== void 0 ? { tags } : {},
6093
+ meta: {
6094
+ reason: input.reason,
6095
+ targetId: target.id,
6096
+ error: String(err)
6097
+ }
6098
+ });
6099
+ return false;
6100
+ }
6101
+ }
5568
6102
  /** Device display names for a set of ids, for the digest's lines. */
5569
6103
  async resolveDeviceNames(deviceIds) {
5570
6104
  const out = /* @__PURE__ */ new Map();
@@ -5597,7 +6131,37 @@ var NcDispatcher = class {
5597
6131
  if (this.now() - this.targetCacheAt > this.targetCacheTtlMs) return null;
5598
6132
  return this.targetCache.get(targetId) ?? null;
5599
6133
  }
5600
- async buildNotification(entry) {
6134
+ /**
6135
+ * This delivery's buttons, or none.
6136
+ *
6137
+ * Never throws. A notification that arrives without its buttons is a
6138
+ * degraded notification; one that does not arrive because the token registry
6139
+ * hiccupped is a missed event. The failure is logged rather than swallowed —
6140
+ * a button the operator authored and never saw is exactly the silence this
6141
+ * repo keeps paying for.
6142
+ */
6143
+ async mintActions(entry, target) {
6144
+ if (this.deps.buildActions === void 0) return [];
6145
+ try {
6146
+ return await this.deps.buildActions({
6147
+ ruleId: entry.ruleId,
6148
+ ruleName: entry.payload.ruleName,
6149
+ deviceId: entry.deviceId,
6150
+ targetId: target.id
6151
+ });
6152
+ } catch (err) {
6153
+ this.deps.logger.warn("notification buttons could not be minted — sending without them", {
6154
+ tags: { deviceId: entry.deviceId },
6155
+ meta: {
6156
+ ruleId: entry.ruleId,
6157
+ targetId: target.id,
6158
+ error: String(err)
6159
+ }
6160
+ });
6161
+ return [];
6162
+ }
6163
+ }
6164
+ async buildNotification(entry, target) {
5601
6165
  const subject = entry.payload.subject;
5602
6166
  const deviceName = await this.deps.getDeviceName(subject.deviceId).catch(() => null) ?? `camera ${subject.deviceId}`;
5603
6167
  const zoneLabels = await resolveZoneLabels(this.deps.getZoneNames, subject.deviceId, subject.zones);
@@ -5606,6 +6170,7 @@ var NcDispatcher = class {
5606
6170
  const body = renderTemplate(entry.payload.template?.body, vars) ?? defaultBody(entry, deviceName, zoneLabels);
5607
6171
  const attachments = await this.withArtifactUrls(await this.resolveAttachments(entry));
5608
6172
  const params = pickParams(entry.payload.params);
6173
+ const actions = await this.mintActions(entry, target);
5609
6174
  return {
5610
6175
  body,
5611
6176
  title,
@@ -5614,7 +6179,8 @@ var NcDispatcher = class {
5614
6179
  tag: entry.ruleId,
5615
6180
  deviceId: subject.deviceId,
5616
6181
  ...subject.eventId !== void 0 ? { eventId: subject.eventId } : {},
5617
- ...attachments.length > 0 ? { attachments } : {}
6182
+ ...attachments.length > 0 ? { attachments } : {},
6183
+ ...actions.length > 0 ? { actions } : {}
5618
6184
  };
5619
6185
  }
5620
6186
  /**
@@ -6732,6 +7298,60 @@ function rowToEntry$1(id, data) {
6732
7298
  };
6733
7299
  }
6734
7300
  //#endregion
7301
+ //#region src/notification-center/action-buttons.ts
7302
+ /** Sequence names the rule actually declares. A button may only name one. */
7303
+ function declaredSequences(actions) {
7304
+ const names = /* @__PURE__ */ new Set();
7305
+ for (const sequence of actions.onTrigger ?? []) names.add(sequence.name);
7306
+ return names;
7307
+ }
7308
+ /**
7309
+ * Build the notification's buttons.
7310
+ *
7311
+ * Returns an empty array rather than `undefined` — the caller decides whether
7312
+ * to set the field, and an empty array is the honest answer to "which buttons
7313
+ * survived", which `undefined` would conflate with "the rule declared none".
7314
+ */
7315
+ function buildActionButtons(input) {
7316
+ const actions = input.actions;
7317
+ if (actions === void 0) return [];
7318
+ const buttons = actions.buttons ?? [];
7319
+ if (buttons.length === 0) return [];
7320
+ const known = declaredSequences(actions);
7321
+ const seen = /* @__PURE__ */ new Set();
7322
+ const out = [];
7323
+ for (const button of buttons) {
7324
+ if (!known.has(button.sequence)) continue;
7325
+ if (seen.has(button.id)) continue;
7326
+ seen.add(button.id);
7327
+ out.push({
7328
+ id: button.id,
7329
+ label: button.label,
7330
+ url: input.mintUrl({
7331
+ sequence: button.sequence,
7332
+ actionId: button.id
7333
+ }),
7334
+ ...button.icon !== void 0 ? { icon: button.icon } : {},
7335
+ ...button.destructive !== void 0 ? { destructive: button.destructive } : {}
7336
+ });
7337
+ }
7338
+ return out;
7339
+ }
7340
+ /**
7341
+ * Which of a rule's buttons name a sequence it does not have.
7342
+ *
7343
+ * Exported so the caller can LOG the drop. A button silently missing from a
7344
+ * notification is the exact shape of failure this repo keeps paying for — the
7345
+ * operator authored it, it never appeared, and nothing said why.
7346
+ */
7347
+ function unresolvableButtons(actions) {
7348
+ if (actions === void 0) return [];
7349
+ const buttons = actions.buttons ?? [];
7350
+ if (buttons.length === 0) return [];
7351
+ const known = declaredSequences(actions);
7352
+ return buttons.filter((b) => !known.has(b.sequence)).map((b) => `${b.id}→${b.sequence}`);
7353
+ }
7354
+ //#endregion
6735
7355
  //#region src/notification-center/rule-actions.ts
6736
7356
  var NcRuleActionRunner = class {
6737
7357
  deps;
@@ -7810,6 +8430,44 @@ var TimelapseStore = class {
7810
8430
  //#endregion
7811
8431
  //#region src/notification-center/index.ts
7812
8432
  /**
8433
+ * NotificationCenter — the P1 core of the Notification Center (spec
8434
+ * `2026-07-22-notification-center-requirements.md`, decisions D-1/D-2/D-3).
8435
+ *
8436
+ * A deps-injected, extractable module (TrackCloser pattern) hosted by
8437
+ * pipeline-analytics. It couples rule evaluation to the DURABLE persist
8438
+ * moments (D-2), never to the telemetry bus:
8439
+ *
8440
+ * object-event insert ─▶ onObjectEventPersisted ─▶ immediate rules
8441
+ * TrackCloser.closeExpired ─▶ onTrackClosed ─▶ track-end rules
8442
+ * │ match + throttle
8443
+ * ▼
8444
+ * NcOutbox (durable rows, unique key rule:track:target)
8445
+ * │ drain loop (backoff / dead-letter)
8446
+ * ▼
8447
+ * NcDispatcher ─▶ ctx.api notification-output.send (RPC, hub-routed)
8448
+ *
8449
+ * Crash gap: on start (evaluation mode) the module scans object events
8450
+ * newer than the persisted watermark and re-evaluates them — idempotent
8451
+ * through the outbox dedup key (D8: reconcile, never trust an event).
8452
+ *
8453
+ * Multi-node: rules live in the centralized settings-store; only the
8454
+ * designated post-processing node runs evaluation + the drain loop, so a
8455
+ * CRUD served elsewhere becomes effective within one rule-reload tick.
8456
+ */
8457
+ /**
8458
+ * What `getAlarmConfig` answers on a node with no panel.
8459
+ *
8460
+ * The DEFAULTS, not zeroes: paired with `deviceId: null` this tells a client
8461
+ * "there is no panel here" while still describing the shape it would have.
8462
+ * Zeroes would render as "no entry delay", which is a claim about an alarm
8463
+ * that does not exist.
8464
+ */
8465
+ var NC_ALARM_SETTINGS_FALLBACK = {
8466
+ ...DEFAULT_DELAYS,
8467
+ announceArm: false,
8468
+ announceTargets: []
8469
+ };
8470
+ /**
7813
8471
  * How often the "matched NO rule" report may fire per device. Long enough that
7814
8472
  * a busy camera prints one line rather than one per event, short enough that a
7815
8473
  * rule which has stopped matching is visible within minutes rather than by
@@ -7929,9 +8587,14 @@ var NotificationCenter = class NotificationCenter {
7929
8587
  deviceStates;
7930
8588
  /** Runs a matched rule's action sequences. Null when no actuator is wired. */
7931
8589
  actionRunner;
8590
+ /** Grants behind the tap-through buttons. In-process and single-use — read
8591
+ * `action-token.ts` before assuming anything about what a tap proves. */
8592
+ actionTokens = new NcActionTokenRegistry();
7932
8593
  /** True when ≥1 enabled `device-event` rule declares an occupancy condition —
7933
8594
  * the watcher is idle (zero per-frame cost) otherwise. */
7934
8595
  occupancyEnabled = false;
8596
+ /** The panel this node owns, or null. See {@link NcAlarmPanelPort}. */
8597
+ alarmPanel = null;
7935
8598
  /** In-memory cooldown map — seeded from persisted outbox rows on start. */
7936
8599
  lastFiredAt = /* @__PURE__ */ new Map();
7937
8600
  /** Per-device rate limit for the "matched NO rule" report — see `reportNoMatch`. */
@@ -7949,6 +8612,109 @@ var NotificationCenter = class NotificationCenter {
7949
8612
  reloadTimer = null;
7950
8613
  drainTicks = 0;
7951
8614
  evaluationActive = false;
8615
+ /**
8616
+ * The tap-through buttons for ONE delivery, each with its own single-use
8617
+ * token.
8618
+ *
8619
+ * Per delivery, not per notification: the first tap spends the token, so two
8620
+ * recipients sharing one would mean the second person's button was already
8621
+ * dead when it arrived.
8622
+ *
8623
+ * Returns `[]` for every ordinary reason — the rule declares no buttons, no
8624
+ * actuator is wired, no reachable URL exists. A button is an enhancement; not
8625
+ * being able to mint one must never cost the notification.
8626
+ */
8627
+ async mintButtons(input) {
8628
+ const mint = this.deps.mintActionUrl;
8629
+ if (mint === void 0 || this.actionRunner === null) return [];
8630
+ const rule = this.rules.get(input.ruleId);
8631
+ if (rule === null) return [];
8632
+ if ((rule.actions?.buttons ?? []).length === 0) return [];
8633
+ const expMs = this.now() + DEFAULT_ACTION_TTL_MS;
8634
+ const pending = [];
8635
+ const out = buildActionButtons({
8636
+ actions: rule.actions,
8637
+ mintUrl: ({ sequence, actionId }) => {
8638
+ const id = randomUUID();
8639
+ pending.push({
8640
+ id,
8641
+ url: "",
8642
+ sequence,
8643
+ actionId
8644
+ });
8645
+ return id;
8646
+ }
8647
+ });
8648
+ if (out.length === 0) return [];
8649
+ const resolved = [];
8650
+ for (const [index, action] of out.entries()) {
8651
+ const slot = pending[index];
8652
+ if (slot === void 0) continue;
8653
+ const url = await mint({
8654
+ id: slot.id,
8655
+ expMs
8656
+ });
8657
+ if (url === null) {
8658
+ this.logger.debug("no reachable base URL — notification buttons omitted", {
8659
+ tags: { deviceId: input.deviceId },
8660
+ meta: { ruleId: input.ruleId }
8661
+ });
8662
+ return [];
8663
+ }
8664
+ this.actionTokens.put({
8665
+ id: slot.id,
8666
+ ruleId: input.ruleId,
8667
+ ruleName: input.ruleName,
8668
+ sequence: slot.sequence,
8669
+ deviceId: input.deviceId,
8670
+ actionId: slot.actionId,
8671
+ targetId: input.targetId,
8672
+ expiresAt: expMs
8673
+ });
8674
+ resolved.push({
8675
+ ...action,
8676
+ url
8677
+ });
8678
+ }
8679
+ return resolved;
8680
+ }
8681
+ /** The registry the action plane claims against. */
8682
+ get actionTokenRegistry() {
8683
+ return this.actionTokens;
8684
+ }
8685
+ /**
8686
+ * Run the sequence a spent token granted.
8687
+ *
8688
+ * **The grant overrides `enabled`, deliberately.** A sequence reachable only
8689
+ * by a button is written as `enabled: false` — that is how "do not run this
8690
+ * automatically" is expressed — and the runner skips a disabled sequence. For
8691
+ * a granted run the BUTTON is the enablement, and the operator tapping it is
8692
+ * a more explicit instruction than the flag it overrides. The per-sequence
8693
+ * throttle still applies: `minDelaySec` is about how often a gate may
8694
+ * physically move, and a tap does not change that.
8695
+ *
8696
+ * Throws when the sequence is gone or a step failed — the plane turns that
8697
+ * into the page that says so.
8698
+ */
8699
+ async runGrantedAction(grant) {
8700
+ if (this.actionRunner === null) throw new Error("no actuator is wired on this node");
8701
+ const rule = this.rules.get(grant.ruleId);
8702
+ if (rule === null) throw new Error(`rule ${grant.ruleId} no longer exists`);
8703
+ const sequence = (rule.actions?.onTrigger ?? []).find((s) => s.name === grant.sequence);
8704
+ if (sequence === void 0) throw new Error(`sequence "${grant.sequence}" no longer exists on rule ${rule.name}`);
8705
+ const [outcome] = await this.actionRunner.run({
8706
+ ruleId: rule.id,
8707
+ ruleName: rule.name,
8708
+ deviceId: grant.deviceId,
8709
+ sequences: [{
8710
+ ...sequence,
8711
+ enabled: true
8712
+ }]
8713
+ });
8714
+ if (outcome === void 0) throw new Error("the sequence did not run");
8715
+ if (outcome.failedAt !== void 0) throw new Error(`step ${String(outcome.failedAt)} of "${sequence.name}" failed`);
8716
+ if (!outcome.ran) throw new Error(`"${sequence.name}" did not run (${outcome.skippedReason ?? "unknown"})`);
8717
+ }
7952
8718
  constructor(deps) {
7953
8719
  this.deps = deps;
7954
8720
  this.logger = deps.logger;
@@ -8259,9 +9025,105 @@ var NotificationCenter = class NotificationCenter {
8259
9025
  cancelSnooze: async ({ snoozeId, caller }) => {
8260
9026
  await this.cancelSnooze(snoozeId, caller);
8261
9027
  return { success: true };
9028
+ },
9029
+ getAlarmConfig: async () => this.alarmConfig(),
9030
+ setAlarmConfig: async ({ patch }) => {
9031
+ const panel = this.alarmPanel;
9032
+ if (panel === null) throw new Error("this node has no alarm panel");
9033
+ const settings = await panel.applySettings(patch);
9034
+ this.logger.info("alarm settings changed", {
9035
+ tags: { deviceId: panel.deviceId },
9036
+ meta: {
9037
+ exitDelaySec: settings.exitDelaySec,
9038
+ entryDelaySec: settings.entryDelaySec,
9039
+ triggeredDurationSec: settings.triggeredDurationSec,
9040
+ announceArm: settings.announceArm,
9041
+ announceTargets: settings.announceTargets.length
9042
+ }
9043
+ });
9044
+ return this.alarmConfig();
8262
9045
  }
8263
9046
  };
8264
9047
  }
9048
+ /**
9049
+ * Adopt the panel this node owns. Called once by the addon after
9050
+ * `ensureAlarmPanel`; a node without one never calls it and the cap then
9051
+ * answers `deviceId: null`.
9052
+ */
9053
+ setAlarmPanel(panel) {
9054
+ this.alarmPanel = panel;
9055
+ }
9056
+ /** Settings + the DERIVED coverage. Read together — see the cap doc. */
9057
+ alarmConfig() {
9058
+ const panel = this.alarmPanel;
9059
+ if (panel === null) return {
9060
+ deviceId: null,
9061
+ settings: NC_ALARM_SETTINGS_FALLBACK,
9062
+ coverage: []
9063
+ };
9064
+ return {
9065
+ deviceId: panel.deviceId,
9066
+ settings: panel.settings(),
9067
+ coverage: this.coverageFor(panel).map((c) => ({
9068
+ ...c,
9069
+ deviceIds: [...c.deviceIds]
9070
+ }))
9071
+ };
9072
+ }
9073
+ coverageFor(panel) {
9074
+ return alarmModeCoverage(this.rules.list(), panel.deviceId, panel.availableModes());
9075
+ }
9076
+ /**
9077
+ * Say which devices a mode just armed.
9078
+ *
9079
+ * Called by the panel on the transition INTO an armed mode. Fire-and-forget
9080
+ * and never able to fail the panel: `announceArm` returns void, and every
9081
+ * branch that sends nothing logs why — a mode that arms nothing is the most
9082
+ * important thing this can report, and it is exactly the case where no
9083
+ * message goes out.
9084
+ */
9085
+ announceArm(mode) {
9086
+ this.deliverArmAnnouncement(mode).catch((err) => {
9087
+ this.logger.warn("alarm arm announcement failed", { meta: {
9088
+ mode,
9089
+ error: String(err)
9090
+ } });
9091
+ });
9092
+ }
9093
+ async deliverArmAnnouncement(mode) {
9094
+ const panel = this.alarmPanel;
9095
+ if (panel === null) return;
9096
+ const settings = panel.settings();
9097
+ const tags = { deviceId: panel.deviceId };
9098
+ if (!settings.announceArm) return;
9099
+ if (settings.announceTargets.length === 0) {
9100
+ this.logger.info("alarm armed but no announcement target is configured", {
9101
+ tags,
9102
+ meta: { mode }
9103
+ });
9104
+ return;
9105
+ }
9106
+ const coverage = this.coverageFor(panel).find((c) => c.mode === mode);
9107
+ if (coverage === void 0) return;
9108
+ const message = buildArmAnnouncement(coverage, await this.dispatcher.resolveDeviceNames(coverage.deviceIds));
9109
+ if (message === null) {
9110
+ this.logger.warn("alarm armed into a mode no enabled rule is gated on", {
9111
+ tags,
9112
+ meta: {
9113
+ mode,
9114
+ state: `armed_${mode}`
9115
+ }
9116
+ });
9117
+ return;
9118
+ }
9119
+ for (const targetId of settings.announceTargets) await this.dispatcher.deliverAnnouncement({
9120
+ reason: "alarm-arm",
9121
+ targetId,
9122
+ title: message.title,
9123
+ body: message.body,
9124
+ deviceId: panel.deviceId
9125
+ });
9126
+ }
8265
9127
  /** Append one evaluation to the serialized chain (see {@link evalChain}). */
8266
9128
  scheduleEvaluation(subject, kind, logContext) {
8267
9129
  this.evalChain = this.evalChain.then(async () => {
@@ -8349,6 +9211,15 @@ var NotificationCenter = class NotificationCenter {
8349
9211
  deviceId: subject.deviceId,
8350
9212
  sequences
8351
9213
  });
9214
+ const unresolvable = unresolvableButtons(rule.actions);
9215
+ if (unresolvable.length > 0) this.logger.warn("rule buttons reference sequences that do not exist — not sent", {
9216
+ tags: { deviceId: subject.deviceId },
9217
+ meta: {
9218
+ ruleId: rule.id,
9219
+ rule: rule.name,
9220
+ buttons: unresolvable.join(", ")
9221
+ }
9222
+ });
8352
9223
  const userTargets = await this.resolveUserTargets(rule, subject.deviceId);
8353
9224
  if (await this.outbox.enqueue(this.buildEntries(rule, subject, kind, evaluation.matchedOn, userTargets)) > 0) this.lastFiredAt.set(key, now);
8354
9225
  anyMatched = true;
@@ -22046,6 +22917,95 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
22046
22917
  }
22047
22918
  }
22048
22919
  /**
22920
+ * The ranked base-URL candidates a notification LINK may use.
22921
+ *
22922
+ * Shared by the artifact plane (media) and the action plane (buttons) — one
22923
+ * resolver, because a second copy would drift and the two would start
22924
+ * minting links on different hosts for the same notification.
22925
+ */
22926
+ async ncEndpoints() {
22927
+ return collectArtifactEndpoints({
22928
+ markedBaseUrl: await this.markedNotificationEndpoint(),
22929
+ configuredPublicUrl: process.env["CAMSTACK_HUB_PUBLIC_URL"],
22930
+ getConnected: async () => {
22931
+ const status = await this.ctx.api.networkAccess.getStatus.query();
22932
+ this.ctx.logger.debug("artifact base-url: connected ingress", { meta: {
22933
+ connected: status.connected,
22934
+ url: status.endpoint?.url ?? null,
22935
+ protocol: status.endpoint?.protocol ?? null
22936
+ } });
22937
+ return status.connected && status.endpoint !== null ? {
22938
+ url: status.endpoint.url,
22939
+ protocol: status.endpoint.protocol
22940
+ } : null;
22941
+ },
22942
+ listExternal: async () => {
22943
+ return (await this.ctx.api.networkAccess.listEndpoints.query()).map((e) => ({
22944
+ url: e.url,
22945
+ protocol: e.protocol
22946
+ }));
22947
+ },
22948
+ listLan: async (port) => {
22949
+ return (await this.ctx.api.localNetwork.getConnectionEndpoints.query({ port })).endpoints.map((e) => ({
22950
+ baseUrl: e.baseUrl,
22951
+ kind: e.kind,
22952
+ priority: e.priority
22953
+ }));
22954
+ },
22955
+ logger: this.ctx.logger
22956
+ });
22957
+ }
22958
+ /**
22959
+ * Serve the NC ACTION plane: the public route a notification button lands on.
22960
+ *
22961
+ * A sibling of the artifact plane and a strictly more dangerous one — that
22962
+ * route serves bytes, this one MOVES something. Its authority is the token in
22963
+ * the link and nothing else, which is the operator's explicit choice; the
22964
+ * bounds are in `notification-center/action-token.ts` and D47.
22965
+ *
22966
+ * Best-effort like every plane here: no facility ⇒ no route, and the
22967
+ * dispatcher simply mints no buttons, because `mintActionUrl` returns null.
22968
+ */
22969
+ async serveNcActionPlane(center) {
22970
+ try {
22971
+ const secretState = this.state("ncActionSecret", NcArtifactSecretSchema, "");
22972
+ let secret = await secretState.get();
22973
+ if (secret === "") {
22974
+ secret = randomUUID().replace(/-/g, "");
22975
+ await secretState.set(secret);
22976
+ }
22977
+ const routePrefix = `/addon/${this.ctx.id}/nc-action`;
22978
+ const plane = new NcActionPlane({
22979
+ registry: center.actionTokenRegistry,
22980
+ secret,
22981
+ logger: this.ctx.logger.child("nc-actions"),
22982
+ run: (grant) => center.runGrantedAction(grant)
22983
+ });
22984
+ const served = await this.ctx.dataPlane?.serve({
22985
+ prefix: "nc-action",
22986
+ access: "public",
22987
+ handler: plane.handler
22988
+ }) ?? null;
22989
+ this.ncActionMintUrl = served === null ? null : async ({ id, expMs }) => {
22990
+ const baseUrl = pickArtifactBaseUrl(await this.ncEndpoints());
22991
+ if (baseUrl === null) return null;
22992
+ return buildActionUrl({
22993
+ baseUrl,
22994
+ routePrefix,
22995
+ id,
22996
+ secret,
22997
+ expMs
22998
+ });
22999
+ };
23000
+ this.ctx.logger.info("nc-action data-plane served", { meta: { served: served !== null } });
23001
+ } catch (err) {
23002
+ this.ctx.logger.warn("nc-action data-plane failed to serve", { meta: { error: errMsg(err) } });
23003
+ }
23004
+ }
23005
+ /** Mints a button's callback URL once the action plane is served; null until
23006
+ * then, and null forever on an install with no reachable public address. */
23007
+ ncActionMintUrl = null;
23008
+ /**
22049
23009
  * Serve the NC artifact plane: a PUBLIC route whose authority is the HMAC on
22050
23010
  * each link (see `notification-center/artifact-url.ts`). It exists because
22051
23011
  * attachments used to carry BYTES only, and the degrade engine drops a
@@ -22073,36 +23033,7 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
22073
23033
  secret,
22074
23034
  logger: this.ctx.logger.child("nc-artifacts"),
22075
23035
  routePrefix: `/addon/${this.ctx.id}/nc-artifact`,
22076
- listEndpoints: async () => collectArtifactEndpoints({
22077
- markedBaseUrl: await this.markedNotificationEndpoint(),
22078
- configuredPublicUrl: process.env["CAMSTACK_HUB_PUBLIC_URL"],
22079
- getConnected: async () => {
22080
- const status = await this.ctx.api.networkAccess.getStatus.query();
22081
- this.ctx.logger.debug("artifact base-url: connected ingress", { meta: {
22082
- connected: status.connected,
22083
- url: status.endpoint?.url ?? null,
22084
- protocol: status.endpoint?.protocol ?? null
22085
- } });
22086
- return status.connected && status.endpoint !== null ? {
22087
- url: status.endpoint.url,
22088
- protocol: status.endpoint.protocol
22089
- } : null;
22090
- },
22091
- listExternal: async () => {
22092
- return (await this.ctx.api.networkAccess.listEndpoints.query()).map((e) => ({
22093
- url: e.url,
22094
- protocol: e.protocol
22095
- }));
22096
- },
22097
- listLan: async (port) => {
22098
- return (await this.ctx.api.localNetwork.getConnectionEndpoints.query({ port })).endpoints.map((e) => ({
22099
- baseUrl: e.baseUrl,
22100
- kind: e.kind,
22101
- priority: e.priority
22102
- }));
22103
- },
22104
- logger: this.ctx.logger
22105
- })
23036
+ listEndpoints: () => this.ncEndpoints()
22106
23037
  });
22107
23038
  this.ncArtifactDataPlane = await this.ctx.dataPlane?.serve({
22108
23039
  prefix: "nc-artifact",
@@ -22171,6 +23102,17 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
22171
23102
  * `notification-rules` provider (CRUD over the central store) is
22172
23103
  * registered from every node. */
22173
23104
  notificationCenter = null;
23105
+ /**
23106
+ * The live alarm panel on THIS node, or null.
23107
+ *
23108
+ * Held, where the earlier note said not to. The objection then was a field
23109
+ * nothing reads growing into a second source of truth for which device the
23110
+ * alarm is — and it stands: the panel is still addressed by stable id
23111
+ * everywhere else. This reference exists because two things now READ it, and
23112
+ * neither can go through the device id: the settings tab, which needs the
23113
+ * panel's own config, and the arm announcement, which the panel pushes.
23114
+ */
23115
+ alarmPanelDevice = null;
22174
23116
  /** THE single owner of capture pressure (S3, refactor spec 2026-07-22):
22175
23117
  * every native-fetch call site routed so far (the per-frame batch media
22176
23118
  * dispatch + `persistKeyFrames`; part B routes the rest) goes through ONE
@@ -22461,14 +23403,18 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
22461
23403
  return found === void 0 ? null : { id: found.id };
22462
23404
  },
22463
23405
  createDevice: async ({ stableId, integrationId, name }) => {
22464
- return { id: (await devices.create(stableId, NC_ALARM_DEVICE_CLASS, {}, null, {
23406
+ const device = await devices.create(stableId, NC_ALARM_DEVICE_CLASS, {}, null, {
22465
23407
  type: NC_ALARM_DEVICE_TYPE,
22466
23408
  name,
22467
23409
  integrationId
22468
- })).id };
23410
+ });
23411
+ this.holdAlarmPanel(device);
23412
+ return { id: device.id };
22469
23413
  },
22470
23414
  adoptDevice: async ({ stableId }) => {
22471
- return { id: (await devices.create(stableId, NC_ALARM_DEVICE_CLASS, {}, null, void 0)).id };
23415
+ const device = await devices.create(stableId, NC_ALARM_DEVICE_CLASS, {}, null, void 0);
23416
+ this.holdAlarmPanel(device);
23417
+ return { id: device.id };
22472
23418
  }
22473
23419
  });
22474
23420
  if (result.created) this.ctx.logger.info("notification-center alarm panel ready", { tags: { deviceId: result.deviceId } });
@@ -22476,6 +23422,41 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
22476
23422
  this.ctx.logger.warn("alarm panel could not be ensured — rules still notify", { meta: { error: err instanceof Error ? err.message : String(err) } });
22477
23423
  }
22478
23424
  }
23425
+ /**
23426
+ * Keep the constructed panel, if that is what came back.
23427
+ *
23428
+ * An `instanceof` narrowing rather than a cast: `devices.create` promises an
23429
+ * `IDevice`, and the only honest way to know it is the alarm is to ask. A
23430
+ * cast here would compile against any future device class and fail at the
23431
+ * first `settings()` call, in production, with the tab already open.
23432
+ */
23433
+ holdAlarmPanel(device) {
23434
+ if (!(device instanceof NcAlarmPanelDevice)) {
23435
+ this.ctx.logger.warn("alarm panel is not the expected device class — settings tab disabled");
23436
+ return;
23437
+ }
23438
+ this.alarmPanelDevice = device;
23439
+ }
23440
+ /**
23441
+ * Join the panel and the Notification Center, once both exist.
23442
+ *
23443
+ * They are built in that order (`ensureAlarmPanel` runs before the centre is
23444
+ * constructed) and neither can reach the other on its own, so the join is
23445
+ * here. A node with no panel simply never calls it, and the cap answers
23446
+ * `deviceId: null` — which is what an agent node should say.
23447
+ */
23448
+ wireAlarmPanel(center) {
23449
+ const panel = this.alarmPanelDevice;
23450
+ if (panel === null) return;
23451
+ center.setAlarmPanel({
23452
+ deviceId: panel.id,
23453
+ settings: () => panel.settings(),
23454
+ applySettings: (patch) => panel.applySettings(patch),
23455
+ availableModes: () => NC_ALARM_MODES
23456
+ });
23457
+ panel.setArmedHook((mode) => center.announceArm(mode));
23458
+ this.ctx.logger.info("alarm panel wired to the notification center", { tags: { deviceId: panel.id } });
23459
+ }
22479
23460
  async declareCollections(api) {
22480
23461
  await TrackStore.declare(api.settingsStore);
22481
23462
  await MediaStore.declare(api.settingsStore);
@@ -22870,6 +23851,7 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
22870
23851
  });
22871
23852
  },
22872
23853
  isCapDeviceScoped: isDeviceScopedCap,
23854
+ mintActionUrl: (input) => this.ncActionMintUrl?.(input) ?? Promise.resolve(null),
22873
23855
  readDeviceStates: async (ids) => {
22874
23856
  const out = /* @__PURE__ */ new Map();
22875
23857
  for (const id of ids) try {
@@ -22931,18 +23913,20 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
22931
23913
  });
22932
23914
  },
22933
23915
  send: async (input) => {
22934
- const { attachments, ...notification } = input.notification;
23916
+ const { attachments, actions, ...notification } = input.notification;
22935
23917
  return api.notificationOutput.send.mutate({
22936
23918
  addonId: input.addonId,
22937
23919
  targetId: input.targetId,
22938
23920
  notification: {
22939
23921
  ...notification,
22940
- ...attachments !== void 0 ? { attachments: [...attachments] } : {}
23922
+ ...attachments !== void 0 ? { attachments: [...attachments] } : {},
23923
+ ...actions !== void 0 ? { actions: [...actions] } : {}
22941
23924
  }
22942
23925
  });
22943
23926
  },
22944
23927
  getMediaForOwner: (ownerKind, ownerId) => stores.mediaStore.listByOwner(ownerKind, ownerId),
22945
- getDeviceName
23928
+ getDeviceName,
23929
+ buildActions: (input) => this.notificationCenter?.mintButtons(input) ?? Promise.resolve([])
22946
23930
  },
22947
23931
  listObjectEventsSince: (since, limit) => stores.eventStore.queryObjectSince({
22948
23932
  since,
@@ -22958,6 +23942,8 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
22958
23942
  })).tracks;
22959
23943
  }
22960
23944
  });
23945
+ this.wireAlarmPanel(this.notificationCenter);
23946
+ await this.serveNcActionPlane(this.notificationCenter);
22961
23947
  await this.notificationCenter.start({ evaluation: this.isPostProcessingNode });
22962
23948
  }
22963
23949
  /** Event-media data-plane: serve thumbnail JPEGs at