@camstack/addon-post-analysis 1.2.69 → 1.2.70

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-BF27UAVH.js");
5
+ const require_dist = require("../dist-DnpVHUcH.js");
6
6
  let node_fs = require("node:fs");
7
7
  node_fs = require_dist.__toESM(node_fs, 1);
8
8
  let node_path = require("node:path");
@@ -344,6 +344,22 @@ function stateAt(m, now) {
344
344
  if (m.armedAt !== void 0) return now >= m.armedAt ? m.target : "arming";
345
345
  return m.target;
346
346
  }
347
+ /**
348
+ * The exit delay running at `now`, or `undefined` when none is.
349
+ *
350
+ * Gated on `stateAt`, not on `armedAt` alone: the instant survives on the
351
+ * machine after the delay is over (until a settle drops it) and after a trigger
352
+ * has opened a DIFFERENT countdown, and either would announce a countdown for a
353
+ * panel that is already armed or already about to go off.
354
+ */
355
+ function exitDelayAt(m, now) {
356
+ if (m.armedAt === void 0) return void 0;
357
+ if (stateAt(m, now) !== "arming") return void 0;
358
+ return {
359
+ target: m.target,
360
+ armedAt: m.armedAt
361
+ };
362
+ }
347
363
  /** Whether the panel is armed enough for a trigger to matter. */
