@camstack/addon-post-analysis 1.2.34 → 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.
@@ -2,7 +2,7 @@ Object.defineProperties(exports, {
2
2
  __esModule: { value: true },
3
3
  [Symbol.toStringTag]: { value: "Module" }
4
4
  });
5
- const require_dist = require("../dist-7ujnICn7.js");
5
+ const require_dist = require("../dist-Cmb3B0Ij.js");
6
6
  let node_fs = require("node:fs");
7
7
  let node_path = require("node:path");
8
8
  node_path = require_dist.__toESM(node_path);
@@ -550,7 +550,132 @@ function mimeFromExtension(file) {
550
550
  return "application/octet-stream";
551
551
  }
552
552
  //#endregion
553
+ //#region src/notification-center/alarm/alarm-mode-coverage.ts
554
+ /**
555
+ * Whether the panel just BECAME armed, in the sense a person means.
556
+ *
557
+ * Two transitions reach an `armed_*` state and only one of them is an arm:
558
+ *
559
+ * - `arming`/`disarmed` → `armed_away` is somebody arming the alarm. Announce.
560
+ * - `triggered` → `armed_away` is the siren's duration ending and the panel
561
+ * re-arming itself. Announcing it would send "Away armed" in the middle of
562
+ * a break-in, seconds after the alarm notification, saying nothing new.
563
+ *
564
+ * `armed_home` → `armed_away` IS an arm: the operator changed mode, and the
565
+ * set of devices covered just changed with it — which is the whole content of
566
+ * the message.
567
+ *
568
+ * A null `previous` is the FIRST publish and never announces. A hub restarting
569
+ * while armed re-publishes `armed_away`, and nobody armed anything — every
570
+ * deploy would otherwise send one. Handled here rather than by the caller
571
+ * happening to wire the hook late, because "it works because of the order two
572
+ * unrelated things run in" is how this stops working.
573
+ */
574
+ function shouldAnnounceArm(previous, next) {
575
+ if (previous === null) return false;
576
+ if (!next.startsWith("armed_")) return false;
577
+ if (previous === next) return false;
578
+ if (previous === "triggered") return false;
579
+ return true;
580
+ }
581
+ /** The mode inside an `armed_<mode>` state, or null for any other state. */
582
+ function armModeOf(state, modes) {
583
+ for (const mode of modes) if (state === armedStateFor(mode)) return mode;
584
+ return null;
585
+ }
586
+ /** The `deviceState` word a rule must gate on for `mode` to cover it. */
587
+ function armedStateFor(mode) {
588
+ return `armed_${mode}`;
589
+ }
590
+ /**
591
+ * One entry per mode the panel offers, including modes nothing is gated on.
592
+ *
593
+ * Empty modes are KEPT rather than filtered: "Night arms nothing" is the single
594
+ * most useful thing this can tell an operator, and a list that omits it looks
595
+ * identical to a list where night is covered.
596
+ */
597
+ function alarmModeCoverage(rules, panelDeviceId, modes) {
598
+ return modes.map((mode) => coverageFor(rules, panelDeviceId, mode));
599
+ }
600
+ function coverageFor(rules, panelDeviceId, mode) {
601
+ const wanted = armedStateFor(mode);
602
+ const ids = /* @__PURE__ */ new Set();
603
+ let ruleCount = 0;
604
+ let allDevices = false;
605
+ for (const rule of rules) {
606
+ if (!rule.enabled) continue;
607
+ const gate = rule.conditions.deviceState;
608
+ if (gate === void 0 || gate.deviceId !== panelDeviceId) continue;
609
+ if (!gate.states.includes(wanted)) continue;
610
+ ruleCount += 1;
611
+ const scope = rule.conditions.devices;
612
+ if (scope === void 0 || scope.length === 0) {
613
+ allDevices = true;
614
+ continue;
615
+ }
616
+ for (const id of scope) ids.add(id);
617
+ }
618
+ return {
619
+ mode,
620
+ ruleCount,
621
+ allDevices,
622
+ deviceIds: [...ids].toSorted((a, b) => a - b)
623
+ };
624
+ }
625
+ /**
626
+ * "Away armed — 3 cameras: Front door, Garage, Gate."
627
+ *
628
+ * Names, not ids: this is read by a person standing at a door. An id that has
629
+ * no name falls back to `Device <id>` rather than being dropped — a silently
630
+ * shorter list would understate what is armed, which is the one error this
631
+ * message must not make.
632
+ *
633
+ * Returns null when the mode covers NOTHING and no rule is gated on it. A
634
+ * notification saying "Night armed" that is followed by nothing happening all
635
+ * night is worse than no notification: it is a false assurance. The caller
636
+ * still logs the arm.
637
+ */
638
+ function buildArmAnnouncement(coverage, deviceNames) {
639
+ if (coverage.ruleCount === 0) return null;
640
+ const title = `${modeLabel(coverage.mode)} armed`;
641
+ if (coverage.allDevices) return {
642
+ title,
643
+ body: `Every device is armed (${plural$1(coverage.ruleCount, "rule")}).`
644
+ };
645
+ if (coverage.deviceIds.length === 0) return {
646
+ title,
647
+ body: `${plural$1(coverage.ruleCount, "rule")}, no device restriction.`
648
+ };
649
+ const names = coverage.deviceIds.map((id) => deviceNames.get(id) ?? `Device ${id}`);
650
+ return {
651
+ title,
652
+ body: `${plural$1(names.length, "device")} armed: ${names.join(", ")}.`
653
+ };
654
+ }
655
+ function modeLabel(mode) {
656
+ return mode.charAt(0).toUpperCase() + mode.slice(1).replace(/_/g, " ");
657
+ }
658
+ function plural$1(n, one) {
659
+ return n === 1 ? `1 ${one}` : `${n} ${one}s`;
660
+ }
661
+ //#endregion
553
662
  //#region src/notification-center/alarm/alarm-state-machine.ts
