@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.
@@ -1,4 +1,4 @@
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-Bjhp9nkX.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";
@@ -544,7 +544,132 @@ function mimeFromExtension(file) {
544
544
  return "application/octet-stream";
545
545
  }
546
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
547
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
+ };
548
673
  var DISARMED = {
549
674
  target: "disarmed",
550
675
  fired: false,
@@ -561,6 +686,7 @@ function armedState(mode) {
561
686
  * answer is still correct the moment anyone asks.
562
687
  */
563
688
  function stateAt(m, now) {
689
+ if (m.clearAt !== void 0 && now >= m.clearAt) return m.target;
564
690
  if (m.fired) return "triggered";
565
691
  if (m.triggerAt !== void 0) return now >= m.triggerAt ? "triggered" : "pending";
566
692
  if (m.armedAt !== void 0) return now >= m.armedAt ? m.target : "arming";
@@ -614,19 +740,24 @@ function disarm(now) {
614
740
  * intruder walking past two sensors would otherwise postpone the alarm.
615
741
  */
616
742
  function trigger(m, delays, now) {
743
+ const state = stateAt(m, now);
744
+ if (state === "triggered" || state === "pending") return m;
617
745
  if (!isArmed(m, now)) return m;
618
- if (m.fired || m.triggerAt !== void 0) return m;
619
- if (delays.entryDelaySec <= 0) return {
620
- ...m,
621
- fired: true,
622
- changedAt: now
623
- };
746
+ const base = settle(m, now);
747
+ const firesAt = now + Math.max(0, delays.entryDelaySec) * 1e3;
624
748
  return {
625
- ...m,
626
- triggerAt: now + delays.entryDelaySec * 1e3,
749
+ ...base,
750
+ triggerAt: firesAt,
751
+ ...autoClear(delays, firesAt),
752
+ fired: false,
627
753
  changedAt: now
628
754
  };
629
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
+ }
630
761
  /**
631
762
  * Settle the machine so a stored value never depends on when it is read.
632
763
  *
@@ -635,10 +766,16 @@ function trigger(m, delays, now) {
635
766
  * than only in the reading of it.
636
767
  */
637
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
+ };
638
774
  if (m.triggerAt !== void 0 && now >= m.triggerAt) return {
639
775
  target: m.target,
640
776
  fired: true,
641
- changedAt: m.triggerAt
777
+ changedAt: m.triggerAt,
778
+ ...m.clearAt !== void 0 ? { clearAt: m.clearAt } : {}
642
779
  };
643
780
  if (m.armedAt !== void 0 && now >= m.armedAt) return {
644
781
  target: m.target,
@@ -667,21 +804,12 @@ function settle(m, now) {
667
804
  * and re-publish when a delay elapses.
668
805
  */
669
806
  /** Every arm mode a camstack-owned panel offers. */
670
- var MODES = [
807
+ var NC_ALARM_MODES = [
671
808
  "home",
672
809
  "away",
673
810
  "night"
674
811
  ];
675
812
  /**
676
- * Defaults an operator can change per panel. `home` has no exit delay on
677
- * purpose — nobody is leaving — but the shared value is applied to every mode
678
- * until per-mode delays are asked for.
679
- */
680
- var DEFAULT_DELAYS = {
681
- exitDelaySec: 30,
682
- entryDelaySec: 20
683
- };
684
- /**
685
813
  * How often the panel re-publishes while a delay is running.
686
814
  *
687
815
  * The state is computed from an INSTANT, so this tick only decides how quickly
@@ -697,30 +825,94 @@ var TICK_MS = 1e3;
697
825
  var ncAlarmPanelSchema = object({
698
826
  exitDelaySec: number().int().min(0).max(600).default(DEFAULT_DELAYS.exitDelaySec),
699
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([]),
700
833
  machine: record(string(), unknown()).optional()
701
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
+ }
702
869
  var NcAlarmPanelDevice = class extends BaseDevice {
703
870
  features = [];
704
871
  machine = DISARMED;
705
872
  delays = DEFAULT_DELAYS;
706
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;
707
881
  constructor(ctx) {
708
882
  super(ctx, ncAlarmPanelSchema, { type: ctx.deviceMeta.type });
709
- this.delays = {
710
- exitDelaySec: this.config.get("exitDelaySec"),
711
- entryDelaySec: this.config.get("entryDelaySec")
712
- };
883
+ this.delays = this.readDelays();
713
884
  this.restore();
714
885
  this.registerAlarmCap();
715
886
  this.publish();
716
887
  }
717
- /** Fire a trigger at the panel. Called by the notification center when a rule
718
- * that arms the alarm matches. Ignored unless the panel is armed. */
719
- onRuleTriggered(now = Date.now()) {
720
- const next = trigger(this.machine, this.delays, now);
721
- if (next === this.machine) return;
722
- this.machine = next;
723
- 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;
724
916
  }
725
917
  /** Current lifecycle word — what the `deviceState` gate compares against. */
726
918
  currentState(now = Date.now()) {
@@ -743,19 +935,24 @@ var NcAlarmPanelDevice = class extends BaseDevice {
743
935
  this.publish();
744
936
  },
745
937
  trigger: async () => {
746
- this.machine = {
747
- ...this.machine,
748
- fired: true,
749
- changedAt: Date.now()
750
- };
751
- 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);
752
949
  }
753
950
  });
754
951
  }
755
952
  status(now = Date.now()) {
756
953
  return {
757
954
  state: stateAt(this.machine, now),
758
- availableModes: [...MODES],
955
+ availableModes: [...NC_ALARM_MODES],
759
956
  requiresCode: false,
760
957
  lastChangedAt: this.machine.changedAt
761
958
  };
@@ -769,11 +966,48 @@ var NcAlarmPanelDevice = class extends BaseDevice {
769
966
  */
770
967
  publish(now = Date.now()) {
771
968
  this.machine = settle(this.machine, now);
969
+ const state = stateAt(this.machine, now);
772
970
  this.runtimeState.setCapState("alarm-panel", this.status(now));
773
971
  this.persist();
774
- 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();
775
974
  else this.stopTicking();
776
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
+ }
777
1011
  startTicking() {
778
1012
  if (this.ticker !== null) return;
779
1013
  this.ticker = setInterval(() => this.publish(), TICK_MS);
@@ -790,17 +1024,9 @@ var NcAlarmPanelDevice = class extends BaseDevice {
790
1024
  this.config.set("machine", { ...this.machine });
791
1025
  }
792
1026
  restore() {
793
- const raw = this.config.get("machine");
794
- if (raw === null || typeof raw !== "object") return;
795
- const m = raw;
796
- if (typeof m.target !== "string" || typeof m.changedAt !== "number") return;
797
- this.machine = {
798
- target: m.target,
799
- ...typeof m.armedAt === "number" ? { armedAt: m.armedAt } : {},
800
- ...typeof m.triggerAt === "number" ? { triggerAt: m.triggerAt } : {},
801
- fired: m.fired === true,
802
- changedAt: m.changedAt
803
- };
1027
+ const restored = machineFromPersisted(this.config.get("machine"));
1028
+ if (restored === null) return;
1029
+ this.machine = restored;
804
1030
  }
805
1031
  };
806
1032
  //#endregion
@@ -5068,14 +5294,21 @@ function readSensorEventType(value) {
5068
5294
  const eventType = last["eventType"];
5069
5295
  return typeof eventType === "string" && eventType.length > 0 ? eventType : void 0;
5070
5296
  }
5071
- /** Build the subject for a `device-event` evaluation (a persisted SensorEvent —
5072
- * 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
+ */
5073
5305
  function subjectFromSensorEvent(ev) {
5074
5306
  const eventType = readSensorEventType(ev.value);
5075
5307
  return {
5076
5308
  kind: "device-event",
5077
5309
  recordId: ev.id,
5078
5310
  deviceId: ev.deviceId,
5311
+ ...ev.sourceDeviceId !== ev.deviceId ? { sourceDeviceId: ev.sourceDeviceId } : {},
5079
5312
  timestamp: ev.timestamp,
5080
5313
  classNames: [],
5081
5314
  zones: [],
@@ -5307,7 +5540,7 @@ function evaluateRule(rule, subject, deviceState) {
5307
5540
  if (current === void 0) return fail("deviceState");
5308
5541
  if (!toLowerSet(c.deviceState.states).has(current.trim().toLowerCase())) return fail("deviceState");
5309
5542
  }
5310
- 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");
5311
5544
  if (c.source !== void 0 && c.source !== "any") {
5312
5545
  if ((subject.source ?? "pipeline") !== c.source) return fail("source");
5313
5546
  }
@@ -5494,6 +5727,28 @@ function keysPerClass(rule, subject) {
5494
5727
  return rule.throttle.granularity === "per-class";
5495
5728
  }
5496
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
+ }
5497
5752
  function cooldownKey(rule, subject) {
5498
5753
  const first = subject.classNames[0];
5499
5754
  const classKey = keysPerClass(rule, subject) && first !== void 0 ? `:c:${first}` : "";
@@ -5768,6 +6023,82 @@ var NcDispatcher = class {
5768
6023
  return false;
5769
6024
  }
5770
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
+ }
5771
6102
  /** Device display names for a set of ids, for the digest's lines. */
5772
6103
  async resolveDeviceNames(deviceIds) {
5773
6104
  const out = /* @__PURE__ */ new Map();
@@ -8124,6 +8455,19 @@ var TimelapseStore = class {
8124
8455
  * CRUD served elsewhere becomes effective within one rule-reload tick.
8125
8456
  */
8126
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
+ /**
8127
8471
  * How often the "matched NO rule" report may fire per device. Long enough that
8128
8472
  * a busy camera prints one line rather than one per event, short enough that a
8129
8473
  * rule which has stopped matching is visible within minutes rather than by
@@ -8249,6 +8593,8 @@ var NotificationCenter = class NotificationCenter {
8249
8593
  /** True when ≥1 enabled `device-event` rule declares an occupancy condition —
8250
8594
  * the watcher is idle (zero per-frame cost) otherwise. */
8251
8595
  occupancyEnabled = false;
8596
+ /** The panel this node owns, or null. See {@link NcAlarmPanelPort}. */
8597
+ alarmPanel = null;
8252
8598
  /** In-memory cooldown map — seeded from persisted outbox rows on start. */
8253
8599
  lastFiredAt = /* @__PURE__ */ new Map();
8254
8600
  /** Per-device rate limit for the "matched NO rule" report — see `reportNoMatch`. */
@@ -8679,9 +9025,105 @@ var NotificationCenter = class NotificationCenter {
8679
9025
  cancelSnooze: async ({ snoozeId, caller }) => {
8680
9026
  await this.cancelSnooze(snoozeId, caller);
8681
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();
8682
9045
  }
8683
9046
  };
8684
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
+ }
8685
9127
  /** Append one evaluation to the serialized chain (see {@link evalChain}). */
8686
9128
  scheduleEvaluation(subject, kind, logContext) {
8687
9129
  this.evalChain = this.evalChain.then(async () => {
@@ -22660,6 +23102,17 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
22660
23102
  * `notification-rules` provider (CRUD over the central store) is
22661
23103
  * registered from every node. */
22662
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;
22663
23116
  /** THE single owner of capture pressure (S3, refactor spec 2026-07-22):
22664
23117
  * every native-fetch call site routed so far (the per-frame batch media
22665
23118
  * dispatch + `persistKeyFrames`; part B routes the rest) goes through ONE
@@ -22950,14 +23403,18 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
22950
23403
  return found === void 0 ? null : { id: found.id };
22951
23404
  },
22952
23405
  createDevice: async ({ stableId, integrationId, name }) => {
22953
- return { id: (await devices.create(stableId, NC_ALARM_DEVICE_CLASS, {}, null, {
23406
+ const device = await devices.create(stableId, NC_ALARM_DEVICE_CLASS, {}, null, {
22954
23407
  type: NC_ALARM_DEVICE_TYPE,
22955
23408
  name,
22956
23409
  integrationId
22957
- })).id };
23410
+ });
23411
+ this.holdAlarmPanel(device);
23412
+ return { id: device.id };
22958
23413
  },
22959
23414
  adoptDevice: async ({ stableId }) => {
22960
- 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 };
22961
23418
  }
22962
23419
  });
22963
23420
  if (result.created) this.ctx.logger.info("notification-center alarm panel ready", { tags: { deviceId: result.deviceId } });
@@ -22965,6 +23422,41 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
22965
23422
  this.ctx.logger.warn("alarm panel could not be ensured — rules still notify", { meta: { error: err instanceof Error ? err.message : String(err) } });
22966
23423
  }
22967
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
+ }
22968
23460
  async declareCollections(api) {
22969
23461
  await TrackStore.declare(api.settingsStore);
22970
23462
  await MediaStore.declare(api.settingsStore);
@@ -23450,6 +23942,7 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
23450
23942
  })).tracks;
23451
23943
  }
23452
23944
  });
23945
+ this.wireAlarmPanel(this.notificationCenter);
23453
23946
  await this.serveNcActionPlane(this.notificationCenter);
23454
23947
  await this.notificationCenter.start({ evaluation: this.isPostProcessingNode });
23455
23948
  }