348
364
  function isArmed(m, now) {
349
365
  const s = stateAt(m, now);
@@ -524,6 +540,21 @@ function skippedFor(deviceIds, skipProbe) {
524
540
  return out;
525
541
  }
526
542
  /**
543
+ * Whether this mode arms anything, and if not, which way it fails.
544
+ *
545
+ * An `allDevices` mode is never a gap: `skippedDevices` only ever probes the
546
+ * ids a rule wrote down, so it cannot answer for a mode that covers every
547
+ * camera on the hub, and reporting "all excluded" from a subset would be a
548
+ * warning about protection that exists.
549
+ */
550
+ function alarmCoverageGap(coverage) {
551
+ if (coverage.ruleCount === 0) return "uncovered";
552
+ if (coverage.allDevices) return null;
553
+ if (coverage.deviceIds.length === 0) return null;
554
+ if (coverage.skippedDevices.length < coverage.deviceIds.length) return null;
555
+ return "all-skipped";
556
+ }
557
+ /**
527
558
  * "Away armed — 3 cameras: Front door, Garage, Gate."
528
559
  *
529
560
  * Names, not ids: this is read by a person standing at a door. An id that has
@@ -532,14 +563,36 @@ function skippedFor(deviceIds, skipProbe) {
532
563
  * the one error this message must not make. (It used to hard-code the English
533
564
  * `Device <id>` into both languages.)
534
565
  *
535
- * Returns null when NO rule is gated on the mode. A notification saying "Night
536
- * armed" that is followed by nothing happening all night is worse than no
537
- * notification: it is a false assurance. The caller still logs the arm.
566
+ * **Always a sentence.** It used to return null for a mode no rule is gated on,
567
+ * on the grounds that "Night armed" followed by an unwatched night is a false
568
+ * assurance — true, but the caller then emitted `alarm-armed` anyway with a
569
+ * fallback body key that existed in NEITHER locale, so the operator received a
570
+ * title and an empty line. Silence is not the third option here: SAYING that
571
+ * the mode arms nothing is.
538
572
  */
539
- function buildArmAnnouncement(coverage, deviceName) {
540
- if (coverage.ruleCount === 0) return null;
573
+ function buildArmAnnouncement(coverage, deviceName, bypassed = []) {
541
574
  const rules = `${coverage.ruleCount}`;
542
575
  const mode = coverage.mode;
576
+ if (bypassed.length > 0) return armedWithBypass(coverage, bypassed, deviceName);
577
+ const gap = alarmCoverageGap(coverage);
578
+ if (gap === "uncovered") return {
579
+ bodyKey: "alarm.arm.body.uncovered",
580
+ textParams: { mode },
581
+ textCount: 0
582
+ };
583
+ if (gap === "all-skipped") {
584
+ const names = coverage.skippedDevices.map((s) => deviceName(s.deviceId));
585
+ return {
586
+ bodyKey: "alarm.arm.body.allSkipped",
587
+ textParams: {
588
+ rules,
589
+ mode,
590
+ devices: `${names.length}`,
591
+ names: names.join(", ")
592
+ },
593
+ textCount: names.length
594
+ };
595
+ }
543
596
  if (coverage.allDevices) return {
544
597
  bodyKey: "alarm.arm.body.allDevices",
545
598
  textParams: {
@@ -556,8 +609,9 @@ function buildArmAnnouncement(coverage, deviceName) {
556
609
  },
557
610
  textCount: coverage.ruleCount
558
611
  };
559
- const names = coverage.deviceIds.map(deviceName);
560
- return {
612
+ const skipped = new Set(coverage.skippedDevices.map((s) => s.deviceId));
613
+ const names = coverage.deviceIds.filter((id) => !skipped.has(id)).map(deviceName);
614
+ if (skipped.size === 0) return {
561
615
  bodyKey: "alarm.arm.body.devices",
562
616
  textParams: {
563
617
  rules,
@@ -567,8 +621,321 @@ function buildArmAnnouncement(coverage, deviceName) {
567
621
  },
568
622
  textCount: names.length
569
623
  };
624
+ return {
625
+ bodyKey: "alarm.arm.body.devicesSkipped",
626
+ textParams: {
627
+ rules,
628
+ mode,
629
+ devices: `${names.length}`,
630
+ names: names.join(", "),
631
+ skippedNames: coverage.skippedDevices.map((s) => deviceName(s.deviceId)).join(", ")
632
+ },
633
+ textCount: names.length
634
+ };
570
635
  }
571
636
  /**
637
+ * "Home armed — with the veranda window open, excluded."
638
+ *
639
+ * The bypassed sensors are named and the armed ones are COUNTED, not listed:
640
+ * this sentence's subject is the exception, and burying it under thirteen
641
+ * camera names is how an operator stops reading the arm notification. The
642
+ * count pluralises on what IS armed, like every other arm variant.
643
+ */
644
+ function armedWithBypass(coverage, bypassed, deviceName) {
645
+ const skipped = new Set(coverage.skippedDevices.map((s) => s.deviceId));
646
+ const excluded = new Set(bypassed);
647
+ const armed = coverage.allDevices ? coverage.ruleCount : coverage.deviceIds.filter((id) => !skipped.has(id) && !excluded.has(id)).length;
648
+ return {
649
+ bodyKey: "alarm.arm.body.bypassed",
650
+ textParams: {
651
+ rules: `${coverage.ruleCount}`,
652
+ mode: coverage.mode,
653
+ devices: `${armed}`,
654
+ bypassedNames: bypassed.map(deviceName).join(", ")
655
+ },
656
+ textCount: armed
657
+ };
658
+ }
659
+ /**
660
+ * "The alarm was NOT armed: the veranda window is open."
661
+ *
662
+ * The one alarm sentence about something that did not happen, so it has to
663
+ * carry both halves an operator needs to act: WHICH sensors are open, and that
664
+ * pressing arm again arms without them. Naming them is the whole message — a
665
+ * refusal that says only "cannot arm" is a panel the operator will conclude is
666
+ * broken.
667
+ */
668
+ function buildArmRefusedAnnouncement(mode, names, retrySeconds) {
669
+ return {
670
+ bodyKey: "alarm.armRefused.body",
671
+ textParams: {
672
+ mode,
673
+ devices: `${names.length}`,
674
+ names: names.join(", "),
675
+ seconds: `${retrySeconds}`
676
+ },
677
+ textCount: names.length
678
+ };
679
+ }
680
+ /**
681
+ * The same coverage, said 30 seconds earlier: "Armed in 30 seconds."
682
+ *
683
+ * The exit-delay sentence is deliberately SHORTER than the arm one — it is read
684
+ * on the way out of a door — and it carries exactly one thing the arm sentence
685
+ * cannot: the warning arrives while the operator can still disarm and fix it.
686
+ * A mode that arms nothing is worth interrupting somebody for at second 0 and
687
+ * merely worth recording at second 30.
688
+ *
689
+ * Pluralises on the SECONDS, not on the devices: this sentence's only number is
690
+ * the countdown, and Italian needs "1 secondo" / "30 secondi".
691
+ */
692
+ function buildArmingAnnouncement(coverage, seconds, deviceName, bypassed = []) {
693
+ const textParams = {
694
+ mode: coverage.mode,
695
+ seconds: `${seconds}`
696
+ };
697
+ if (bypassed.length > 0) return {
698
+ bodyKey: "alarm.arming.body.bypassed",
699
+ textParams: {
700
+ ...textParams,
701
+ bypassedNames: bypassed.map(deviceName).join(", ")
702
+ },
703
+ textCount: seconds
704
+ };
705
+ const gap = alarmCoverageGap(coverage);
706
+ if (gap === "uncovered") return {
707
+ bodyKey: "alarm.arming.body.uncovered",
708
+ textParams,
709
+ textCount: seconds
710
+ };
711
+ if (gap === "all-skipped") return {
712
+ bodyKey: "alarm.arming.body.allSkipped",
713
+ textParams: {
714
+ ...textParams,
715
+ names: coverage.skippedDevices.map((s) => deviceName(s.deviceId)).join(", ")
716
+ },
717
+ textCount: seconds
718
+ };
719
+ return {
720
+ bodyKey: "alarm.arming.body",
721
+ textParams,
722
+ textCount: seconds
723
+ };
724
+ }
725
+ //#endregion
726
+ //#region src/notification-center/alarm/alarm-arm-guard.ts
727
+ /**
728
+ * How long a refusal stays confirmable.
729
+ *
730
+ * Long enough to read the notification and press again (the operator is
731
+ * standing at the door, phone in hand); short enough that it cannot be the
732
+ * state the panel is left in. Sixty seconds is also under the shortest exit
733
+ * delay this panel ships with, so a bypass can never outlive the arm it
734
+ * belongs to.
735
+ */
736
+ var ARM_BYPASS_WINDOW_MS = 6e4;
737
+ /** The taxonomy kind a door/window contact persists its events under. */
738
+ var CONTACT_SENSOR_KIND = "contact";
739
+ /**
740
+ * The contact sensors a mode would arm, ascending and de-duplicated.
741
+ *
742
+ * A rule qualifies only when it is enabled, gated on THIS panel in THIS mode,
743
+ * and about contacts (`sensorKinds` naming `contact`). Two exclusions are
744
+ * deliberate and both are about refusing an arm the operator cannot fix:
745
+ *
746
+ * - a rule that names CAMERAS (`Intrusione — Fuori casa` names thirteen) is
747
+ * not a contact rule, and a camera is never open;
748
+ * - a contact rule with NO device scope covers every contact on the hub, and
749
+ * blocking on a set the operator never wrote down produces a refusal naming
750
+ * devices they never associated with the alarm. The caller says so in a log
751
+ * line rather than inventing the list.
752
+ */
753
+ function alarmBlockingCandidates(rules, panelDeviceId, mode) {
754
+ const wanted = armedStateFor(mode);
755
+ const ids = /* @__PURE__ */ new Set();
756
+ for (const rule of rules) {
757
+ if (!rule.enabled) continue;
758
+ const gate = rule.conditions.deviceState;
759
+ if (gate === void 0 || gate.deviceId !== panelDeviceId) continue;
760
+ if (!gate.states.includes(wanted)) continue;
761
+ const kinds = rule.conditions.sensorKinds;
762
+ if (kinds === void 0 || !kinds.includes(CONTACT_SENSOR_KIND)) continue;
763
+ const scope = rule.conditions.devices;
764
+ if (scope === void 0 || scope.length === 0) continue;
765
+ for (const id of scope) ids.add(id);
766
+ }
767
+ return [...ids].toSorted((a, b) => a - b);
768
+ }
769
+ /**
770
+ * Every contact any mode of this panel could block on — the set whose state is
771
+ * worth keeping mirrored.
772
+ *
773
+ * Read on the rule-reload tick, so the arm path has a recent answer even when
774
+ * the targeted read at arm time fails, and so an excluded sensor's close can be
775
+ * noticed on a hub where the sensor is linked to no camera (and therefore emits
776
+ * no device event at all — the live installation on 2026-08-13).
777
+ */
778
+ function alarmContactWatchList(rules, panelDeviceId, modes) {
779
+ const ids = /* @__PURE__ */ new Set();
780
+ for (const mode of modes) for (const id of alarmBlockingCandidates(rules, panelDeviceId, mode)) ids.add(id);
781
+ return [...ids].toSorted((a, b) => a - b);
782
+ }
783
+ /**
784
+ * Refuse, bypass or proceed — the whole decision, as one comparison.
785
+ *
786
+ * The bypass requires the SAME mode. "Arm home" refused and "arm away" pressed
787
+ * is a different intent about a different set of sensors, and letting one
788
+ * confirm the other would arm a mode around a window nobody was told about.
789
+ *
790
+ * The bypassed set is what is open NOW, not what was open at the refusal: a
791
+ * window shut between the two presses is armed with everything else, which is
792
+ * the entire point of having refused.
793
+ */
794
+ function decideArm(input) {
795
+ const window = input.windowMs ?? 6e4;
796
+ if (input.openContacts.length === 0) return { kind: "clear" };
797
+ const previous = input.previous;
798
+ if (previous !== null && previous.mode === input.mode && input.at - previous.at <= window) return {
799
+ kind: "bypassed",
800
+ bypassed: input.openContacts
801
+ };
802
+ return {
803
+ kind: "refused",
804
+ blocking: input.openContacts,
805
+ retryUntil: input.at + window
806
+ };
807
+ }
808
+ /**
809
+ * The error `alarmPanel.arm` throws when it refuses.
810
+ *
811
+ * A THROW and not a return value, and the reason is a train: the hub's
812
+ * generated router validates this method's response against the HOST's copy of
813
+ * the cap, which says `z.void()`. An addon that answered a structured refusal
814
+ * would have it rejected by the router in front of it — so the refusal reaches
815
+ * the caller as the one channel that already works across that boundary, and
816
+ * the operator's PHONE gets the sentence (`alarm-arm-refused`, through the
817
+ * ordinary outbox) rather than a UI error being the only trace.
818
+ *
819
+ * The message is deliberately actionable and English (an exception message is
820
+ * a developer/API surface; the notification is the operator's).
821
+ */
822
+ var NcAlarmArmRefusedError = class extends Error {
823
+ code = "alarm-arm-refused";
824
+ mode;
825
+ blocking;
826
+ retryUntil;
827
+ constructor(input) {
828
+ const names = input.blocking.map((c) => c.name).join(", ");
829
+ const seconds = Math.round((input.windowMs ?? 6e4) / 1e3);
830
+ super(`arm refused — open: ${names}. Arm ${input.mode} again within ${String(seconds)}s to arm with them excluded.`);
831
+ this.name = "NcAlarmArmRefusedError";
832
+ this.mode = input.mode;
833
+ this.blocking = input.blocking;
834
+ this.retryUntil = input.retryUntil;
835
+ }
836
+ };
837
+ //#endregion
838
+ //#region src/notification-center/alarm/alarm-exclusions.ts
839
+ function isPhase(value) {
840
+ return value === "open" || value === "closed";
841
+ }
842
+ var NcAlarmExclusions = class {
843
+ entries = [];
844
+ /** Replace the whole set — one arm, one set of exclusions. */
845
+ exclude(deviceIds, at) {
846
+ this.entries = [...new Set(deviceIds)].map((deviceId) => ({
847
+ deviceId,
848
+ phase: "open",
849
+ at
850
+ }));
851
+ }
852
+ /** Every excluded sensor, in the order it was excluded. */
853
+ list() {
854
+ return this.entries.map((e) => e.deviceId);
855
+ }
856
+ /** True while this sensor's events must not reach the panel. */
857
+ blocks(deviceId) {
858
+ return this.entries.some((e) => e.deviceId === deviceId);
859
+ }
860
+ /**
861
+ * Feed one contact reading, and say what it CHANGED.
862
+ *
863
+ * Three answers, not two, because the caller has to persist on both of the
864
+ * changes: the first `alarm-arm-refusal.spec.ts` run of this feature saved
865
+ * only the re-arm, so a hub redeployed between the close and the re-open came
866
+ * back with the sensor still in phase `open` — and the exclusion then needed
867
+ * a SECOND close to lift. A phase that lives only in RAM is half a durable
868
+ * exclusion.
869
+ *
870
+ * An unreadable state changes nothing (D49): a failed read must never lift an
871
+ * exclusion the operator asked for, and must never swallow the close that
872
+ * would have.
873
+ */
874
+ observe(deviceId, reading, _at) {
875
+ if (reading === void 0) return "unchanged";
876
+ const current = this.entries.find((e) => e.deviceId === deviceId);
877
+ if (current === void 0) return "unchanged";
878
+ if (reading === "closed") {
879
+ if (current.phase === "closed") return "unchanged";
880
+ this.entries = this.entries.map((e) => e.deviceId === deviceId ? {
881
+ ...e,
882
+ phase: "closed"
883
+ } : e);
884
+ return "closed";
885
+ }
886
+ if (current.phase === "open") return "unchanged";
887
+ this.entries = this.entries.filter((e) => e.deviceId !== deviceId);
888
+ return "rearmed";
889
+ }
890
+ /** The arm this belongs to is over. */
891
+ clear() {
892
+ this.entries = [];
893
+ }
894
+ /** What to persist. Plain data — it rides the panel's config blob. */
895
+ snapshot() {
896
+ return this.entries.map((e) => ({
897
+ deviceId: e.deviceId,
898
+ phase: e.phase,
899
+ at: e.at
900
+ }));
901
+ }
902
+ /**
903
+ * Rebuild from the persisted blob — a GUARD, never a cast.
904
+ *
905
+ * Anything unrecognised restores NOTHING, which is the failure that shows
906
+ * itself (a bypassed sensor that fires) rather than the one that hides (a
907
+ * sensor excluded forever by a blob nobody can read). A bare id with no phase
908
+ * restores as still-OPEN: it needs a close before it can re-arm, which is the
909
+ * half that cannot cause a false alarm.
910
+ */
911
+ restore(raw) {
912
+ if (!Array.isArray(raw)) return;
913
+ const out = [];
914
+ for (const item of raw) {
915
+ if (typeof item === "number" && Number.isInteger(item)) {
916
+ out.push({
917
+ deviceId: item,
918
+ phase: "open",
919
+ at: 0
920
+ });
921
+ continue;
922
+ }
923
+ if (item === null || typeof item !== "object") continue;
924
+ const rec = { ...item };
925
+ const deviceId = rec["deviceId"];
926
+ if (typeof deviceId !== "number" || !Number.isInteger(deviceId)) continue;
927
+ const phase = isPhase(rec["phase"]) ? rec["phase"] : "open";
928
+ const at = typeof rec["at"] === "number" ? rec["at"] : 0;
929
+ out.push({
930
+ deviceId,
931
+ phase,
932
+ at
933
+ });
934
+ }
935
+ this.entries = out;
936
+ }
937
+ };
938
+ /**
572
939
  * WHO the panel's current countdown belongs to.
573
940
  *
574
941
  * Small and separate because its whole content is a LIFETIME, and a lifetime
@@ -591,6 +958,26 @@ var NcAlarmAttribution = class {
591
958
  };
592
959
  }
593
960
  /**
961
+ * The note that has not been claimed yet — WITHOUT consuming it.
962
+ *
963
+ * The panel has to know which SENSOR is about to set the alarm off before it
964
+ * decides whether to accept the trigger at all (an excluded contact does
965
+ * not), and asking must not spend the note: a trigger that is accepted still
966
+ * needs it to build the combined notification. Expired reads as absent, on
967
+ * the same bound {@link claim} uses.
968
+ */
969
+ pending(now) {
970
+ const noted = this.noted;
971
+ if (noted === null) return null;
972
+ if (now - noted.at > 3e4) return null;
973
+ return noted.source;
974
+ }
975
+ /** Throw the un-claimed note away — the trigger it described was REFUSED, and
976
+ * a note left behind would be inherited by the next real one. */
977
+ dropPending() {
978
+ this.noted = null;
979
+ }
980
+ /**
594
981
  * Called by an ACCEPTED trigger, and by every accepted trigger — including
595
982
  * one with no note, so a manual trigger clears whatever a rule left behind
596
983
  * rather than inheriting it.
@@ -645,6 +1032,7 @@ function alarmTransition(input) {
645
1032
  at,
646
1033
  ...input.source !== void 0 ? { source: input.source } : {}
647
1034
  };
1035
+ if (next === "arming") return armingTransition(previous, input);
648
1036
  if (shouldAnnounceArm(previous, next)) {
649
1037
  const mode = armModeOf(next, input.modes);
650
1038
  if (mode === null) return null;
@@ -664,6 +1052,29 @@ function alarmTransition(input) {
664
1052
  };
665
1053
  return null;
666
1054
  }
1055
+ /**
1056
+ * The exit delay, as one notification at its START.
1057
+ *
1058
+ * Reached only from a state CHANGE into `arming`, so the once-a-second
1059
+ * re-publish is already excluded by the caller's `previous === next` guard —
1060
+ * the countdown is one notification, not thirty. Null when the panel did not
1061
+ * say what it is arming into, or named a mode it does not offer: both would
1062
+ * produce a message with no title and no number in it.
1063
+ */
1064
+ function armingTransition(previous, input) {
1065
+ const arming = input.arming;
1066
+ if (arming === void 0) return null;
1067
+ const mode = armModeOf(arming.target, input.modes);
1068
+ if (mode === null) return null;
1069
+ return {
1070
+ kind: "alarm-arming",
1071
+ state: "arming",
1072
+ previous,
1073
+ mode,
1074
+ seconds: Math.max(0, Math.ceil((arming.armedAt - input.at) / 1e3)),
1075
+ at: input.at
1076
+ };
1077
+ }
667
1078
  //#endregion
668
1079
  //#region src/notification-center/alarm/alarm-panel-device.ts
669
1080
  /**
@@ -710,7 +1121,17 @@ var ncAlarmPanelSchema = require_dist.object({
710
1121
  announceArm: require_dist.boolean().default(false),
711
1122
  /** Target ids that announcement goes to — see `NcAlarmSettingsSchema`. */
712
1123
  announceTargets: require_dist.array(require_dist.string()).default([]),
713
- machine: require_dist.record(require_dist.string(), require_dist.unknown()).optional()
1124
+ machine: require_dist.record(require_dist.string(), require_dist.unknown()).optional(),
1125
+ /**
1126
+ * Sensors the CURRENT arm is ignoring because they were open when the
1127
+ * operator confirmed it, and how far each is through its two-phase lift.
1128
+ *
1129
+ * Persisted for the same reason the machine is: a deploy in the middle of an
1130
+ * armed night would otherwise silently re-arm every sensor the operator
1131
+ * agreed to leave out, and they would learn about it from the siren. Read
1132
+ * back through a guard, never a cast — see `NcAlarmExclusions.restore`.
1133
+ */
1134
+ exclusions: require_dist.array(require_dist.unknown()).default([])
714
1135
  });
715
1136
  /** `armed_home` and friends — the only targets a machine may settle into. */
716
1137
  function isArmTarget(value) {
@@ -760,6 +1181,19 @@ var NcAlarmPanelDevice = class extends require_dist.BaseDevice {
760
1181
  lastPublished = null;
761
1182
  /** WHO the current countdown belongs to — see {@link NcAlarmAttribution}. */
762
1183
  attribution = new NcAlarmAttribution();
1184
+ /** Sensors this arm is ignoring — see {@link NcAlarmExclusions}. */
1185
+ exclusions = new NcAlarmExclusions();
1186
+ /** Who answers "is anything open" — null until the centre wires itself in. */
1187
+ armGuard = null;
1188
+ /**
1189
+ * The arm attempt that was REFUSED, and is therefore confirmable.
1190
+ *
1191
+ * In RAM and only in RAM, deliberately: it is a confirmation of something the
1192
+ * operator read seconds ago, and a confirmation that survived a restart would
1193
+ * let a process respawn arm the alarm around an open window nobody re-read.
1194
+ * Losing it costs one extra press.
1195
+ */
1196
+ lastRefusal = null;
763
1197
  constructor(ctx) {
764
1198
  super(ctx, ncAlarmPanelSchema, { type: ctx.deviceMeta.type });
765
1199
  this.delays = this.readDelays();
@@ -817,26 +1251,164 @@ var NcAlarmPanelDevice = class extends require_dist.BaseDevice {
817
1251
  currentState(now = Date.now()) {
818
1252
  return stateAt(this.machine, now);
819
1253
  }
1254
+ /** Wire the open-contact check and the refusal announcement. */
1255
+ setArmGuard(hook) {
1256
+ this.armGuard = hook;
1257
+ }
1258
+ /**
1259
+ * Sensors the current arm is ignoring — what the armed/arming sentence names
1260
+ * as excluded, and the only place that list is published.
1261
+ */
1262
+ excludedDevices() {
1263
+ return this.exclusions.list();
1264
+ }
1265
+ /**
1266
+ * One contact reading, from whoever saw it — the sensor's own event, or the
1267
+ * centre's reconcile tick.
1268
+ *
1269
+ * The lift is two-phase (`alarm-exclusions.ts`), so this is safe to call with
1270
+ * the same reading repeatedly, and an unreadable state changes nothing. Fed
1271
+ * BEFORE the event that carried it is evaluated, so the open that re-arms a
1272
+ * sensor is already lifted when the rule it fires reaches the panel.
1273
+ */
1274
+ observeContact(deviceId, reading, now = Date.now()) {
1275
+ const change = this.exclusions.observe(deviceId, reading, now);
1276
+ if (change === "unchanged") return;
1277
+ this.persist();
1278
+ if (change === "closed") return;
1279
+ this.ctx.logger.info("alarm exclusion lifted — the sensor closed and opened again", {
1280
+ tags: { deviceId },
1281
+ meta: {
1282
+ panelDeviceId: this.id,
1283
+ remaining: this.exclusions.list().length
1284
+ }
1285
+ });
1286
+ }
820
1287
  /** Stop the countdown when the device goes away. The state itself is
821
1288
  * persisted, so nothing is lost — only the announcements stop. */
822
1289
  stopCountdown() {
823
1290
  this.stopTicking();
824
1291
  }
1292
+ /**
1293
+ * Refuse the arm, bypass around it, or let it through — and leave the
1294
+ * exclusions the arm will run with.
1295
+ *
1296
+ * Fail-OPEN by construction, in three places, and each one is a decision:
1297
+ *
1298
+ * - no guard wired (an agent node, a boot pass before the centre exists) ⇒
1299
+ * arm. A panel that cannot ask must not refuse.
1300
+ * - the guard threw ⇒ arm, and SAY so. Refusing on a failed read would tell
1301
+ * the operator to go and shut a window that is already shut (the coverage
1302
+ * derivation makes the same choice: an unreadable authority answers
1303
+ * "unknown", never "excluded").
1304
+ * - nothing reads open ⇒ arm clean, and any exclusion a previous arm left
1305
+ * is dropped.
1306
+ *
1307
+ * The refusal throws. See {@link NcAlarmArmRefusedError} for why that is the
1308
+ * channel rather than a return value.
1309
+ */
1310
+ async applyArmGuard(mode, now) {
1311
+ const guard = this.armGuard;
1312
+ if (guard === null) return;
1313
+ let openContacts;
1314
+ try {
1315
+ openContacts = await guard.openContacts(mode);
1316
+ } catch (err) {
1317
+ this.ctx.logger.warn("alarm arm guard could not read the contacts — arming anyway", {
1318
+ tags: { deviceId: this.id },
1319
+ meta: {
1320
+ mode,
1321
+ error: String(err)
1322
+ }
1323
+ });
1324
+ return;
1325
+ }
1326
+ const verdict = decideArm({
1327
+ mode,
1328
+ openContacts,
1329
+ previous: this.lastRefusal,
1330
+ at: now
1331
+ });
1332
+ if (verdict.kind === "clear") {
1333
+ this.lastRefusal = null;
1334
+ if (this.exclusions.list().length > 0) {
1335
+ this.exclusions.clear();
1336
+ this.persist();
1337
+ }
1338
+ return;
1339
+ }
1340
+ if (verdict.kind === "bypassed") {
1341
+ this.lastRefusal = null;
1342
+ this.exclusions.exclude(verdict.bypassed.map((c) => c.deviceId), now);
1343
+ this.persist();
1344
+ for (const contact of verdict.bypassed) this.ctx.logger.info("alarm armed with an open sensor excluded", {
1345
+ tags: { deviceId: contact.deviceId },
1346
+ meta: {
1347
+ panelDeviceId: this.id,
1348
+ mode,
1349
+ name: contact.name
1350
+ }
1351
+ });
1352
+ return;
1353
+ }
1354
+ this.lastRefusal = {
1355
+ mode,
1356
+ at: now
1357
+ };
1358
+ for (const contact of verdict.blocking) this.ctx.logger.warn("alarm arm REFUSED — this sensor is open", {
1359
+ tags: { deviceId: contact.deviceId },
1360
+ meta: {
1361
+ panelDeviceId: this.id,
1362
+ mode,
1363
+ name: contact.name
1364
+ }
1365
+ });
1366
+ guard.announceRefusal({
1367
+ mode,
1368
+ blocking: verdict.blocking,
1369
+ retrySeconds: Math.round(ARM_BYPASS_WINDOW_MS / 1e3),
1370
+ at: now
1371
+ });
1372
+ throw new NcAlarmArmRefusedError({
1373
+ mode,
1374
+ blocking: verdict.blocking,
1375
+ retryUntil: verdict.retryUntil
1376
+ });
1377
+ }
825
1378
  registerAlarmCap() {
826
1379
  this.ctx.registerNativeCap(require_dist.alarmPanelCapability, {
827
1380
  getStatus: async () => this.status(),
828
1381
  arm: async ({ mode }) => {
829
- this.machine = arm(mode, this.delays, Date.now());
1382
+ const now = Date.now();
1383
+ await this.applyArmGuard(mode, now);
1384
+ this.machine = arm(mode, this.delays, now);
830
1385
  this.attribution.clear();
831
- this.publish();
1386
+ this.publish(now);
832
1387
  },
833
1388
  disarm: async () => {
834
1389
  this.machine = disarm(Date.now());
835
1390
  this.attribution.clear();
1391
+ this.exclusions.clear();
1392
+ this.lastRefusal = null;
836
1393
  this.publish();
837
1394
  },
838
1395
  trigger: async () => {
839
1396
  const now = Date.now();
1397
+ const pending = this.attribution.pending(now);
1398
+ const source = pending?.sourceDeviceId;
1399
+ if (source !== void 0 && this.exclusions.blocks(source)) {
1400
+ this.attribution.dropPending();
1401
+ this.ctx.logger.info("alarm trigger ignored — the sensor was open when the alarm was armed", {
1402
+ tags: { deviceId: source },
1403
+ meta: {
1404
+ panelDeviceId: this.id,
1405
+ ruleId: pending?.ruleId,
1406
+ rule: pending?.ruleName,
1407
+ state: stateAt(this.machine, now)
1408
+ }
1409
+ });
1410
+ return;
1411
+ }
840
1412
  const next = trigger(this.machine, this.delays, now);
841
1413
  if (next === this.machine) {
842
1414
  this.ctx.logger.info("alarm trigger ignored — the panel is not armed", {
@@ -896,12 +1468,14 @@ var NcAlarmPanelDevice = class extends require_dist.BaseDevice {
896
1468
  const hook = this.onPublish;
897
1469
  if (hook === null) return;
898
1470
  const source = this.attribution.current();
1471
+ const arming = exitDelayAt(this.machine, now);
899
1472
  const transition = alarmTransition({
900
1473
  previous,
901
1474
  next: state,
902
1475
  modes: NC_ALARM_MODES,
903
1476
  at: now,
904
- ...source !== null ? { source } : {}
1477
+ ...source !== null ? { source } : {},
1478
+ ...arming !== void 0 ? { arming } : {}
905
1479
  });
906
1480
  if (transition?.kind === "alarm-triggered") this.attribution.clear();
907
1481
  try {
@@ -939,11 +1513,14 @@ var NcAlarmPanelDevice = class extends require_dist.BaseDevice {
939
1513
  this.ticker = null;
940
1514
  }
941
1515
  /** The machine rides the device's own config blob — it is small, and it must
942
- * survive a restart or the alarm disarms itself on every deploy. */
1516
+ * survive a restart or the alarm disarms itself on every deploy. The
1517
+ * exclusions ride with it, for the same reason and with the same lifetime. */
943
1518
  persist() {
944
1519
  this.config.set("machine", { ...this.machine });
1520
+ this.config.set("exclusions", [...this.exclusions.snapshot()]);
945
1521
  }
946
1522
  restore() {
1523
+ this.exclusions.restore(this.config.get("exclusions"));
947
1524
  const restored = machineFromPersisted(this.config.get("machine"));
948
1525
  if (restored === null) return;
949
1526
  this.machine = restored;
@@ -1719,10 +2296,15 @@ var MAX_ARM_BUTTONS = 3;
1719
2296
  *
1720
2297
  * | notification | buttons |
1721
2298
  * | --- | --- |
2299
+ * | `alarm-arming` | **Disarma**, destructive |
1722
2300
  * | `alarm-triggered` | **Disarma**, destructive |
1723
2301
  * | `alarm-armed` | **Disarma**, destructive |
1724
2302
  * | `alarm-disarmed` | **Arma …** per declared mode, max three |
1725
2303
  *
2304
+ * The `alarm-arming` row is the one that earns its keep: that notification
2305
+ * arrives at the START of the exit delay, so its Disarm is the only alarm
2306
+ * button an operator can press to cancel something rather than to undo it.
2307
+ *
1726
2308
  * `destructive` on the disarm is a RENDERING hint and never a gate (D47) — what
1727
2309
  * actually bounds it is the five-minute TTL the minter gives that grant
1728
2310
  * (`ALARM_DISARM_TTL_MS`). Both exist: the hint is for the person, the TTL is
@@ -3772,10 +4354,52 @@ var en_default = {
3772
4354
  "one": "{{devices}} device armed: {{names}}.",
3773
4355
  "other": "{{devices}} devices armed: {{names}}."
3774
4356
  },
4357
+ "alarm.arm.body.devicesSkipped": {
4358
+ "one": "{{devices}} device armed: {{names}}. Excluded: {{skippedNames}}.",
4359
+ "other": "{{devices}} devices armed: {{names}}. Excluded: {{skippedNames}}."
4360
+ },
4361
+ "alarm.arm.body.uncovered": "Nothing is armed — no enabled rule is gated on this mode.",
4362
+ "alarm.arm.body.allSkipped": {
4363
+ "one": "Nothing is armed: the {{devices}} device this mode covers is excluded ({{names}}).",
4364
+ "other": "Nothing is armed: all {{devices}} devices this mode covers are excluded ({{names}})."
4365
+ },
4366
+ "alarm.arming.body": {
4367
+ "one": "Armed in {{seconds}} second.",
4368
+ "other": "Armed in {{seconds}} seconds."
4369
+ },
4370
+ "alarm.arming.body.uncovered": {
4371
+ "one": "Armed in {{seconds}} second — but no enabled rule is gated on this mode, so it will protect nothing.",
4372
+ "other": "Armed in {{seconds}} seconds — but no enabled rule is gated on this mode, so it will protect nothing."
4373
+ },
4374
+ "alarm.arming.body.allSkipped": {
4375
+ "one": "Armed in {{seconds}} second — but every device this mode covers is excluded ({{names}}), so it will protect nothing.",
4376
+ "other": "Armed in {{seconds}} seconds — but every device this mode covers is excluded ({{names}}), so it will protect nothing."
4377
+ },
4378
+ "alarm.arm.body.bypassed": {
4379
+ "one": "{{devices}} device armed — open and excluded: {{bypassedNames}}.",
4380
+ "other": "{{devices}} devices armed — open and excluded: {{bypassedNames}}."
4381
+ },
4382
+ "alarm.arming.body.bypassed": {
4383
+ "one": "Armed in {{seconds}} second, excluding what is open: {{bypassedNames}}.",
4384
+ "other": "Armed in {{seconds}} seconds, excluding what is open: {{bypassedNames}}."
4385
+ },
4386
+ "alarm.armRefused.body": {
4387
+ "one": "{{names}} is open. Press arm again within {{seconds}}s to arm without it.",
4388
+ "other": "These are open: {{names}}. Press arm again within {{seconds}}s to arm without them."
4389
+ },
3775
4390
  "alarm.device.unnamed": "Device {{id}}",
4391
+ "system.alarm-arm-refused.title.home": "Home NOT armed",
4392
+ "system.alarm-arm-refused.title.away": "Away NOT armed",
4393
+ "system.alarm-arm-refused.title.night": "Night NOT armed",
4394
+ "system.alarm-arm-refused.body": "The alarm could not be armed — something it covers is open.",
4395
+ "system.alarm-arming.title.home": "Arming home",
4396
+ "system.alarm-arming.title.away": "Arming away",
4397
+ "system.alarm-arming.title.night": "Arming night",
4398
+ "system.alarm-arming.body": "The alarm is arming",
3776
4399
  "system.alarm-armed.title.home": "Home armed",
3777
4400
  "system.alarm-armed.title.away": "Away armed",
3778
4401
  "system.alarm-armed.title.night": "Night armed",
4402
+ "system.alarm-armed.body": "The alarm was armed",
3779
4403
  "system.alarm-disarmed.title": "Alarm disarmed",
3780
4404
  "system.alarm-disarmed.body": "The alarm was disarmed",
3781
4405
  "system.alarm-triggered.title": "ALARM",
@@ -3924,10 +4548,52 @@ var it_default = {
3924
4548
  "one": "{{devices}} dispositivo attivo: {{names}}.",
3925
4549
  "other": "{{devices}} dispositivi attivi: {{names}}."
3926
4550
  },
4551
+ "alarm.arm.body.devicesSkipped": {
4552
+ "one": "{{devices}} dispositivo attivo: {{names}}. Esclusi: {{skippedNames}}.",
4553
+ "other": "{{devices}} dispositivi attivi: {{names}}. Esclusi: {{skippedNames}}."
4554
+ },
4555
+ "alarm.arm.body.uncovered": "Non è attivo nulla — nessuna regola abilitata è legata a questa modalità.",
4556
+ "alarm.arm.body.allSkipped": {
4557
+ "one": "Non è attivo nulla: l'unico dispositivo ({{devices}}) coperto da questa modalità è escluso ({{names}}).",
4558
+ "other": "Non è attivo nulla: tutti i {{devices}} dispositivi coperti da questa modalità sono esclusi ({{names}})."
4559
+ },
4560
+ "alarm.arming.body": {
4561
+ "one": "Attivo tra {{seconds}} secondo.",
4562
+ "other": "Attivo tra {{seconds}} secondi."
4563
+ },
4564
+ "alarm.arming.body.uncovered": {
4565
+ "one": "Attivo tra {{seconds}} secondo — ma nessuna regola abilitata è legata a questa modalità: non proteggerà nulla.",
4566
+ "other": "Attivo tra {{seconds}} secondi — ma nessuna regola abilitata è legata a questa modalità: non proteggerà nulla."
4567
+ },
4568
+ "alarm.arming.body.allSkipped": {
4569
+ "one": "Attivo tra {{seconds}} secondo — ma ogni dispositivo coperto da questa modalità è escluso ({{names}}): non proteggerà nulla.",
4570
+ "other": "Attivo tra {{seconds}} secondi — ma ogni dispositivo coperto da questa modalità è escluso ({{names}}): non proteggerà nulla."
4571
+ },
4572
+ "alarm.arm.body.bypassed": {
4573
+ "one": "{{devices}} dispositivo attivo — aperto ed escluso: {{bypassedNames}}.",
4574
+ "other": "{{devices}} dispositivi attivi — aperti ed esclusi: {{bypassedNames}}."
4575
+ },
4576
+ "alarm.arming.body.bypassed": {
4577
+ "one": "Attivo tra {{seconds}} secondo, escludendo ciò che è aperto: {{bypassedNames}}.",
4578
+ "other": "Attivo tra {{seconds}} secondi, escludendo ciò che è aperto: {{bypassedNames}}."
4579
+ },
4580
+ "alarm.armRefused.body": {
4581
+ "one": "{{names}} è aperto. Premi di nuovo entro {{seconds}}s per inserire senza di esso.",
4582
+ "other": "Sono aperti: {{names}}. Premi di nuovo entro {{seconds}}s per inserire senza di essi."
4583
+ },
3927
4584
  "alarm.device.unnamed": "Dispositivo {{id}}",
4585
+ "system.alarm-arm-refused.title.home": "Inserimento Casa rifiutato",
4586
+ "system.alarm-arm-refused.title.away": "Inserimento Fuori rifiutato",
4587
+ "system.alarm-arm-refused.title.night": "Inserimento Notte rifiutato",
4588
+ "system.alarm-arm-refused.body": "L'allarme non è stato inserito — qualcosa che copre è aperto.",
4589
+ "system.alarm-arming.title.home": "Inserimento Casa",
4590
+ "system.alarm-arming.title.away": "Inserimento Fuori",
4591
+ "system.alarm-arming.title.night": "Inserimento Notte",
4592
+ "system.alarm-arming.body": "L'allarme si sta inserendo",
3928
4593
  "system.alarm-armed.title.home": "Casa attivato",
3929
4594
  "system.alarm-armed.title.away": "Fuori attivato",
3930
4595
  "system.alarm-armed.title.night": "Notte attivato",
4596
+ "system.alarm-armed.body": "L'allarme è stato inserito",
3931
4597
  "system.alarm-disarmed.title": "Allarme disattivato",
3932
4598
  "system.alarm-disarmed.body": "L'allarme è stato disattivato",
3933
4599
  "system.alarm-triggered.title": "ALLARME",
@@ -5599,16 +6265,16 @@ function incomingFromAlarmTransition(input) {
5599
6265
  kind,
5600
6266
  subject: `alarm:${String(panelDeviceId)}`,
5601
6267
  titleKey: transition.mode !== void 0 ? `system.${kind}.title.${transition.mode}` : `system.${kind}.title`,
5602
- bodyKey: input.arm?.bodyKey ?? (source !== void 0 ? `system.${kind}.body.rule` : `system.${kind}.body`),
6268
+ bodyKey: input.body?.bodyKey ?? (source !== void 0 ? `system.${kind}.body.rule` : `system.${kind}.body`),
5603
6269
  textParams: {
5604
6270
  ...deviceTextParams(deviceId, identity),
5605
6271
  ...transition.mode !== void 0 ? { mode: transition.mode } : {},
5606
6272
  ...source !== void 0 ? { rule: source.ruleName } : {},
5607
- ...input.arm?.textParams
6273
+ ...input.body?.textParams
5608
6274
  },
5609
6275
  deviceId,
5610
6276
  ...identity !== void 0 ? { deviceType: identity.type } : {},
5611
- ...input.arm !== void 0 ? { textCount: input.arm.textCount } : {},
6277
+ ...input.body !== void 0 ? { textCount: input.body.textCount } : {},
5612
6278
  ...source !== void 0 ? { alarmSource: source } : {}
5613
6279
  });
5614
6280
  }
@@ -6195,6 +6861,16 @@ var OccupancyWatcher = class {
6195
6861
  watched = /* @__PURE__ */ new Map();
6196
6862
  /** Confirmed + pending state, keyed by the full {@link OccupancyKey}. */
6197
6863
  states = /* @__PURE__ */ new Map();
6864
+ /** Rows SEEDED since the last {@link takeSeededRows} — the durable
6865
+ * write-through queue (see that method). */
6866
+ seeded = /* @__PURE__ */ new Map();
6867
+ /** Keys already reported through `onImpossibleScope` (once-per-key gate).
6868
+ * Pruned with the watched set, so re-authoring a rule reports again. */
6869
+ impossibleReported = /* @__PURE__ */ new Set();
6870
+ deps;
6871
+ constructor(deps = {}) {
6872
+ this.deps = deps;
6873
+ }
6198
6874
  /**
6199
6875
  * Replace the watched key set (rule-driven — recomputed on rule change).
6200
6876
  * Each distinct `(zone, class, threshold)` is its OWN watched partial key
@@ -6225,6 +6901,8 @@ var OccupancyWatcher = class {
6225
6901
  });
6226
6902
  }
6227
6903
  for (const key of [...this.states.keys()]) if (!this.watched.has(partialKeyOf(key))) this.states.delete(key);
6904
+ for (const key of [...this.seeded.keys()]) if (!this.watched.has(partialKeyOf(key))) this.seeded.delete(key);
6905
+ for (const key of [...this.impossibleReported]) if (!this.watched.has(partialKeyOf(key))) this.impossibleReported.delete(key);
6228
6906
  }
6229
6907
  /**
6230
6908
  * Feed one camera snapshot at time `now`, returning the edges that COMMIT on
@@ -6237,9 +6915,12 @@ var OccupancyWatcher = class {
6237
6915
  const resolved = resolveOccupancyScope(snapshot, spec);
6238
6916
  if (resolved === null) continue;
6239
6917
  const key = occupancyKey(deviceId, spec.zoneId, spec.className, spec.threshold);
6918
+ this.reportImpossibleScope(deviceId, key, spec);
6240
6919
  const existing = this.states.get(key);
6241
6920
  if (existing === void 0) {
6242
- this.states.set(key, seedState(deviceId, spec, resolved, now));
6921
+ const state = seedState(deviceId, spec, resolved, now);
6922
+ this.states.set(key, state);
6923
+ this.seeded.set(key, stateToRow(key, state, now));
6243
6924
  continue;
6244
6925
  }
6245
6926
  const edge = step(existing, spec, resolved, now);
@@ -6247,6 +6928,38 @@ var OccupancyWatcher = class {
6247
6928
  }
6248
6929
  return edges;
6249
6930
  }
6931
+ /**
6932
+ * Drain the rows seeded since the last call — the caller persists them
6933
+ * through the SAME ledger the committed edges use.
6934
+ *
6935
+ * Drained rather than exposed because a seed is a ONE-TIME fact: re-offering
6936
+ * it would turn a rare write into a per-frame one, which is the thing the
6937
+ * edge-only write-through was designed to avoid. Missing a drain costs
6938
+ * nothing worse than today's behaviour (a cold re-seed at the next boot), so
6939
+ * the caller may fail the write without corrupting anything.
6940
+ */
6941
+ takeSeededRows() {
6942
+ const rows = [...this.seeded.values()];
6943
+ this.seeded.clear();
6944
+ return rows;
6945
+ }
6946
+ /** Fire `onImpossibleScope` the first time a key with an uncountable class
6947
+ * scope is evaluated. See {@link OccupancyImpossibleScope}. */
6948
+ reportImpossibleScope(deviceId, key, spec) {
6949
+ const report = this.deps.onImpossibleScope;
6950
+ if (report === void 0) return;
6951
+ const className = spec.className;
6952
+ if (className === void 0 || require_dist.isDetectionMacroClass(className)) return;
6953
+ if (this.impossibleReported.has(key)) return;
6954
+ this.impossibleReported.add(key);
6955
+ report({
6956
+ deviceId,
6957
+ key,
6958
+ ...spec.zoneId !== void 0 ? { zoneId: spec.zoneId } : {},
6959
+ className,
6960
+ threshold: spec.threshold
6961
+ });
6962
+ }
6250
6963
  /** Reseed confirmed state from durable rows (boot). Only rows whose key is
6251
6964
  * currently WATCHED are restored — an orphaned durable row (its rule gone)
6252
6965
  * is dropped, keeping the RAM map bounded to the active set. Pending edges
@@ -6269,20 +6982,25 @@ var OccupancyWatcher = class {
6269
6982
  /** Snapshot the CONFIRMED state for durable persistence (pending excluded). */
6270
6983
  snapshotState() {
6271
6984
  const rows = [];
6272
- for (const [key, state] of this.states) rows.push({
6273
- key,
6274
- deviceId: state.deviceId,
6275
- ...state.zoneId !== void 0 ? { zoneId: state.zoneId } : {},
6276
- ...state.className !== void 0 ? { className: state.className } : {},
6277
- threshold: state.threshold,
6278
- confirmedCount: state.confirmedCount,
6279
- occupied: state.occupied,
6280
- lastChangeAt: state.lastChangeAt,
6281
- updatedAt: state.lastChangeAt
6282
- });
6985
+ for (const [key, state] of this.states) rows.push(stateToRow(key, state, state.lastChangeAt));
6283
6986
  return rows;
6284
6987
  }
6285
6988
  };
6989
+ /** The durable projection of one in-RAM state — the ONE place the persisted row
6990
+ * shape is built, so a seeded row and a snapshotted one cannot differ. */
6991
+ function stateToRow(key, state, updatedAt) {
6992
+ return {
6993
+ key,
6994
+ deviceId: state.deviceId,
6995
+ ...state.zoneId !== void 0 ? { zoneId: state.zoneId } : {},
6996
+ ...state.className !== void 0 ? { className: state.className } : {},
6997
+ threshold: state.threshold,
6998
+ confirmedCount: state.confirmedCount,
6999
+ occupied: state.occupied,
7000
+ lastChangeAt: state.lastChangeAt,
7001
+ updatedAt
7002
+ };
7003
+ }
6286
7004
  /**
6287
7005
  * The state a key starts from: WHAT THE FIRST SNAPSHOT SAID.
6288
7006
  *
@@ -6446,7 +7164,11 @@ function probeOccupancyNow(input) {
6446
7164
  * field for them would be a cross-package change for a sentence.
6447
7165
  */
6448
7166
  function occupancyProbeLabel(occupancy, probe, cooldownSec) {
6449
- if (probe.kind === "unavailable") return probe.reason === "no-snapshot" ? "no ZoneAnalytics snapshot for this camera — the condition fails closed until one arrives" : "that zone is not in the camera’s current snapshot — the condition fails closed (deleted zone?)";
7167
+ const classNote = impossibleClassNote(occupancy.className);
7168
+ if (probe.kind === "unavailable") {
7169
+ const reason = probe.reason === "no-snapshot" ? "no ZoneAnalytics snapshot for this camera — the condition fails closed until one arrives" : "that zone is not in the camera’s current snapshot — the condition fails closed (deleted zone?)";
7170
+ return classNote === null ? reason : `${classNote} · ${reason}`;
7171
+ }
6450
7172
  const where = probe.zoneName ?? (occupancy.zoneId === void 0 ? "whole frame" : occupancy.zoneId);
6451
7173
  const what = occupancy.className ?? "objects";
6452
7174
  const parts = [
@@ -6455,7 +7177,21 @@ function occupancyProbeLabel(occupancy, probe, cooldownSec) {
6455
7177
  `fires on the transition, sustained ${String(occupancy.sustainSeconds)} s`
6456
7178
  ];
6457
7179
  if (cooldownSec > 0) parts.push(`cooldown ${String(cooldownSec)} s`);
6458
- return parts.join(" · ");
7180
+ return classNote === null ? parts.join(" · ") : `${classNote} · ${parts.join(" · ")}`;
7181
+ }
7182
+ /**
7183
+ * The one-clause warning for a class scope the snapshots can never key on, or
7184
+ * `null` when the scope is fine.
7185
+ *
7186
+ * The dry-run's whole job is to make "why does this rule never fire?" answerable
7187
+ * before the operator waits a night for it. A sub-type class produced the most
7188
+ * convincing wrong answer it can give — "0/1 car · not occupied now" — which is
7189
+ * pixel-identical to an empty parking space. `byClass` is MACRO-keyed, so that
7190
+ * counter is 0 structurally and no scene can change it.
7191
+ */
7192
+ function impossibleClassNote(className) {
7193
+ if (className === void 0 || require_dist.isDetectionMacroClass(className)) return null;
7194
+ return `⚠ “${className}” is a sub-type, not a macro class: this counter is always 0 (use ${[...require_dist.DETECTION_MACRO_CLASSES].join(" / ")})`;
6459
7195
  }
6460
7196
  /**
6461
7197
  * The mechanics, declared once.
@@ -11767,7 +12503,7 @@ var NC_ALARM_ANNOUNCE_RULE_ID = "nc-alarm-announce";
11767
12503
  * notification that says the alarm was disarmed is worse than offering nothing.
11768
12504
  */
11769
12505
  function alarmButtonKind(kind) {
11770
- if (kind === "alarm-triggered" || kind === "alarm-armed" || kind === "alarm-disarmed") return kind;
12506
+ if (kind === "alarm-triggered" || kind === "alarm-armed" || kind === "alarm-arming" || kind === "alarm-disarmed") return kind;
11771
12507
  return null;
11772
12508
  }
11773
12509
  /**
@@ -11852,6 +12588,7 @@ function fireRefFor(rule, subject, kind) {
11852
12588
  ruleId: rule.id,
11853
12589
  ruleName: rule.name,
11854
12590
  deviceId: subject.deviceId,
12591
+ ...subject.sourceDeviceId !== void 0 ? { sourceDeviceId: subject.sourceDeviceId } : {},
11855
12592
  recordId: subject.recordId,
11856
12593
  ...subject.trackId !== void 0 ? { trackId: subject.trackId } : {},
11857
12594
  ...hasEventMedia ? { eventId: subject.recordId } : {},
@@ -11894,17 +12631,55 @@ function historyRecordKind(kind) {
11894
12631
  * showed a key while the phone showed a sentence would be a second vocabulary
11895
12632
  * to keep in step. A pre-catalog row falls back to the pair frozen on it.
11896
12633
  */
12634
+ /**
12635
+ * Cap-facing downgrade of a system-event kind — the SAME device as
12636
+ * {@link historyRecordKind}, and it exists for the same reason.
12637
+ *
12638
+ * This addon bundles its own `@camstack/types` (self-contained externals), so
12639
+ * it can emit a kind the enum in the RUNNING SERVER does not have. The history
12640
+ * router does not: it validates its output against the host's
12641
+ * `NcHistorySubjectSchema`, whose `kind` is a `z.enum`. One row carrying a kind
12642
+ * the host has never heard of therefore does not degrade — it fails the whole
12643
+ * `getHistory` call, for every row, exactly as the enum's own legacy-tail note
12644
+ * warns.
12645
+ *
12646
+ * `alarm-arming` is projected onto `alarm-armed`, its nearest member: same
12647
+ * panel, same lifecycle, adjacent instant. Nothing a person reads changes —
12648
+ * the row's title and body are resolved from the arming keys ("Inserimento
12649
+ * Fuori — attivo tra 30 secondi") — only the machine-facing word does.
12650
+ *
12651
+ * DELETE THIS when a `@camstack/server` closure carrying `alarm-arming` in
12652
+ * `NcSystemEventKindSchema` is installed everywhere this addon runs. Until
12653
+ * then it is what lets the exit-delay notification ship without a train.
12654
+ */
12655
+ /**
12656
+ * The mirrored device-state word, narrowed to a contact reading.
12657
+ *
12658
+ * Anything that is not one of the two contact words is `undefined` — a device
12659
+ * whose slice the state table read as `on` / `armed_home` is not a contact, and
12660
+ * an exclusion must never be lifted by a word that belongs to another cap.
12661
+ */
12662
+ function contactReadingOf(state) {
12663
+ if (state === "open" || state === "closed") return state;
12664
+ }
12665
+ function historySystemEventKind(kind) {
12666
+ if (kind === "alarm-arming") return "alarm-armed";
12667
+ if (kind === "alarm-arm-refused") return "alarm-disarmed";
12668
+ return kind;
12669
+ }
11897
12670
  function historySystemEvent(texts, systemEvent) {
11898
12671
  const vars = systemEvent.textParams ?? {};
12672
+ const count = systemEvent.textCount;
11899
12673
  const resolve = (key, frozen) => {
11900
12674
  if (key !== void 0 && texts.has(key)) return texts.text({
11901
12675
  key,
11902
- vars
12676
+ vars,
12677
+ ...count !== void 0 ? { count } : {}
11903
12678
  });
11904
12679
  return frozen ?? "";
11905
12680
  };
11906
12681
  return {
11907
- kind: systemEvent.kind,
12682
+ kind: historySystemEventKind(systemEvent.kind),
11908
12683
  subject: systemEvent.subject,
11909
12684
  title: resolve(systemEvent.titleKey, systemEvent.title),
11910
12685
  body: resolve(systemEvent.bodyKey, systemEvent.body),
@@ -11966,8 +12741,22 @@ var NotificationCenter = class NotificationCenter {
11966
12741
  /** Debounced occupancy edge state machine (pure) + its durable confirmed
11967
12742
  * state. Fed in-process by {@link observeOccupancy} from ZoneAnalytics
11968
12743
  * snapshots; watched keys are recomputed from the enabled occupancy rules. */
11969
- occupancyWatcher = new OccupancyWatcher();
12744
+ occupancyWatcher = new OccupancyWatcher({ onImpossibleScope: (scope) => {
12745
+ this.logger.warn("occupancy key can never count — class is a sub-type, not a macro", {
12746
+ tags: { deviceId: scope.deviceId },
12747
+ meta: {
12748
+ key: scope.key,
12749
+ zoneId: scope.zoneId ?? "@frame",
12750
+ className: scope.className,
12751
+ threshold: scope.threshold,
12752
+ macros: [...require_dist.DETECTION_MACRO_CLASSES].join(",")
12753
+ }
12754
+ });
12755
+ } });
11970
12756
  occupancyStore;
12757
+ /** Rendered watched-occupancy-key set as last logged — the change gate for
12758
+ * {@link reportOccupancyWatch} (it runs on every rule-reload tick). */
12759
+ lastOccupancyWatchReport = "";
11971
12760
  /**
11972
12761
  * The sustained-sound sampling-window matcher (pure). Fed in-process by
11973
12762
  * {@link observeAudio} from the pipeline's audio inference frames; watched
@@ -12778,8 +13567,18 @@ var NotificationCenter = class NotificationCenter {
12778
13567
  * before it.
12779
13568
  */
12780
13569
  onSensorEventPersisted(event, markerTrackId) {
13570
+ this.observeContactEvent(event);
12781
13571
  this.consumeEvent(incomingFromSensorEvent(event, markerTrackId));
12782
13572
  }
13573
+ /** The contact reading a sensor row carries, handed to the panel. Nothing at
13574
+ * all for any other sensor kind — see {@link observeAlarmContacts}. */
13575
+ observeContactEvent(event) {
13576
+ const panel = this.alarmPanel;
13577
+ if (panel === null || event.kind !== "contact") return;
13578
+ const entryOpen = event.value?.["entryOpen"];
13579
+ if (typeof entryOpen !== "boolean") return;
13580
+ panel.observeContact(event.sourceDeviceId, entryOpen ? "open" : "closed", event.timestamp);
13581
+ }
12783
13582
  /**
12784
13583
  * Called at the AUDIO-event persist site (`eventStore.insertAudio`), in the
12785
13584
  * SAME moment as the durable insert. Feeds an `immediate` rule that OPTS IN
@@ -12826,7 +13625,20 @@ var NotificationCenter = class NotificationCenter {
12826
13625
  });
12827
13626
  return;
12828
13627
  }
13628
+ this.persistSeededOccupancy(now);
12829
13629
  for (const edge of edges) {
13630
+ this.logger.info("occupancy edge committed", {
13631
+ tags: { deviceId: edge.deviceId },
13632
+ meta: {
13633
+ zoneId: edge.zoneId ?? "@frame",
13634
+ zoneName: edge.zoneName ?? "",
13635
+ className: edge.className ?? "@all",
13636
+ threshold: edge.threshold,
13637
+ count: edge.count,
13638
+ previousCount: edge.previousCount,
13639
+ occupied: edge.occupied
13640
+ }
13641
+ });
12830
13642
  this.persistOccupancyEdge(edge, now);
12831
13643
  this.onOccupancyEdge(edge);
12832
13644
  }
@@ -13031,6 +13843,119 @@ var NotificationCenter = class NotificationCenter {
13031
13843
  setAlarmPanel(panel) {
13032
13844
  this.alarmPanel = panel;
13033
13845
  }
13846
+ /**
13847
+ * What the panel asks before it arms — see {@link NcAlarmArmGuardHook}.
13848
+ *
13849
+ * Handed to the panel by the same wiring that hands it the publish hook: the
13850
+ * panel owns the state machine, the centre owns the rules that say which
13851
+ * contacts a mode covers, and neither can reach the other on its own.
13852
+ */
13853
+ alarmArmGuard() {
13854
+ return {
13855
+ openContacts: (mode) => this.alarmOpenContacts(mode),
13856
+ announceRefusal: (input) => this.announceArmRefused(input)
13857
+ };
13858
+ }
13859
+ /**
13860
+ * Which of the contacts this mode covers read OPEN, right now.
13861
+ *
13862
+ * A TARGETED read of at most the handful of sensors the mode names, not a
13863
+ * refresh of the whole watched set: the arm is a person pressing a button and
13864
+ * the answer has to be about this second, but it must not become a fan-out
13865
+ * over every gated device on the hub.
13866
+ *
13867
+ * Three fail-open rules, and all three exist because refusing an arm on
13868
+ * ignorance is worse than arming:
13869
+ *
13870
+ * - a sensor the read could not answer for falls back to the mirrored state
13871
+ * (bounded staleness, the same contract the `deviceState` gate lives with);
13872
+ * - a sensor NEITHER can answer for is not blocking, and says so in a line;
13873
+ * - a mode whose contact rule names no devices blocks nothing, and says so —
13874
+ * an unscoped contact rule covers every contact on the hub, and a refusal
13875
+ * naming devices the operator never associated with the alarm is one they
13876
+ * cannot act on.
13877
+ */
13878
+ async alarmOpenContacts(mode) {
13879
+ const panel = this.alarmPanel;
13880
+ if (panel === null) return [];
13881
+ const candidates = alarmBlockingCandidates(this.rules.list(), panel.deviceId, mode);
13882
+ if (candidates.length === 0) return [];
13883
+ const fresh = await this.readContactStates(candidates);
13884
+ const name = this.deviceNameFromDirectory();
13885
+ const open = [];
13886
+ const unknown = [];
13887
+ for (const deviceId of candidates) {
13888
+ const state = fresh.get(deviceId) ?? this.deviceStates.get(deviceId);
13889
+ if (state === void 0) {
13890
+ unknown.push(deviceId);
13891
+ continue;
13892
+ }
13893
+ if (state === "open") open.push({
13894
+ deviceId,
13895
+ name: name(deviceId)
13896
+ });
13897
+ }
13898
+ for (const deviceId of unknown) this.logger.info("alarm arm check: no contact state — the sensor cannot block this arm", {
13899
+ tags: { deviceId },
13900
+ meta: {
13901
+ panelDeviceId: panel.deviceId,
13902
+ mode
13903
+ }
13904
+ });
13905
+ this.logger.info("alarm arm check", {
13906
+ tags: { deviceId: panel.deviceId },
13907
+ meta: {
13908
+ mode,
13909
+ candidates: candidates.length,
13910
+ open: open.length,
13911
+ unknown: unknown.length,
13912
+ openIds: open.map((c) => c.deviceId).join(",")
13913
+ }
13914
+ });
13915
+ return open;
13916
+ }
13917
+ /** One bounded read of the named contacts. Never throws — an unreadable
13918
+ * sensor is simply absent, exactly as `NcDeviceStateCache` contracts. */
13919
+ async readContactStates(ids) {
13920
+ const read = this.deps.readDeviceStates;
13921
+ if (read === void 0) return /* @__PURE__ */ new Map();
13922
+ try {
13923
+ return await read(ids);
13924
+ } catch (err) {
13925
+ this.logger.warn("alarm arm check: contact read failed — falling back to the mirror", { meta: {
13926
+ devices: ids.length,
13927
+ error: String(err)
13928
+ } });
13929
+ return /* @__PURE__ */ new Map();
13930
+ }
13931
+ }
13932
+ /**
13933
+ * The refusal, as an ordinary notification.
13934
+ *
13935
+ * Same path the arm announcement takes (D126: the alarm speaks through the
13936
+ * ordinary notification path), so it is durable, retried, in history and
13937
+ * worded in the reader's language at send time. It is the operator's ONLY
13938
+ * copy: the thrown error reaches whoever called the cap, and when that caller
13939
+ * is a notification button on a phone, nobody ever sees it.
13940
+ */
13941
+ announceArmRefused(input) {
13942
+ const panel = this.alarmPanel;
13943
+ if (panel === null) return;
13944
+ const text = buildArmRefusedAnnouncement(input.mode, input.blocking.map((c) => c.name), input.retrySeconds);
13945
+ const transition = {
13946
+ kind: "alarm-arm-refused",
13947
+ state: "disarmed",
13948
+ previous: "disarmed",
13949
+ mode: input.mode,
13950
+ at: input.at
13951
+ };
13952
+ const settings = panel.settings();
13953
+ const targetIds = settings.announceArm ? settings.announceTargets : [];
13954
+ this.scheduleAlarmTransition(panel, transition, {
13955
+ text,
13956
+ targetIds
13957
+ });
13958
+ }
13034
13959
  /** Settings + the DERIVED coverage. Read together — see the cap doc. */
13035
13960
  alarmConfig() {
13036
13961
  const panel = this.alarmPanel;
@@ -13067,7 +13992,7 @@ var NotificationCenter = class NotificationCenter {
13067
13992
  const identity = this.deviceDirectory.get(deviceId);
13068
13993
  if (identity === void 0) return void 0;
13069
13994
  if (identity.online === false) return "offline";
13070
- if (identity.detectionActive === false) return "detection-off";
13995
+ if (identity.isCamera && identity.detectionActive === false) return "detection-off";
13071
13996
  }
13072
13997
  /**
13073
13998
  * Everything the panel reports, on every publish.
@@ -13104,13 +14029,13 @@ var NotificationCenter = class NotificationCenter {
13104
14029
  * the combined notification: one row carrying the alarm's words and that
13105
14030
  * rule's pictures, instead of two pushes about one incident.
13106
14031
  */
13107
- scheduleAlarmTransition(panel, transition) {
13108
- const arm = transition.kind === "alarm-armed" && transition.mode !== void 0 ? this.armAnnouncement(panel, transition.mode) : null;
14032
+ scheduleAlarmTransition(panel, transition, prepared) {
14033
+ const arm = prepared ?? this.armAnnouncement(panel, transition);
13109
14034
  const incoming = incomingFromAlarmTransition({
13110
14035
  transition,
13111
14036
  panelDeviceId: panel.deviceId,
13112
14037
  lookup: this.deviceDirectory.lookup(),
13113
- ...arm !== null ? { arm: arm.text } : {}
14038
+ ...arm !== null ? { body: arm.text } : {}
13114
14039
  });
13115
14040
  this.evalChain = this.evalChain.then(async () => {
13116
14041
  try {
@@ -13131,31 +14056,45 @@ var NotificationCenter = class NotificationCenter {
13131
14056
  });
13132
14057
  }
13133
14058
  /**
13134
- * The arm sentence and the targets it goes to, or null when this arm is not
13135
- * announced at all.
14059
+ * The arm/arming sentence and the targets it goes to, or null when the
14060
+ * transition is not one of those two.
14061
+ *
14062
+ * Both kinds compose from the SAME coverage — "what does this mode actually
14063
+ * watch" — because they are the same answer given thirty seconds apart. The
14064
+ * arming one is the useful one: it reaches the operator while the exit delay
14065
+ * is still running, so a mode that protects nothing can still be fixed.
13136
14066
  *
13137
14067
  * Every way of NOT announcing produces a line: an announcement that silently
13138
14068
  * did not arrive is indistinguishable from the alarm not having armed, which
13139
- * is the exact confusion this feature exists to remove. The loudest of them
13140
- * is the last — a mode nothing is gated on protects nothing, and saying
13141
- * "Away armed" there would be a false assurance.
13142
- */
13143
- armAnnouncement(panel, mode) {
14069
+ * is the exact confusion this feature exists to remove. A COVERAGE GAP no
14070
+ * longer suppresses the sentence — it changes it. Suppressing it left the
14071
+ * operator with a title and an empty line (the fallback body key existed in
14072
+ * neither locale) and no way to learn that the mode was gated on by nothing.
14073
+ */
14074
+ armAnnouncement(panel, transition) {
14075
+ const kind = transition.kind;
14076
+ if (kind !== "alarm-armed" && kind !== "alarm-arming") return null;
14077
+ const mode = transition.mode;
14078
+ if (mode === void 0) return null;
13144
14079
  const settings = panel.settings();
13145
14080
  const tags = { deviceId: panel.deviceId };
13146
14081
  const coverage = this.coverageFor(panel).find((c) => c.mode === mode);
13147
14082
  if (coverage === void 0) return null;
13148
- const text = buildArmAnnouncement(coverage, this.deviceNameFromDirectory());
13149
- if (text === null) {
13150
- this.logger.warn("alarm armed into a mode no enabled rule is gated on", {
13151
- tags,
13152
- meta: {
13153
- mode,
13154
- state: `armed_${mode}`
13155
- }
13156
- });
13157
- return null;
13158
- }
14083
+ const name = this.deviceNameFromDirectory();
14084
+ const bypassed = panel.excludedDevices();
14085
+ const text = kind === "alarm-arming" ? buildArmingAnnouncement(coverage, transition.seconds ?? 0, name, bypassed) : buildArmAnnouncement(coverage, name, bypassed);
14086
+ const gap = alarmCoverageGap(coverage);
14087
+ if (gap !== null) this.logger.warn("alarm armed into a mode that watches nothing", {
14088
+ tags,
14089
+ meta: {
14090
+ mode,
14091
+ kind,
14092
+ gap,
14093
+ rules: coverage.ruleCount,
14094
+ devices: coverage.deviceIds.length,
14095
+ skipped: coverage.skippedDevices.length
14096
+ }
14097
+ });
13159
14098
  if (!settings.announceArm) return {
13160
14099
  text,
13161
14100
  targetIds: []
@@ -13699,6 +14638,7 @@ var NotificationCenter = class NotificationCenter {
13699
14638
  }
13700
14639
  this.occupancyWatcher.setWatchedKeys(specs);
13701
14640
  this.occupancyEnabled = specs.length > 0;
14641
+ this.reportOccupancyWatch(specs);
13702
14642
  const audioSpecs = [];
13703
14643
  for (const rule of this.rules.listEnabled("immediate")) {
13704
14644
  const audio = rule.conditions.audio;
@@ -13725,9 +14665,44 @@ var NotificationCenter = class NotificationCenter {
13725
14665
  const devices = rule.conditions.devices;
13726
14666
  if (zones !== void 0 && zones.ids.length > 0 && devices !== void 0) zoneScoped.push(...devices);
13727
14667
  }
14668
+ const panel = this.alarmPanel;
14669
+ if (panel !== null) gated.push(...alarmContactWatchList(this.rules.list(), panel.deviceId, panel.availableModes()));
13728
14670
  this.deviceStates.setWatched(gated);
13729
14671
  this.zoneOwners.setWatched(zoneScoped);
13730
14672
  }
14673
+ /**
14674
+ * Feed every excluded sensor's mirrored state back to the panel.
14675
+ *
14676
+ * The reconcile half of the two-phase lift. The EVENT half
14677
+ * ({@link onSensorEventPersisted}) is the fast one, but it only exists for a
14678
+ * sensor linked to a camera — the seven contacts on this hub are linked to
14679
+ * none, so without this an exclusion would never lift at all and the sensor
14680
+ * would stay out of the alarm until a disarm.
14681
+ */
14682
+ observeAlarmContacts() {
14683
+ const panel = this.alarmPanel;
14684
+ if (panel === null) return;
14685
+ const excluded = panel.excludedDevices();
14686
+ if (excluded.length === 0) return;
14687
+ const now = this.now();
14688
+ for (const deviceId of excluded) panel.observeContact(deviceId, contactReadingOf(this.deviceStates.get(deviceId)), now);
14689
+ }
14690
+ /**
14691
+ * Log the watched occupancy key set, on CHANGE only.
14692
+ *
14693
+ * Change-only because this runs on every rule-reload tick; a line per tick
14694
+ * would be noise, and no line at all is how a rule that watches an
14695
+ * impossible scope stays invisible for a day.
14696
+ */
14697
+ reportOccupancyWatch(specs) {
14698
+ const rendered = specs.map((s) => `${s.zoneId ?? "@frame"}|${s.className ?? "@all"}|t${String(s.threshold)}@${String(s.sustainSeconds)}s`).toSorted().join(",");
14699
+ if (rendered === this.lastOccupancyWatchReport) return;
14700
+ this.lastOccupancyWatchReport = rendered;
14701
+ this.logger.info("occupancy watched keys", { meta: {
14702
+ keys: specs.length,
14703
+ specs: rendered.length > 0 ? rendered : "(none)"
14704
+ } });
14705
+ }
13731
14706
  /** Boot reseed of confirmed occupancy edge-state (durability, constraint 4) —
13732
14707
  * hydrate the watcher from the store, then prune orphaned durable rows to the
13733
14708
  * active watched set. Runs AFTER {@link refreshOccupancyWatch} so hydrate
@@ -13790,6 +14765,7 @@ var NotificationCenter = class NotificationCenter {
13790
14765
  await this.deviceStates.refresh();
13791
14766
  await this.deviceDirectory.refresh();
13792
14767
  await this.zoneOwners.refresh();
14768
+ this.observeAlarmContacts();
13793
14769
  await this.occupancyStore.pruneExcept(this.activeOccupancyKeys());
13794
14770
  }
13795
14771
  /**
@@ -13815,26 +14791,63 @@ var NotificationCenter = class NotificationCenter {
13815
14791
  activeOccupancyKeys() {
13816
14792
  return new Set(this.occupancyWatcher.snapshotState().map((r) => r.key));
13817
14793
  }
14794
+ /**
14795
+ * Write-through the rows the watcher SEEDED on this observation — the initial
14796
+ * level of a key that had neither a durable row nor RAM state.
14797
+ *
14798
+ * Same ledger, same best-effort contract as an edge: a failed write leaves
14799
+ * exactly today's behaviour (a cold re-seed at the next boot), never a
14800
+ * corrupt level. Drained, so a key is written once and not per frame.
14801
+ */
14802
+ persistSeededOccupancy(now) {
14803
+ const rows = this.occupancyWatcher.takeSeededRows();
14804
+ for (const row of rows) {
14805
+ this.logger.info("occupancy baseline seeded", {
14806
+ tags: { deviceId: row.deviceId },
14807
+ meta: {
14808
+ key: row.key,
14809
+ zoneId: row.zoneId ?? "@frame",
14810
+ className: row.className ?? "@all",
14811
+ threshold: row.threshold,
14812
+ count: row.confirmedCount,
14813
+ occupied: row.occupied
14814
+ }
14815
+ });
14816
+ this.persistOccupancyRow(row, now);
14817
+ }
14818
+ }
13818
14819
  /** Write-through the committed confirmed level for one edge (rare — only on a
13819
14820
  * boolean flip). Best-effort: a failed persist is logged, never thrown into
13820
14821
  * the frame path (the boot reseed + next edge recover). */
13821
- async persistOccupancyEdge(edge, now) {
14822
+ persistOccupancyEdge(edge, now) {
14823
+ return this.persistOccupancyRow({
14824
+ key: occupancyKey(edge.deviceId, edge.zoneId, edge.className, edge.threshold),
14825
+ deviceId: edge.deviceId,
14826
+ ...edge.zoneId !== void 0 ? { zoneId: edge.zoneId } : {},
14827
+ ...edge.className !== void 0 ? { className: edge.className } : {},
14828
+ threshold: edge.threshold,
14829
+ confirmedCount: edge.count,
14830
+ occupied: edge.occupied,
14831
+ lastChangeAt: edge.timestamp,
14832
+ updatedAt: now
14833
+ }, now);
14834
+ }
14835
+ /** The ONE durable write for a confirmed occupancy level, whether it came from
14836
+ * a committed edge or a cold seed. Best-effort by contract — see the two
14837
+ * callers. */
14838
+ async persistOccupancyRow(row, now) {
13822
14839
  try {
13823
14840
  await this.occupancyStore.persist({
13824
- key: occupancyKey(edge.deviceId, edge.zoneId, edge.className, edge.threshold),
13825
- deviceId: edge.deviceId,
13826
- ...edge.zoneId !== void 0 ? { zoneId: edge.zoneId } : {},
13827
- ...edge.className !== void 0 ? { className: edge.className } : {},
13828
- threshold: edge.threshold,
13829
- confirmedCount: edge.count,
13830
- occupied: edge.occupied,
13831
- lastChangeAt: edge.timestamp,
14841
+ ...row,
13832
14842
  updatedAt: now
13833
14843
  });
13834
14844
  } catch (err) {
13835
14845
  this.logger.debug("occupancy persist failed", {
13836
- tags: { deviceId: edge.deviceId },
13837
- meta: { error: String(err) }
14846
+ tags: { deviceId: row.deviceId },
14847
+ meta: {
14848
+ key: row.key,
14849
+ error: String(err)
14850
+ }
13838
14851
  });
13839
14852
  }
13840
14853
  }
@@ -39921,7 +40934,13 @@ var ZoneAnalyticsProvider = class {
39921
40934
  meta: {
39922
40935
  totalObjects: total,
39923
40936
  byClass: snapshot.frame.byClass,
39924
- zones: snapshot.zones.length
40937
+ zones: snapshot.zones.length,
40938
+ zoneCounts: snapshot.zones.map((z) => ({
40939
+ zoneId: z.zoneId,
40940
+ zoneName: z.zoneName,
40941
+ totalObjects: z.totalObjects,
40942
+ byClass: z.byClass
40943
+ }))
39925
40944
  }
39926
40945
  });
39927
40946
  this.ctx.emitOccupancyChanged?.({
@@ -41223,9 +42242,12 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
41223
42242
  settings: () => panel.settings(),
41224
42243
  applySettings: (patch) => panel.applySettings(patch),
41225
42244
  availableModes: () => NC_ALARM_MODES,
41226
- noteRuleTrigger: (source) => panel.noteRuleTrigger(source)
42245
+ noteRuleTrigger: (source) => panel.noteRuleTrigger(source),
42246
+ excludedDevices: () => panel.excludedDevices(),
42247
+ observeContact: (deviceId, reading, now) => panel.observeContact(deviceId, reading, now)
41227
42248
  });
41228
42249
  panel.setPublishHook((report) => center.onAlarmPublish(report));
42250
+ panel.setArmGuard(center.alarmArmGuard());
41229
42251
  center.onAlarmPublish({
41230
42252
  state: panel.currentState(),
41231
42253
  transition: null