663
+ /**
664
+ * Defaults an operator can change per panel. `home` has no exit delay on
665
+ * purpose — nobody is leaving — but the shared value is applied to every mode
666
+ * until per-mode delays are asked for.
667
+ *
668
+ * `triggeredDurationSec: 0` = "sound until somebody disarms it". That is what
669
+ * the panel did before the field existed, so an install that never opens the
670
+ * tab keeps behaving exactly as it did — a default that silently re-armed
671
+ * every existing alarm after N seconds would be a behaviour change nobody
672
+ * asked for.
673
+ */
674
+ var DEFAULT_DELAYS = {
675
+ exitDelaySec: 30,
676
+ entryDelaySec: 20,
677
+ triggeredDurationSec: 0
678
+ };
554
679
  var DISARMED = {
555
680
  target: "disarmed",
556
681
  fired: false,
@@ -567,6 +692,7 @@ function armedState(mode) {
567
692
  * answer is still correct the moment anyone asks.
568
693
  */
569
694
  function stateAt(m, now) {
695
+ if (m.clearAt !== void 0 && now >= m.clearAt) return m.target;
570
696
  if (m.fired) return "triggered";
571
697
  if (m.triggerAt !== void 0) return now >= m.triggerAt ? "triggered" : "pending";
572
698
  if (m.armedAt !== void 0) return now >= m.armedAt ? m.target : "arming";
@@ -620,19 +746,24 @@ function disarm(now) {
620
746
  * intruder walking past two sensors would otherwise postpone the alarm.
621
747
  */
622
748
  function trigger(m, delays, now) {
749
+ const state = stateAt(m, now);
750
+ if (state === "triggered" || state === "pending") return m;
623
751
  if (!isArmed(m, now)) return m;
624
- if (m.fired || m.triggerAt !== void 0) return m;
625
- if (delays.entryDelaySec <= 0) return {
626
- ...m,
627
- fired: true,
628
- changedAt: now
629
- };
752
+ const base = settle(m, now);
753
+ const firesAt = now + Math.max(0, delays.entryDelaySec) * 1e3;
630
754
  return {
631
- ...m,
632
- triggerAt: now + delays.entryDelaySec * 1e3,
755
+ ...base,
756
+ triggerAt: firesAt,
757
+ ...autoClear(delays, firesAt),
758
+ fired: false,
633
759
  changedAt: now
634
760
  };
635
761
  }
762
+ /** `clearAt` iff the operator asked for a bounded siren — see {@link AlarmDelays}. */
763
+ function autoClear(delays, firesAt) {
764
+ if (delays.triggeredDurationSec <= 0) return {};
765
+ return { clearAt: firesAt + delays.triggeredDurationSec * 1e3 };
766
+ }
636
767
  /**
637
768
  * Settle the machine so a stored value never depends on when it is read.
638
769
  *
@@ -641,10 +772,16 @@ function trigger(m, delays, now) {
641
772
  * than only in the reading of it.
642
773
  */
643
774
  function settle(m, now) {
775
+ if (m.clearAt !== void 0 && now >= m.clearAt) return {
776
+ target: m.target,
777
+ fired: false,
778
+ changedAt: m.clearAt
779
+ };
644
780
  if (m.triggerAt !== void 0 && now >= m.triggerAt) return {
645
781
  target: m.target,
646
782
  fired: true,
647
- changedAt: m.triggerAt
783
+ changedAt: m.triggerAt,
784
+ ...m.clearAt !== void 0 ? { clearAt: m.clearAt } : {}
648
785
  };
649
786
  if (m.armedAt !== void 0 && now >= m.armedAt) return {
650
787
  target: m.target,
@@ -673,21 +810,12 @@ function settle(m, now) {
673
810
  * and re-publish when a delay elapses.
674
811
  */
675
812
  /** Every arm mode a camstack-owned panel offers. */
676
- var MODES = [
813
+ var NC_ALARM_MODES = [
677
814
  "home",
678
815
  "away",
679
816
  "night"
680
817
  ];
681
818
  /**
682
- * Defaults an operator can change per panel. `home` has no exit delay on
683
- * purpose — nobody is leaving — but the shared value is applied to every mode
684
- * until per-mode delays are asked for.
685
- */
686
- var DEFAULT_DELAYS = {
687
- exitDelaySec: 30,
688
- entryDelaySec: 20
689
- };
690
- /**
691
819
  * How often the panel re-publishes while a delay is running.
692
820
  *
693
821
  * The state is computed from an INSTANT, so this tick only decides how quickly
@@ -703,30 +831,94 @@ var TICK_MS = 1e3;
703
831
  var ncAlarmPanelSchema = require_dist.object({
704
832
  exitDelaySec: require_dist.number().int().min(0).max(600).default(DEFAULT_DELAYS.exitDelaySec),
705
833
  entryDelaySec: require_dist.number().int().min(0).max(600).default(DEFAULT_DELAYS.entryDelaySec),
834
+ triggeredDurationSec: require_dist.number().int().min(0).max(3600).default(DEFAULT_DELAYS.triggeredDurationSec),
835
+ /** Send a notification when a mode takes effect. Off until asked for. */
836
+ announceArm: require_dist.boolean().default(false),
837
+ /** Target ids that announcement goes to — see `NcAlarmSettingsSchema`. */
838
+ announceTargets: require_dist.array(require_dist.string()).default([]),
706
839
  machine: require_dist.record(require_dist.string(), require_dist.unknown()).optional()
707
840
  });
841
+ /** `armed_home` and friends — the only targets a machine may settle into. */
842
+ function isArmTarget(value) {
843
+ return value === "disarmed" || typeof value === "string" && value.startsWith("armed_");
844
+ }
845
+ function optionalInstant(value) {
846
+ return typeof value === "number" && Number.isFinite(value) ? value : void 0;
847
+ }
848
+ /**
849
+ * Rebuild the machine from the persisted blob, or null when there is nothing
850
+ * trustworthy in it.
851
+ *
852
+ * A type GUARD, not a cast: the blob has survived a schema change once already
853
+ * (`clearAt` did not exist), and the failure mode of a cast here is an alarm
854
+ * that reports a state it cannot reach. Anything unrecognised falls back to
855
+ * DISARMED, which is the state an operator will notice.
856
+ *
857
+ * Exported for the test that proves a pre-`clearAt` blob still restores.
858
+ */
859
+ function machineFromPersisted(raw) {
860
+ if (raw === null || typeof raw !== "object") return null;
861
+ const rec = { ...raw };
862
+ if (!isArmTarget(rec["target"]) || typeof rec["changedAt"] !== "number") return null;
863
+ const armedAt = optionalInstant(rec["armedAt"]);
864
+ const triggerAt = optionalInstant(rec["triggerAt"]);
865
+ const clearAt = optionalInstant(rec["clearAt"]);
866
+ return {
867
+ target: rec["target"],
868
+ ...armedAt !== void 0 ? { armedAt } : {},
869
+ ...triggerAt !== void 0 ? { triggerAt } : {},
870
+ ...clearAt !== void 0 ? { clearAt } : {},
871
+ fired: rec["fired"] === true,
872
+ changedAt: rec["changedAt"]
873
+ };
874
+ }
708
875
  var NcAlarmPanelDevice = class extends require_dist.BaseDevice {
709
876
  features = [];
710
877
  machine = DISARMED;
711
878
  delays = DEFAULT_DELAYS;
712
879
  ticker = null;
880
+ onArmed = null;
881
+ /**
882
+ * The state the last publish reported. Null until the first one, so the
883
+ * BOOT publish never announces: a hub restarting while armed would otherwise
884
+ * announce "Away armed" on every deploy.
885
+ */
886
+ lastPublished = null;
713
887
  constructor(ctx) {
714
888
  super(ctx, ncAlarmPanelSchema, { type: ctx.deviceMeta.type });
715
- this.delays = {
716
- exitDelaySec: this.config.get("exitDelaySec"),
717
- entryDelaySec: this.config.get("entryDelaySec")
718
- };
889
+ this.delays = this.readDelays();
719
890
  this.restore();
720
891
  this.registerAlarmCap();
721
892
  this.publish();
722
893
  }
723
- /** Fire a trigger at the panel. Called by the notification center when a rule
724
- * that arms the alarm matches. Ignored unless the panel is armed. */
725
- onRuleTriggered(now = Date.now()) {
726
- const next = trigger(this.machine, this.delays, now);
727
- if (next === this.machine) return;
728
- this.machine = next;
729
- this.publish(now);
894
+ /** The operator-visible configuration, as the cap serves it. */
895
+ settings() {
896
+ return {
897
+ ...this.readDelays(),
898
+ announceArm: this.config.get("announceArm"),
899
+ announceTargets: [...this.config.get("announceTargets")]
900
+ };
901
+ }
902
+ /**
903
+ * Apply an editor's patch and return what the panel settled on.
904
+ *
905
+ * The new delays take effect on the NEXT transition, never retroactively: a
906
+ * machine already counting down keeps the instants it was given. Re-deriving
907
+ * `armedAt` from a delay the operator changed mid-exit would move the arm
908
+ * instant under somebody who is walking out of the door.
909
+ */
910
+ async applySettings(patch) {
911
+ if (patch.exitDelaySec !== void 0) await this.config.set("exitDelaySec", patch.exitDelaySec);
912
+ if (patch.entryDelaySec !== void 0) await this.config.set("entryDelaySec", patch.entryDelaySec);
913
+ if (patch.triggeredDurationSec !== void 0) await this.config.set("triggeredDurationSec", patch.triggeredDurationSec);
914
+ if (patch.announceArm !== void 0) await this.config.set("announceArm", patch.announceArm);
915
+ if (patch.announceTargets !== void 0) await this.config.set("announceTargets", [...patch.announceTargets]);
916
+ this.delays = this.readDelays();
917
+ return this.settings();
918
+ }
919
+ /** Wire the arm announcement. See {@link NcAlarmArmedHook}. */
920
+ setArmedHook(hook) {
921
+ this.onArmed = hook;
730
922
  }
731
923
  /** Current lifecycle word — what the `deviceState` gate compares against. */
732
924
  currentState(now = Date.now()) {
@@ -749,19 +941,24 @@ var NcAlarmPanelDevice = class extends require_dist.BaseDevice {
749
941
  this.publish();
750
942
  },
751
943
  trigger: async () => {
752
- this.machine = {
753
- ...this.machine,
754
- fired: true,
755
- changedAt: Date.now()
756
- };
757
- this.publish();
944
+ const now = Date.now();
945
+ const next = trigger(this.machine, this.delays, now);
946
+ if (next === this.machine) {
947
+ this.ctx.logger.info("alarm trigger ignored — the panel is not armed", {
948
+ tags: { deviceId: this.id },
949
+ meta: { state: stateAt(this.machine, now) }
950
+ });
951
+ return;
952
+ }
953
+ this.machine = next;
954
+ this.publish(now);
758
955
  }
759
956
  });
760
957
  }
761
958
  status(now = Date.now()) {
762
959
  return {
763
960
  state: stateAt(this.machine, now),
764
- availableModes: [...MODES],
961
+ availableModes: [...NC_ALARM_MODES],
765
962
  requiresCode: false,
766
963
  lastChangedAt: this.machine.changedAt
767
964
  };
@@ -775,11 +972,48 @@ var NcAlarmPanelDevice = class extends require_dist.BaseDevice {
775
972
  */
776
973
  publish(now = Date.now()) {
777
974
  this.machine = settle(this.machine, now);
975
+ const state = stateAt(this.machine, now);
778
976
  this.runtimeState.setCapState("alarm-panel", this.status(now));
779
977
  this.persist();
780
- if (this.machine.armedAt !== void 0 || this.machine.triggerAt !== void 0) this.startTicking();
978
+ this.announceIfArmed(state);
979
+ if (this.machine.armedAt !== void 0 || this.machine.triggerAt !== void 0 || this.machine.clearAt !== void 0) this.startTicking();
781
980
  else this.stopTicking();
782
981
  }
982
+ /**
983
+ * Fire the arm hook on the transition INTO an armed mode.
984
+ *
985
+ * Never throws into the publish path: an announcement that could take the
986
+ * panel's own state update with it would make a notification failure look
987
+ * like an alarm failure.
988
+ */
989
+ announceIfArmed(state) {
990
+ const previous = this.lastPublished;
991
+ this.lastPublished = state;
992
+ const hook = this.onArmed;
993
+ if (hook === null) return;
994
+ if (!shouldAnnounceArm(previous, state)) return;
995
+ const mode = armModeOf(state, NC_ALARM_MODES);
996
+ if (mode === null) return;
997
+ try {
998
+ hook(mode);
999
+ } catch (err) {
1000
+ this.ctx.logger.warn("alarm arm announcement failed", {
1001
+ tags: { deviceId: this.id },
1002
+ meta: {
1003
+ mode,
1004
+ error: String(err)
1005
+ }
1006
+ });
1007
+ }
1008
+ }
1009
+ /** The three durations, read from the persisted config. */
1010
+ readDelays() {
1011
+ return {
1012
+ exitDelaySec: this.config.get("exitDelaySec"),
1013
+ entryDelaySec: this.config.get("entryDelaySec"),
1014
+ triggeredDurationSec: this.config.get("triggeredDurationSec")
1015
+ };
1016
+ }
783
1017
  startTicking() {
784
1018
  if (this.ticker !== null) return;
785
1019
  this.ticker = setInterval(() => this.publish(), TICK_MS);
@@ -796,17 +1030,9 @@ var NcAlarmPanelDevice = class extends require_dist.BaseDevice {
796
1030
  this.config.set("machine", { ...this.machine });
797
1031
  }
798
1032
  restore() {
799
- const raw = this.config.get("machine");
800
- if (raw === null || typeof raw !== "object") return;
801
- const m = raw;
802
- if (typeof m.target !== "string" || typeof m.changedAt !== "number") return;
803
- this.machine = {
804
- target: m.target,
805
- ...typeof m.armedAt === "number" ? { armedAt: m.armedAt } : {},
806
- ...typeof m.triggerAt === "number" ? { triggerAt: m.triggerAt } : {},
807
- fired: m.fired === true,
808
- changedAt: m.changedAt
809
- };
1033
+ const restored = machineFromPersisted(this.config.get("machine"));
1034
+ if (restored === null) return;
1035
+ this.machine = restored;
810
1036
  }
811
1037
  };
812
1038
  //#endregion
@@ -5074,14 +5300,21 @@ function readSensorEventType(value) {
5074
5300
  const eventType = last["eventType"];
5075
5301
  return typeof eventType === "string" && eventType.length > 0 ? eventType : void 0;
5076
5302
  }
5077
- /** Build the subject for a `device-event` evaluation (a persisted SensorEvent —
5078
- * one row per linked camera; `deviceId` is the CAMERA). */
5303
+ /**
5304
+ * Build the subject for a `device-event` evaluation (a persisted SensorEvent —
5305
+ * one row per linked camera; `deviceId` is the CAMERA).
5306
+ *
5307
+ * BOTH ids ride along. The camera is what the notification shows; the sensor is
5308
+ * what the operator named in the rule. Carrying only the camera is what made a
5309
+ * sensor-scoped rule unmatched — see {@link NcRuleSubject.sourceDeviceId}.
5310
+ */
5079
5311
  function subjectFromSensorEvent(ev) {
5080
5312
  const eventType = readSensorEventType(ev.value);
5081
5313
  return {
5082
5314
  kind: "device-event",
5083
5315
  recordId: ev.id,
5084
5316
  deviceId: ev.deviceId,
5317
+ ...ev.sourceDeviceId !== ev.deviceId ? { sourceDeviceId: ev.sourceDeviceId } : {},
5085
5318
  timestamp: ev.timestamp,
5086
5319
  classNames: [],
5087
5320
  zones: [],
@@ -5313,7 +5546,7 @@ function evaluateRule(rule, subject, deviceState) {
5313
5546
  if (current === void 0) return fail("deviceState");
5314
5547
  if (!toLowerSet(c.deviceState.states).has(current.trim().toLowerCase())) return fail("deviceState");
5315
5548
  }
5316
- if (c.devices !== void 0 && c.devices.length > 0 && !c.devices.includes(subject.deviceId)) return fail("devices");
5549
+ if (c.devices !== void 0 && c.devices.length > 0 && !matchesDeviceScope(c.devices, subject)) return fail("devices");
5317
5550
  if (c.source !== void 0 && c.source !== "any") {
5318
5551
  if ((subject.source ?? "pipeline") !== c.source) return fail("source");
5319
5552
  }
@@ -5500,6 +5733,28 @@ function keysPerClass(rule, subject) {
5500
5733
  return rule.throttle.granularity === "per-class";
5501
5734
  }
5502
5735
  /** Stable cooldown key per the rule's throttle scope + class granularity. */
5736
+ /**
5737
+ * Does the rule's device scope cover this subject?
5738
+ *
5739
+ * EITHER id matches, and both are needed for the same rule to be authorable
5740
+ * the two ways an operator thinks about it:
5741
+ *
5742
+ * - "when anything happens on the front-door CAMERA" → the camera id, which
5743
+ * is what a sensor row is attributed to;
5744
+ * - "when the front-door CONTACT opens" → the sensor id, which is the device
5745
+ * the operator actually cares about and the one they pick in the Devices
5746
+ * tab. Before this, that rule matched nothing, ever, silently.
5747
+ *
5748
+ * Consequence worth knowing: a sensor linked to N cameras persists N rows, so
5749
+ * a SENSOR-scoped rule is evaluated N times for one door opening. The rule's
5750
+ * own throttle is what collapses that — `scope: 'rule'` gives one
5751
+ * notification, `scope: 'device'` gives one per camera, which is the right
5752
+ * choice when each carries its own picture.
5753
+ */
5754
+ function matchesDeviceScope(scope, subject) {
5755
+ if (scope.includes(subject.deviceId)) return true;
5756
+ return subject.sourceDeviceId !== void 0 && scope.includes(subject.sourceDeviceId);
5757
+ }
5503
5758
  function cooldownKey(rule, subject) {
5504
5759
  const first = subject.classNames[0];
5505
5760
  const classKey = keysPerClass(rule, subject) && first !== void 0 ? `:c:${first}` : "";
@@ -5774,6 +6029,82 @@ var NcDispatcher = class {
5774
6029
  return false;
5775
6030
  }
5776
6031
  }
6032
+ /**
6033
+ * Send a Notification-Center-composed message to ONE target.
6034
+ *
6035
+ * Never throws and never retries — see {@link NcAnnouncementSendInput}. Every
6036
+ * way of not delivering produces a line: an announcement that silently did
6037
+ * not arrive is indistinguishable from the alarm not having armed, which is
6038
+ * the exact confusion this feature exists to remove.
6039
+ */
6040
+ async deliverAnnouncement(input) {
6041
+ const tags = input.deviceId !== void 0 ? { deviceId: input.deviceId } : void 0;
6042
+ let target;
6043
+ try {
6044
+ target = await this.resolveTarget(input.targetId);
6045
+ } catch (err) {
6046
+ this.deps.logger.warn("announcement: target catalog unreachable", {
6047
+ ...tags !== void 0 ? { tags } : {},
6048
+ meta: {
6049
+ reason: input.reason,
6050
+ targetId: input.targetId,
6051
+ error: String(err)
6052
+ }
6053
+ });
6054
+ return false;
6055
+ }
6056
+ if (target === null || !target.enabled) {
6057
+ this.deps.logger.warn("announcement: target gone or disabled", {
6058
+ ...tags !== void 0 ? { tags } : {},
6059
+ meta: {
6060
+ reason: input.reason,
6061
+ targetId: input.targetId
6062
+ }
6063
+ });
6064
+ return false;
6065
+ }
6066
+ try {
6067
+ const result = await this.deps.send({
6068
+ addonId: target.addonId,
6069
+ targetId: target.id,
6070
+ notification: {
6071
+ title: input.title,
6072
+ body: input.body,
6073
+ priority: 2,
6074
+ ...input.deviceId !== void 0 ? { deviceId: input.deviceId } : {}
6075
+ }
6076
+ });
6077
+ if (!result.success) {
6078
+ this.deps.logger.warn("announcement send failed", {
6079
+ ...tags !== void 0 ? { tags } : {},
6080
+ meta: {
6081
+ reason: input.reason,
6082
+ targetId: target.id,
6083
+ error: result.error
6084
+ }
6085
+ });
6086
+ return false;
6087
+ }
6088
+ this.deps.logger.info("announcement delivered", {
6089
+ ...tags !== void 0 ? { tags } : {},
6090
+ meta: {
6091
+ reason: input.reason,
6092
+ target: target.name
6093
+ }
6094
+ });
6095
+ return true;
6096
+ } catch (err) {
6097
+ this.deps.logger.warn("announcement send threw", {
6098
+ ...tags !== void 0 ? { tags } : {},
6099
+ meta: {
6100
+ reason: input.reason,
6101
+ targetId: target.id,
6102
+ error: String(err)
6103
+ }
6104
+ });
6105
+ return false;
6106
+ }
6107
+ }
5777
6108
  /** Device display names for a set of ids, for the digest's lines. */
5778
6109
  async resolveDeviceNames(deviceIds) {
5779
6110
  const out = /* @__PURE__ */ new Map();
@@ -8130,6 +8461,19 @@ var TimelapseStore = class {
8130
8461
  * CRUD served elsewhere becomes effective within one rule-reload tick.
8131
8462
  */
8132
8463
  /**
8464
+ * What `getAlarmConfig` answers on a node with no panel.
8465
+ *
8466
+ * The DEFAULTS, not zeroes: paired with `deviceId: null` this tells a client
8467
+ * "there is no panel here" while still describing the shape it would have.
8468
+ * Zeroes would render as "no entry delay", which is a claim about an alarm
8469
+ * that does not exist.
8470
+ */
8471
+ var NC_ALARM_SETTINGS_FALLBACK = {
8472
+ ...DEFAULT_DELAYS,
8473
+ announceArm: false,
8474
+ announceTargets: []
8475
+ };
8476
+ /**
8133
8477
  * How often the "matched NO rule" report may fire per device. Long enough that
8134
8478
  * a busy camera prints one line rather than one per event, short enough that a
8135
8479
  * rule which has stopped matching is visible within minutes rather than by
@@ -8255,6 +8599,8 @@ var NotificationCenter = class NotificationCenter {
8255
8599
  /** True when ≥1 enabled `device-event` rule declares an occupancy condition —
8256
8600
  * the watcher is idle (zero per-frame cost) otherwise. */
8257
8601
  occupancyEnabled = false;
8602
+ /** The panel this node owns, or null. See {@link NcAlarmPanelPort}. */
8603
+ alarmPanel = null;
8258
8604
  /** In-memory cooldown map — seeded from persisted outbox rows on start. */
8259
8605
  lastFiredAt = /* @__PURE__ */ new Map();
8260
8606
  /** Per-device rate limit for the "matched NO rule" report — see `reportNoMatch`. */
@@ -8685,9 +9031,105 @@ var NotificationCenter = class NotificationCenter {
8685
9031
  cancelSnooze: async ({ snoozeId, caller }) => {
8686
9032
  await this.cancelSnooze(snoozeId, caller);
8687
9033
  return { success: true };
9034
+ },
9035
+ getAlarmConfig: async () => this.alarmConfig(),
9036
+ setAlarmConfig: async ({ patch }) => {
9037
+ const panel = this.alarmPanel;
9038
+ if (panel === null) throw new Error("this node has no alarm panel");
9039
+ const settings = await panel.applySettings(patch);
9040
+ this.logger.info("alarm settings changed", {
9041
+ tags: { deviceId: panel.deviceId },
9042
+ meta: {
9043
+ exitDelaySec: settings.exitDelaySec,
9044
+ entryDelaySec: settings.entryDelaySec,
9045
+ triggeredDurationSec: settings.triggeredDurationSec,
9046
+ announceArm: settings.announceArm,
9047
+ announceTargets: settings.announceTargets.length
9048
+ }
9049
+ });
9050
+ return this.alarmConfig();
8688
9051
  }
8689
9052
  };
8690
9053
  }
9054
+ /**
9055
+ * Adopt the panel this node owns. Called once by the addon after
9056
+ * `ensureAlarmPanel`; a node without one never calls it and the cap then
9057
+ * answers `deviceId: null`.
9058
+ */
9059
+ setAlarmPanel(panel) {
9060
+ this.alarmPanel = panel;
9061
+ }
9062
+ /** Settings + the DERIVED coverage. Read together — see the cap doc. */
9063
+ alarmConfig() {
9064
+ const panel = this.alarmPanel;
9065
+ if (panel === null) return {
9066
+ deviceId: null,
9067
+ settings: NC_ALARM_SETTINGS_FALLBACK,
9068
+ coverage: []
9069
+ };
9070
+ return {
9071
+ deviceId: panel.deviceId,
9072
+ settings: panel.settings(),
9073
+ coverage: this.coverageFor(panel).map((c) => ({
9074
+ ...c,
9075
+ deviceIds: [...c.deviceIds]
9076
+ }))
9077
+ };
9078
+ }
9079
+ coverageFor(panel) {
9080
+ return alarmModeCoverage(this.rules.list(), panel.deviceId, panel.availableModes());
9081
+ }
9082
+ /**
9083
+ * Say which devices a mode just armed.
9084
+ *
9085
+ * Called by the panel on the transition INTO an armed mode. Fire-and-forget
9086
+ * and never able to fail the panel: `announceArm` returns void, and every
9087
+ * branch that sends nothing logs why — a mode that arms nothing is the most
9088
+ * important thing this can report, and it is exactly the case where no
9089
+ * message goes out.
9090
+ */
9091
+ announceArm(mode) {
9092
+ this.deliverArmAnnouncement(mode).catch((err) => {
9093
+ this.logger.warn("alarm arm announcement failed", { meta: {
9094
+ mode,
9095
+ error: String(err)
9096
+ } });
9097
+ });
9098
+ }
9099
+ async deliverArmAnnouncement(mode) {
9100
+ const panel = this.alarmPanel;
9101
+ if (panel === null) return;
9102
+ const settings = panel.settings();
9103
+ const tags = { deviceId: panel.deviceId };
9104
+ if (!settings.announceArm) return;
9105
+ if (settings.announceTargets.length === 0) {
9106
+ this.logger.info("alarm armed but no announcement target is configured", {
9107
+ tags,
9108
+ meta: { mode }
9109
+ });
9110
+ return;
9111
+ }
9112
+ const coverage = this.coverageFor(panel).find((c) => c.mode === mode);
9113
+ if (coverage === void 0) return;
9114
+ const message = buildArmAnnouncement(coverage, await this.dispatcher.resolveDeviceNames(coverage.deviceIds));
9115
+ if (message === null) {
9116
+ this.logger.warn("alarm armed into a mode no enabled rule is gated on", {
9117
+ tags,
9118
+ meta: {
9119
+ mode,
9120
+ state: `armed_${mode}`
9121
+ }
9122
+ });
9123
+ return;
9124
+ }
9125
+ for (const targetId of settings.announceTargets) await this.dispatcher.deliverAnnouncement({
9126
+ reason: "alarm-arm",
9127
+ targetId,
9128
+ title: message.title,
9129
+ body: message.body,
9130
+ deviceId: panel.deviceId
9131
+ });
9132
+ }
8691
9133
  /** Append one evaluation to the serialized chain (see {@link evalChain}). */
8692
9134
  scheduleEvaluation(subject, kind, logContext) {
8693
9135
  this.evalChain = this.evalChain.then(async () => {
@@ -22666,6 +23108,17 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
22666
23108
  * `notification-rules` provider (CRUD over the central store) is
22667
23109
  * registered from every node. */
22668
23110
  notificationCenter = null;
23111
+ /**
23112
+ * The live alarm panel on THIS node, or null.
23113
+ *
23114
+ * Held, where the earlier note said not to. The objection then was a field
23115
+ * nothing reads growing into a second source of truth for which device the
23116
+ * alarm is — and it stands: the panel is still addressed by stable id
23117
+ * everywhere else. This reference exists because two things now READ it, and
23118
+ * neither can go through the device id: the settings tab, which needs the
23119
+ * panel's own config, and the arm announcement, which the panel pushes.
23120
+ */
23121
+ alarmPanelDevice = null;
22669
23122
  /** THE single owner of capture pressure (S3, refactor spec 2026-07-22):
22670
23123
  * every native-fetch call site routed so far (the per-frame batch media
22671
23124
  * dispatch + `persistKeyFrames`; part B routes the rest) goes through ONE
@@ -22956,14 +23409,18 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
22956
23409
  return found === void 0 ? null : { id: found.id };
22957
23410
  },
22958
23411
  createDevice: async ({ stableId, integrationId, name }) => {
22959
- return { id: (await devices.create(stableId, NC_ALARM_DEVICE_CLASS, {}, null, {
23412
+ const device = await devices.create(stableId, NC_ALARM_DEVICE_CLASS, {}, null, {
22960
23413
  type: NC_ALARM_DEVICE_TYPE,
22961
23414
  name,
22962
23415
  integrationId
22963
- })).id };
23416
+ });
23417
+ this.holdAlarmPanel(device);
23418
+ return { id: device.id };
22964
23419
  },
22965
23420
  adoptDevice: async ({ stableId }) => {
22966
- return { id: (await devices.create(stableId, NC_ALARM_DEVICE_CLASS, {}, null, void 0)).id };
23421
+ const device = await devices.create(stableId, NC_ALARM_DEVICE_CLASS, {}, null, void 0);
23422
+ this.holdAlarmPanel(device);
23423
+ return { id: device.id };
22967
23424
  }
22968
23425
  });
22969
23426
  if (result.created) this.ctx.logger.info("notification-center alarm panel ready", { tags: { deviceId: result.deviceId } });
@@ -22971,6 +23428,41 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
22971
23428
  this.ctx.logger.warn("alarm panel could not be ensured — rules still notify", { meta: { error: err instanceof Error ? err.message : String(err) } });
22972
23429
  }
22973
23430
  }
23431
+ /**
23432
+ * Keep the constructed panel, if that is what came back.
23433
+ *
23434
+ * An `instanceof` narrowing rather than a cast: `devices.create` promises an
23435
+ * `IDevice`, and the only honest way to know it is the alarm is to ask. A
23436
+ * cast here would compile against any future device class and fail at the
23437
+ * first `settings()` call, in production, with the tab already open.
23438
+ */
23439
+ holdAlarmPanel(device) {
23440
+ if (!(device instanceof NcAlarmPanelDevice)) {
23441
+ this.ctx.logger.warn("alarm panel is not the expected device class — settings tab disabled");
23442
+ return;
23443
+ }
23444
+ this.alarmPanelDevice = device;
23445
+ }
23446
+ /**
23447
+ * Join the panel and the Notification Center, once both exist.
23448
+ *
23449
+ * They are built in that order (`ensureAlarmPanel` runs before the centre is
23450
+ * constructed) and neither can reach the other on its own, so the join is
23451
+ * here. A node with no panel simply never calls it, and the cap answers
23452
+ * `deviceId: null` — which is what an agent node should say.
23453
+ */
23454
+ wireAlarmPanel(center) {
23455
+ const panel = this.alarmPanelDevice;
23456
+ if (panel === null) return;
23457
+ center.setAlarmPanel({
23458
+ deviceId: panel.id,
23459
+ settings: () => panel.settings(),
23460
+ applySettings: (patch) => panel.applySettings(patch),
23461
+ availableModes: () => NC_ALARM_MODES
23462
+ });
23463
+ panel.setArmedHook((mode) => center.announceArm(mode));
23464
+ this.ctx.logger.info("alarm panel wired to the notification center", { tags: { deviceId: panel.id } });
23465
+ }
22974
23466
  async declareCollections(api) {
22975
23467
  await TrackStore.declare(api.settingsStore);
22976
23468
  await MediaStore.declare(api.settingsStore);
@@ -22995,7 +23487,7 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
22995
23487
  let storage = this.ctx.kernel.storage;
22996
23488
  const mediaRoot = process.env.CAMSTACK_MEDIA_ROOT?.trim();
22997
23489
  if (mediaRoot) {
22998
- const { FilesystemStorageProvider } = await Promise.resolve().then(() => require("../node-B4EfMwxa.js"));
23490
+ const { FilesystemStorageProvider } = await Promise.resolve().then(() => require("../node-BNgGpVnp.js"));
22999
23491
  storage = new FilesystemStorageProvider(mediaRoot, { eventMedia: mediaRoot });
23000
23492
  logger.info("pipeline-analytics: event media rooted at CAMSTACK_MEDIA_ROOT", { meta: { mediaRoot } });
23001
23493
  }
@@ -23456,6 +23948,7 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
23456
23948
  })).tracks;
23457
23949
  }
23458
23950
  });
23951
+ this.wireAlarmPanel(this.notificationCenter);
23459
23952
  await this.serveNcActionPlane(this.notificationCenter);
23460
23953
  await this.notificationCenter.start({ evaluation: this.isPostProcessingNode });
23461
23954
  }