@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.
@@ -1,4 +1,4 @@
1
- import { $ as subKindsOf, A as TimelapseRulePatchSchema, B as defineCustomActions, C as NcSnoozeSuppressedSchema, D as RetrainStatusSchema, E as RECORDING_EXPORT_MAX_READ_BYTES, F as assertTimelapseCadences, I as audioMetricsCapability, J as notificationRulesCapability, K as isScheduleActive, L as buildEventKindDescriptor, M as TrackSourceSchema, N as addonWidgetsSourceCapability, O as TIMELAPSE_DENSE_FLOOR_SEC, P as alarmPanelCapability, Q as readTimelapseGeneratedAt, R as cosineSimilarity, S as NcSnoozeSchema, St as EventCategory, T as OpsLogEntrySchema, U as encodeVectorBase64, V as deriveRecordingMode, W as faceGalleryCapability, X as plateGalleryCapability, Y as pipelineAnalyticsCapability, Z as readDeviceStateFrom, _ as NcRulePatchSchema, _t as object, a as DeclaredDevices, at as DeviceType, b as NcScheduleSchema, bt as string, c as LabelAttributionSchema, ct as isDeviceScopedCap, d as NC_CONDITION_CATALOG, dt as _enum, et as vectorDimFromBase64, f as NC_DEFAULT_SNOOZE_MINUTES, ft as array, g as NcRuleInputSchema, gt as number, h as NcConditionDescriptorSchema, ht as literal, it as BaseAddon, j as TimelapseRuleSchema, k as TimelapseRuleInputSchema, l as MACRO_LABELS, lt as nodePin, m as NC_TAXONOMY, mt as discriminatedUnion, n as DEFAULT_EVENT_COLOR, nt as zoneAnalyticsCapability, o as EVENT_KIND_BY_CAP, ot as createEvent, pt as boolean, q as kebabToCamel, rt as errMsg, s as EVENT_PAD_MS, st as hydrateSchema, t as BaseDevice, tt as videoclipsCapability, u as NC_ALARM_SYSTEM_EVENT_KINDS, ut as sleep, v as NcRuleSchema, vt as partialRecord, w as NcTaxonomySchema, x as NcSnoozeInputSchema, xt as unknown, y as NcRuleTargetSchema, yt as record, z as customAction } from "../dist-CU_FWCL9.mjs";
1
+ import { $ as readDeviceStateFrom, A as TimelapseRuleInputSchema, B as customAction, C as NcSnoozeSchema, Ct as unknown, D as RECORDING_EXPORT_MAX_READ_BYTES, E as OpsLogEntrySchema, F as alarmPanelCapability, G as faceGalleryCapability, H as deriveRecordingMode, I as assertTimelapseCadences, J as isScheduleActive, L as audioMetricsCapability, M as TimelapseRuleSchema, N as TrackSourceSchema, O as RetrainStatusSchema, P as addonWidgetsSourceCapability, Q as plateGalleryCapability, R as buildEventKindDescriptor, S as NcSnoozeInputSchema, St as string, T as NcTaxonomySchema, V as defineCustomActions, W as encodeVectorBase64, X as notificationRulesCapability, Y as kebabToCamel, Z as pipelineAnalyticsCapability, _ as NcRuleInputSchema, _t as literal, at as errMsg, b as NcRuleTargetSchema, bt as partialRecord, c as EVENT_PAD_MS, ct as createEvent, d as NC_ALARM_SYSTEM_EVENT_KINDS, dt as nodePin, et as readTimelapseGeneratedAt, f as NC_CONDITION_CATALOG, ft as sleep, g as NcConditionDescriptorSchema, gt as discriminatedUnion, h as NC_TAXONOMY, ht as boolean, i as DETECTION_MACRO_CLASSES, it as zoneAnalyticsCapability, j as TimelapseRulePatchSchema, k as TIMELAPSE_DENSE_FLOOR_SEC, l as LabelAttributionSchema, lt as hydrateSchema, mt as array, n as DEFAULT_EVENT_COLOR, nt as vectorDimFromBase64, o as DeclaredDevices, ot as BaseAddon, p as NC_DEFAULT_SNOOZE_MINUTES, pt as _enum, q as isDetectionMacroClass, rt as videoclipsCapability, s as EVENT_KIND_BY_CAP, st as DeviceType, t as BaseDevice, tt as subKindsOf, u as MACRO_LABELS, ut as isDeviceScopedCap, v as NcRulePatchSchema, vt as number, w as NcSnoozeSuppressedSchema, wt as EventCategory, x as NcScheduleSchema, xt as record, y as NcRuleSchema, yt as object, z as cosineSimilarity } from "../dist-6T-F0YJO.mjs";
2
2
  import { t as __exportAll } from "../embedding-encoder/index.mjs";
3
3
  import * as fs from "node:fs";
4
4
  import { promises } from "node:fs";
@@ -338,6 +338,22 @@ function stateAt(m, now) {
338
338
  if (m.armedAt !== void 0) return now >= m.armedAt ? m.target : "arming";
339
339
  return m.target;
340
340
  }
341
+ /**
342
+ * The exit delay running at `now`, or `undefined` when none is.
343
+ *
344
+ * Gated on `stateAt`, not on `armedAt` alone: the instant survives on the
345
+ * machine after the delay is over (until a settle drops it) and after a trigger
346
+ * has opened a DIFFERENT countdown, and either would announce a countdown for a
347
+ * panel that is already armed or already about to go off.
348
+ */
349
+ function exitDelayAt(m, now) {
350
+ if (m.armedAt === void 0) return void 0;
351
+ if (stateAt(m, now) !== "arming") return void 0;
352
+ return {
353
+ target: m.target,
354
+ armedAt: m.armedAt
355
+ };
356
+ }
341
357
  /** Whether the panel is armed enough for a trigger to matter. */
342
358
  function isArmed(m, now) {
343
359
  const s = stateAt(m, now);
@@ -518,6 +534,21 @@ function skippedFor(deviceIds, skipProbe) {
518
534
  return out;
519
535
  }
520
536
  /**
537
+ * Whether this mode arms anything, and if not, which way it fails.
538
+ *
539
+ * An `allDevices` mode is never a gap: `skippedDevices` only ever probes the
540
+ * ids a rule wrote down, so it cannot answer for a mode that covers every
541
+ * camera on the hub, and reporting "all excluded" from a subset would be a
542
+ * warning about protection that exists.
543
+ */
544
+ function alarmCoverageGap(coverage) {
545
+ if (coverage.ruleCount === 0) return "uncovered";
546
+ if (coverage.allDevices) return null;
547
+ if (coverage.deviceIds.length === 0) return null;
548
+ if (coverage.skippedDevices.length < coverage.deviceIds.length) return null;
549
+ return "all-skipped";
550
+ }
551
+ /**
521
552
  * "Away armed — 3 cameras: Front door, Garage, Gate."
522
553
  *
523
554
  * Names, not ids: this is read by a person standing at a door. An id that has
@@ -526,14 +557,36 @@ function skippedFor(deviceIds, skipProbe) {
526
557
  * the one error this message must not make. (It used to hard-code the English
527
558
  * `Device <id>` into both languages.)
528
559
  *
529
- * Returns null when NO rule is gated on the mode. A notification saying "Night
530
- * armed" that is followed by nothing happening all night is worse than no
531
- * notification: it is a false assurance. The caller still logs the arm.
560
+ * **Always a sentence.** It used to return null for a mode no rule is gated on,
561
+ * on the grounds that "Night armed" followed by an unwatched night is a false
562
+ * assurance — true, but the caller then emitted `alarm-armed` anyway with a
563
+ * fallback body key that existed in NEITHER locale, so the operator received a
564
+ * title and an empty line. Silence is not the third option here: SAYING that
565
+ * the mode arms nothing is.
532
566
  */
533
- function buildArmAnnouncement(coverage, deviceName) {
534
- if (coverage.ruleCount === 0) return null;
567
+ function buildArmAnnouncement(coverage, deviceName, bypassed = []) {
535
568
  const rules = `${coverage.ruleCount}`;
536
569
  const mode = coverage.mode;
570
+ if (bypassed.length > 0) return armedWithBypass(coverage, bypassed, deviceName);
571
+ const gap = alarmCoverageGap(coverage);
572
+ if (gap === "uncovered") return {
573
+ bodyKey: "alarm.arm.body.uncovered",
574
+ textParams: { mode },
575
+ textCount: 0
576
+ };
577
+ if (gap === "all-skipped") {
578
+ const names = coverage.skippedDevices.map((s) => deviceName(s.deviceId));
579
+ return {
580
+ bodyKey: "alarm.arm.body.allSkipped",
581
+ textParams: {
582
+ rules,
583
+ mode,
584
+ devices: `${names.length}`,
585
+ names: names.join(", ")
586
+ },
587
+ textCount: names.length
588
+ };
589
+ }
537
590
  if (coverage.allDevices) return {
538
591
  bodyKey: "alarm.arm.body.allDevices",
539
592
  textParams: {
@@ -550,8 +603,9 @@ function buildArmAnnouncement(coverage, deviceName) {
550
603
  },
551
604
  textCount: coverage.ruleCount
552
605
  };
553
- const names = coverage.deviceIds.map(deviceName);
554
- return {
606
+ const skipped = new Set(coverage.skippedDevices.map((s) => s.deviceId));
607
+ const names = coverage.deviceIds.filter((id) => !skipped.has(id)).map(deviceName);
608
+ if (skipped.size === 0) return {
555
609
  bodyKey: "alarm.arm.body.devices",
556
610
  textParams: {
557
611
  rules,
@@ -561,8 +615,321 @@ function buildArmAnnouncement(coverage, deviceName) {
561
615
  },
562
616
  textCount: names.length
563
617
  };
618
+ return {
619
+ bodyKey: "alarm.arm.body.devicesSkipped",
620
+ textParams: {
621
+ rules,
622
+ mode,
623
+ devices: `${names.length}`,
624
+ names: names.join(", "),
625
+ skippedNames: coverage.skippedDevices.map((s) => deviceName(s.deviceId)).join(", ")
626
+ },
627
+ textCount: names.length
628
+ };
564
629
  }
565
630
  /**
631
+ * "Home armed — with the veranda window open, excluded."
632
+ *
633
+ * The bypassed sensors are named and the armed ones are COUNTED, not listed:
634
+ * this sentence's subject is the exception, and burying it under thirteen
635
+ * camera names is how an operator stops reading the arm notification. The
636
+ * count pluralises on what IS armed, like every other arm variant.
637
+ */
638
+ function armedWithBypass(coverage, bypassed, deviceName) {
639
+ const skipped = new Set(coverage.skippedDevices.map((s) => s.deviceId));
640
+ const excluded = new Set(bypassed);
641
+ const armed = coverage.allDevices ? coverage.ruleCount : coverage.deviceIds.filter((id) => !skipped.has(id) && !excluded.has(id)).length;
642
+ return {
643
+ bodyKey: "alarm.arm.body.bypassed",
644
+ textParams: {
645
+ rules: `${coverage.ruleCount}`,
646
+ mode: coverage.mode,
647
+ devices: `${armed}`,
648
+ bypassedNames: bypassed.map(deviceName).join(", ")
649
+ },
650
+ textCount: armed
651
+ };
652
+ }
653
+ /**
654
+ * "The alarm was NOT armed: the veranda window is open."
655
+ *
656
+ * The one alarm sentence about something that did not happen, so it has to
657
+ * carry both halves an operator needs to act: WHICH sensors are open, and that
658
+ * pressing arm again arms without them. Naming them is the whole message — a
659
+ * refusal that says only "cannot arm" is a panel the operator will conclude is
660
+ * broken.
661
+ */
662
+ function buildArmRefusedAnnouncement(mode, names, retrySeconds) {
663
+ return {
664
+ bodyKey: "alarm.armRefused.body",
665
+ textParams: {
666
+ mode,
667
+ devices: `${names.length}`,
668
+ names: names.join(", "),
669
+ seconds: `${retrySeconds}`
670
+ },
671
+ textCount: names.length
672
+ };
673
+ }
674
+ /**
675
+ * The same coverage, said 30 seconds earlier: "Armed in 30 seconds."
676
+ *
677
+ * The exit-delay sentence is deliberately SHORTER than the arm one — it is read
678
+ * on the way out of a door — and it carries exactly one thing the arm sentence
679
+ * cannot: the warning arrives while the operator can still disarm and fix it.
680
+ * A mode that arms nothing is worth interrupting somebody for at second 0 and
681
+ * merely worth recording at second 30.
682
+ *
683
+ * Pluralises on the SECONDS, not on the devices: this sentence's only number is
684
+ * the countdown, and Italian needs "1 secondo" / "30 secondi".
685
+ */
686
+ function buildArmingAnnouncement(coverage, seconds, deviceName, bypassed = []) {
687
+ const textParams = {
688
+ mode: coverage.mode,
689
+ seconds: `${seconds}`
690
+ };
691
+ if (bypassed.length > 0) return {
692
+ bodyKey: "alarm.arming.body.bypassed",
693
+ textParams: {
694
+ ...textParams,
695
+ bypassedNames: bypassed.map(deviceName).join(", ")
696
+ },
697
+ textCount: seconds
698
+ };
699
+ const gap = alarmCoverageGap(coverage);
700
+ if (gap === "uncovered") return {
701
+ bodyKey: "alarm.arming.body.uncovered",
702
+ textParams,
703
+ textCount: seconds
704
+ };
705
+ if (gap === "all-skipped") return {
706
+ bodyKey: "alarm.arming.body.allSkipped",
707
+ textParams: {
708
+ ...textParams,
709
+ names: coverage.skippedDevices.map((s) => deviceName(s.deviceId)).join(", ")
710
+ },
711
+ textCount: seconds
712
+ };
713
+ return {
714
+ bodyKey: "alarm.arming.body",
715
+ textParams,
716
+ textCount: seconds
717
+ };
718
+ }
719
+ //#endregion
720
+ //#region src/notification-center/alarm/alarm-arm-guard.ts
721
+ /**
722
+ * How long a refusal stays confirmable.
723
+ *
724
+ * Long enough to read the notification and press again (the operator is
725
+ * standing at the door, phone in hand); short enough that it cannot be the
726
+ * state the panel is left in. Sixty seconds is also under the shortest exit
727
+ * delay this panel ships with, so a bypass can never outlive the arm it
728
+ * belongs to.
729
+ */
730
+ var ARM_BYPASS_WINDOW_MS = 6e4;
731
+ /** The taxonomy kind a door/window contact persists its events under. */
732
+ var CONTACT_SENSOR_KIND = "contact";
733
+ /**
734
+ * The contact sensors a mode would arm, ascending and de-duplicated.
735
+ *
736
+ * A rule qualifies only when it is enabled, gated on THIS panel in THIS mode,
737
+ * and about contacts (`sensorKinds` naming `contact`). Two exclusions are
738
+ * deliberate and both are about refusing an arm the operator cannot fix:
739
+ *
740
+ * - a rule that names CAMERAS (`Intrusione — Fuori casa` names thirteen) is
741
+ * not a contact rule, and a camera is never open;
742
+ * - a contact rule with NO device scope covers every contact on the hub, and
743
+ * blocking on a set the operator never wrote down produces a refusal naming
744
+ * devices they never associated with the alarm. The caller says so in a log
745
+ * line rather than inventing the list.
746
+ */
747
+ function alarmBlockingCandidates(rules, panelDeviceId, mode) {
748
+ const wanted = armedStateFor(mode);
749
+ const ids = /* @__PURE__ */ new Set();
750
+ for (const rule of rules) {
751
+ if (!rule.enabled) continue;
752
+ const gate = rule.conditions.deviceState;
753
+ if (gate === void 0 || gate.deviceId !== panelDeviceId) continue;
754
+ if (!gate.states.includes(wanted)) continue;
755
+ const kinds = rule.conditions.sensorKinds;
756
+ if (kinds === void 0 || !kinds.includes(CONTACT_SENSOR_KIND)) continue;
757
+ const scope = rule.conditions.devices;
758
+ if (scope === void 0 || scope.length === 0) continue;
759
+ for (const id of scope) ids.add(id);
760
+ }
761
+ return [...ids].toSorted((a, b) => a - b);
762
+ }
763
+ /**
764
+ * Every contact any mode of this panel could block on — the set whose state is
765
+ * worth keeping mirrored.
766
+ *
767
+ * Read on the rule-reload tick, so the arm path has a recent answer even when
768
+ * the targeted read at arm time fails, and so an excluded sensor's close can be
769
+ * noticed on a hub where the sensor is linked to no camera (and therefore emits
770
+ * no device event at all — the live installation on 2026-08-13).
771
+ */
772
+ function alarmContactWatchList(rules, panelDeviceId, modes) {
773
+ const ids = /* @__PURE__ */ new Set();
774
+ for (const mode of modes) for (const id of alarmBlockingCandidates(rules, panelDeviceId, mode)) ids.add(id);
775
+ return [...ids].toSorted((a, b) => a - b);
776
+ }
777
+ /**
778
+ * Refuse, bypass or proceed — the whole decision, as one comparison.
779
+ *
780
+ * The bypass requires the SAME mode. "Arm home" refused and "arm away" pressed
781
+ * is a different intent about a different set of sensors, and letting one
782
+ * confirm the other would arm a mode around a window nobody was told about.
783
+ *
784
+ * The bypassed set is what is open NOW, not what was open at the refusal: a
785
+ * window shut between the two presses is armed with everything else, which is
786
+ * the entire point of having refused.
787
+ */
788
+ function decideArm(input) {
789
+ const window = input.windowMs ?? 6e4;
790
+ if (input.openContacts.length === 0) return { kind: "clear" };
791
+ const previous = input.previous;
792
+ if (previous !== null && previous.mode === input.mode && input.at - previous.at <= window) return {
793
+ kind: "bypassed",
794
+ bypassed: input.openContacts
795
+ };
796
+ return {
797
+ kind: "refused",
798
+ blocking: input.openContacts,
799
+ retryUntil: input.at + window
800
+ };
801
+ }
802
+ /**
803
+ * The error `alarmPanel.arm` throws when it refuses.
804
+ *
805
+ * A THROW and not a return value, and the reason is a train: the hub's
806
+ * generated router validates this method's response against the HOST's copy of
807
+ * the cap, which says `z.void()`. An addon that answered a structured refusal
808
+ * would have it rejected by the router in front of it — so the refusal reaches
809
+ * the caller as the one channel that already works across that boundary, and
810
+ * the operator's PHONE gets the sentence (`alarm-arm-refused`, through the
811
+ * ordinary outbox) rather than a UI error being the only trace.
812
+ *
813
+ * The message is deliberately actionable and English (an exception message is
814
+ * a developer/API surface; the notification is the operator's).
815
+ */
816
+ var NcAlarmArmRefusedError = class extends Error {
817
+ code = "alarm-arm-refused";
818
+ mode;
819
+ blocking;
820
+ retryUntil;
821
+ constructor(input) {
822
+ const names = input.blocking.map((c) => c.name).join(", ");
823
+ const seconds = Math.round((input.windowMs ?? 6e4) / 1e3);
824
+ super(`arm refused — open: ${names}. Arm ${input.mode} again within ${String(seconds)}s to arm with them excluded.`);
825
+ this.name = "NcAlarmArmRefusedError";
826
+ this.mode = input.mode;
827
+ this.blocking = input.blocking;
828
+ this.retryUntil = input.retryUntil;
829
+ }
830
+ };
831
+ //#endregion
832
+ //#region src/notification-center/alarm/alarm-exclusions.ts
833
+ function isPhase(value) {
834
+ return value === "open" || value === "closed";
835
+ }
836
+ var NcAlarmExclusions = class {
837
+ entries = [];
838
+ /** Replace the whole set — one arm, one set of exclusions. */
839
+ exclude(deviceIds, at) {
840
+ this.entries = [...new Set(deviceIds)].map((deviceId) => ({
841
+ deviceId,
842
+ phase: "open",
843
+ at
844
+ }));
845
+ }
846
+ /** Every excluded sensor, in the order it was excluded. */
847
+ list() {
848
+ return this.entries.map((e) => e.deviceId);
849
+ }
850
+ /** True while this sensor's events must not reach the panel. */
851
+ blocks(deviceId) {
852
+ return this.entries.some((e) => e.deviceId === deviceId);
853
+ }
854
+ /**
855
+ * Feed one contact reading, and say what it CHANGED.
856
+ *
857
+ * Three answers, not two, because the caller has to persist on both of the
858
+ * changes: the first `alarm-arm-refusal.spec.ts` run of this feature saved
859
+ * only the re-arm, so a hub redeployed between the close and the re-open came
860
+ * back with the sensor still in phase `open` — and the exclusion then needed
861
+ * a SECOND close to lift. A phase that lives only in RAM is half a durable
862
+ * exclusion.
863
+ *
864
+ * An unreadable state changes nothing (D49): a failed read must never lift an
865
+ * exclusion the operator asked for, and must never swallow the close that
866
+ * would have.
867
+ */
868
+ observe(deviceId, reading, _at) {
869
+ if (reading === void 0) return "unchanged";
870
+ const current = this.entries.find((e) => e.deviceId === deviceId);
871
+ if (current === void 0) return "unchanged";
872
+ if (reading === "closed") {
873
+ if (current.phase === "closed") return "unchanged";
874
+ this.entries = this.entries.map((e) => e.deviceId === deviceId ? {
875
+ ...e,
876
+ phase: "closed"
877
+ } : e);
878
+ return "closed";
879
+ }
880
+ if (current.phase === "open") return "unchanged";
881
+ this.entries = this.entries.filter((e) => e.deviceId !== deviceId);
882
+ return "rearmed";
883
+ }
884
+ /** The arm this belongs to is over. */
885
+ clear() {
886
+ this.entries = [];
887
+ }
888
+ /** What to persist. Plain data — it rides the panel's config blob. */
889
+ snapshot() {
890
+ return this.entries.map((e) => ({
891
+ deviceId: e.deviceId,
892
+ phase: e.phase,
893
+ at: e.at
894
+ }));
895
+ }
896
+ /**
897
+ * Rebuild from the persisted blob — a GUARD, never a cast.
898
+ *
899
+ * Anything unrecognised restores NOTHING, which is the failure that shows
900
+ * itself (a bypassed sensor that fires) rather than the one that hides (a
901
+ * sensor excluded forever by a blob nobody can read). A bare id with no phase
902
+ * restores as still-OPEN: it needs a close before it can re-arm, which is the
903
+ * half that cannot cause a false alarm.
904
+ */
905
+ restore(raw) {
906
+ if (!Array.isArray(raw)) return;
907
+ const out = [];
908
+ for (const item of raw) {
909
+ if (typeof item === "number" && Number.isInteger(item)) {
910
+ out.push({
911
+ deviceId: item,
912
+ phase: "open",
913
+ at: 0
914
+ });
915
+ continue;
916
+ }
917
+ if (item === null || typeof item !== "object") continue;
918
+ const rec = { ...item };
919
+ const deviceId = rec["deviceId"];
920
+ if (typeof deviceId !== "number" || !Number.isInteger(deviceId)) continue;
921
+ const phase = isPhase(rec["phase"]) ? rec["phase"] : "open";
922
+ const at = typeof rec["at"] === "number" ? rec["at"] : 0;
923
+ out.push({
924
+ deviceId,
925
+ phase,
926
+ at
927
+ });
928
+ }
929
+ this.entries = out;
930
+ }
931
+ };
932
+ /**
566
933
  * WHO the panel's current countdown belongs to.
567
934
  *
568
935
  * Small and separate because its whole content is a LIFETIME, and a lifetime
@@ -585,6 +952,26 @@ var NcAlarmAttribution = class {
585
952
  };
586
953
  }
587
954
  /**
955
+ * The note that has not been claimed yet — WITHOUT consuming it.
956
+ *
957
+ * The panel has to know which SENSOR is about to set the alarm off before it
958
+ * decides whether to accept the trigger at all (an excluded contact does
959
+ * not), and asking must not spend the note: a trigger that is accepted still
960
+ * needs it to build the combined notification. Expired reads as absent, on
961
+ * the same bound {@link claim} uses.
962
+ */
963
+ pending(now) {
964
+ const noted = this.noted;
965
+ if (noted === null) return null;
966
+ if (now - noted.at > 3e4) return null;
967
+ return noted.source;
968
+ }
969
+ /** Throw the un-claimed note away — the trigger it described was REFUSED, and
970
+ * a note left behind would be inherited by the next real one. */
971
+ dropPending() {
972
+ this.noted = null;
973
+ }
974
+ /**
588
975
  * Called by an ACCEPTED trigger, and by every accepted trigger — including
589
976
  * one with no note, so a manual trigger clears whatever a rule left behind
590
977
  * rather than inheriting it.
@@ -639,6 +1026,7 @@ function alarmTransition(input) {
639
1026
  at,
640
1027
  ...input.source !== void 0 ? { source: input.source } : {}
641
1028
  };
1029
+ if (next === "arming") return armingTransition(previous, input);
642
1030
  if (shouldAnnounceArm(previous, next)) {
643
1031
  const mode = armModeOf(next, input.modes);
644
1032
  if (mode === null) return null;
@@ -658,6 +1046,29 @@ function alarmTransition(input) {
658
1046
  };
659
1047
  return null;
660
1048
  }
1049
+ /**
1050
+ * The exit delay, as one notification at its START.
1051
+ *
1052
+ * Reached only from a state CHANGE into `arming`, so the once-a-second
1053
+ * re-publish is already excluded by the caller's `previous === next` guard —
1054
+ * the countdown is one notification, not thirty. Null when the panel did not
1055
+ * say what it is arming into, or named a mode it does not offer: both would
1056
+ * produce a message with no title and no number in it.
1057
+ */
1058
+ function armingTransition(previous, input) {
1059
+ const arming = input.arming;
1060
+ if (arming === void 0) return null;
1061
+ const mode = armModeOf(arming.target, input.modes);
1062
+ if (mode === null) return null;
1063
+ return {
1064
+ kind: "alarm-arming",
1065
+ state: "arming",
1066
+ previous,
1067
+ mode,
1068
+ seconds: Math.max(0, Math.ceil((arming.armedAt - input.at) / 1e3)),
1069
+ at: input.at
1070
+ };
1071
+ }
661
1072
  //#endregion
662
1073
  //#region src/notification-center/alarm/alarm-panel-device.ts
663
1074
  /**
@@ -704,7 +1115,17 @@ var ncAlarmPanelSchema = object({
704
1115
  announceArm: boolean().default(false),
705
1116
  /** Target ids that announcement goes to — see `NcAlarmSettingsSchema`. */
706
1117
  announceTargets: array(string()).default([]),
707
- machine: record(string(), unknown()).optional()
1118
+ machine: record(string(), unknown()).optional(),
1119
+ /**
1120
+ * Sensors the CURRENT arm is ignoring because they were open when the
1121
+ * operator confirmed it, and how far each is through its two-phase lift.
1122
+ *
1123
+ * Persisted for the same reason the machine is: a deploy in the middle of an
1124
+ * armed night would otherwise silently re-arm every sensor the operator
1125
+ * agreed to leave out, and they would learn about it from the siren. Read
1126
+ * back through a guard, never a cast — see `NcAlarmExclusions.restore`.
1127
+ */
1128
+ exclusions: array(unknown()).default([])
708
1129
  });
709
1130
  /** `armed_home` and friends — the only targets a machine may settle into. */
710
1131
  function isArmTarget(value) {
@@ -754,6 +1175,19 @@ var NcAlarmPanelDevice = class extends BaseDevice {
754
1175
  lastPublished = null;
755
1176
  /** WHO the current countdown belongs to — see {@link NcAlarmAttribution}. */
756
1177
  attribution = new NcAlarmAttribution();
1178
+ /** Sensors this arm is ignoring — see {@link NcAlarmExclusions}. */
1179
+ exclusions = new NcAlarmExclusions();
1180
+ /** Who answers "is anything open" — null until the centre wires itself in. */
1181
+ armGuard = null;
1182
+ /**
1183
+ * The arm attempt that was REFUSED, and is therefore confirmable.
1184
+ *
1185
+ * In RAM and only in RAM, deliberately: it is a confirmation of something the
1186
+ * operator read seconds ago, and a confirmation that survived a restart would
1187
+ * let a process respawn arm the alarm around an open window nobody re-read.
1188
+ * Losing it costs one extra press.
1189
+ */
1190
+ lastRefusal = null;
757
1191
  constructor(ctx) {
758
1192
  super(ctx, ncAlarmPanelSchema, { type: ctx.deviceMeta.type });
759
1193
  this.delays = this.readDelays();
@@ -811,26 +1245,164 @@ var NcAlarmPanelDevice = class extends BaseDevice {
811
1245
  currentState(now = Date.now()) {
812
1246
  return stateAt(this.machine, now);
813
1247
  }
1248
+ /** Wire the open-contact check and the refusal announcement. */
1249
+ setArmGuard(hook) {
1250
+ this.armGuard = hook;
1251
+ }
1252
+ /**
1253
+ * Sensors the current arm is ignoring — what the armed/arming sentence names
1254
+ * as excluded, and the only place that list is published.
1255
+ */
1256
+ excludedDevices() {
1257
+ return this.exclusions.list();
1258
+ }
1259
+ /**
1260
+ * One contact reading, from whoever saw it — the sensor's own event, or the
1261
+ * centre's reconcile tick.
1262
+ *
1263
+ * The lift is two-phase (`alarm-exclusions.ts`), so this is safe to call with
1264
+ * the same reading repeatedly, and an unreadable state changes nothing. Fed
1265
+ * BEFORE the event that carried it is evaluated, so the open that re-arms a
1266
+ * sensor is already lifted when the rule it fires reaches the panel.
1267
+ */
1268
+ observeContact(deviceId, reading, now = Date.now()) {
1269
+ const change = this.exclusions.observe(deviceId, reading, now);
1270
+ if (change === "unchanged") return;
1271
+ this.persist();
1272
+ if (change === "closed") return;
1273
+ this.ctx.logger.info("alarm exclusion lifted — the sensor closed and opened again", {
1274
+ tags: { deviceId },
1275
+ meta: {
1276
+ panelDeviceId: this.id,
1277
+ remaining: this.exclusions.list().length
1278
+ }
1279
+ });
1280
+ }
814
1281
  /** Stop the countdown when the device goes away. The state itself is
815
1282
  * persisted, so nothing is lost — only the announcements stop. */
816
1283
  stopCountdown() {
817
1284
  this.stopTicking();
818
1285
  }
1286
+ /**
1287
+ * Refuse the arm, bypass around it, or let it through — and leave the
1288
+ * exclusions the arm will run with.
1289
+ *
1290
+ * Fail-OPEN by construction, in three places, and each one is a decision:
1291
+ *
1292
+ * - no guard wired (an agent node, a boot pass before the centre exists) ⇒
1293
+ * arm. A panel that cannot ask must not refuse.
1294
+ * - the guard threw ⇒ arm, and SAY so. Refusing on a failed read would tell
1295
+ * the operator to go and shut a window that is already shut (the coverage
1296
+ * derivation makes the same choice: an unreadable authority answers
1297
+ * "unknown", never "excluded").
1298
+ * - nothing reads open ⇒ arm clean, and any exclusion a previous arm left
1299
+ * is dropped.
1300
+ *
1301
+ * The refusal throws. See {@link NcAlarmArmRefusedError} for why that is the
1302
+ * channel rather than a return value.
1303
+ */
1304
+ async applyArmGuard(mode, now) {
1305
+ const guard = this.armGuard;
1306
+ if (guard === null) return;
1307
+ let openContacts;
1308
+ try {
1309
+ openContacts = await guard.openContacts(mode);
1310
+ } catch (err) {
1311
+ this.ctx.logger.warn("alarm arm guard could not read the contacts — arming anyway", {
1312
+ tags: { deviceId: this.id },
1313
+ meta: {
1314
+ mode,
1315
+ error: String(err)
1316
+ }
1317
+ });
1318
+ return;
1319
+ }
1320
+ const verdict = decideArm({
1321
+ mode,
1322
+ openContacts,
1323
+ previous: this.lastRefusal,
1324
+ at: now
1325
+ });
1326
+ if (verdict.kind === "clear") {
1327
+ this.lastRefusal = null;
1328
+ if (this.exclusions.list().length > 0) {
1329
+ this.exclusions.clear();
1330
+ this.persist();
1331
+ }
1332
+ return;
1333
+ }
1334
+ if (verdict.kind === "bypassed") {
1335
+ this.lastRefusal = null;
1336
+ this.exclusions.exclude(verdict.bypassed.map((c) => c.deviceId), now);
1337
+ this.persist();
1338
+ for (const contact of verdict.bypassed) this.ctx.logger.info("alarm armed with an open sensor excluded", {
1339
+ tags: { deviceId: contact.deviceId },
1340
+ meta: {
1341
+ panelDeviceId: this.id,
1342
+ mode,
1343
+ name: contact.name
1344
+ }
1345
+ });
1346
+ return;
1347
+ }
1348
+ this.lastRefusal = {
1349
+ mode,
1350
+ at: now
1351
+ };
1352
+ for (const contact of verdict.blocking) this.ctx.logger.warn("alarm arm REFUSED — this sensor is open", {
1353
+ tags: { deviceId: contact.deviceId },
1354
+ meta: {
1355
+ panelDeviceId: this.id,
1356
+ mode,
1357
+ name: contact.name
1358
+ }
1359
+ });
1360
+ guard.announceRefusal({
1361
+ mode,
1362
+ blocking: verdict.blocking,
1363
+ retrySeconds: Math.round(ARM_BYPASS_WINDOW_MS / 1e3),
1364
+ at: now
1365
+ });
1366
+ throw new NcAlarmArmRefusedError({
1367
+ mode,
1368
+ blocking: verdict.blocking,
1369
+ retryUntil: verdict.retryUntil
1370
+ });
1371
+ }
819
1372
  registerAlarmCap() {
820
1373
  this.ctx.registerNativeCap(alarmPanelCapability, {
821
1374
  getStatus: async () => this.status(),
822
1375
  arm: async ({ mode }) => {
823
- this.machine = arm(mode, this.delays, Date.now());
1376
+ const now = Date.now();
1377
+ await this.applyArmGuard(mode, now);
1378
+ this.machine = arm(mode, this.delays, now);
824
1379
  this.attribution.clear();
825
- this.publish();
1380
+ this.publish(now);
826
1381
  },
827
1382
  disarm: async () => {
828
1383
  this.machine = disarm(Date.now());
829
1384
  this.attribution.clear();
1385
+ this.exclusions.clear();
1386
+ this.lastRefusal = null;
830
1387
  this.publish();
831
1388
  },
832
1389
  trigger: async () => {
833
1390
  const now = Date.now();
1391
+ const pending = this.attribution.pending(now);
1392
+ const source = pending?.sourceDeviceId;
1393
+ if (source !== void 0 && this.exclusions.blocks(source)) {
1394
+ this.attribution.dropPending();
1395
+ this.ctx.logger.info("alarm trigger ignored — the sensor was open when the alarm was armed", {
1396
+ tags: { deviceId: source },
1397
+ meta: {
1398
+ panelDeviceId: this.id,
1399
+ ruleId: pending?.ruleId,
1400
+ rule: pending?.ruleName,
1401
+ state: stateAt(this.machine, now)
1402
+ }
1403
+ });
1404
+ return;
1405
+ }
834
1406
  const next = trigger(this.machine, this.delays, now);
835
1407
  if (next === this.machine) {
836
1408
  this.ctx.logger.info("alarm trigger ignored — the panel is not armed", {
@@ -890,12 +1462,14 @@ var NcAlarmPanelDevice = class extends BaseDevice {
890
1462
  const hook = this.onPublish;
891
1463
  if (hook === null) return;
892
1464
  const source = this.attribution.current();
1465
+ const arming = exitDelayAt(this.machine, now);
893
1466
  const transition = alarmTransition({
894
1467
  previous,
895
1468
  next: state,
896
1469
  modes: NC_ALARM_MODES,
897
1470
  at: now,
898
- ...source !== null ? { source } : {}
1471
+ ...source !== null ? { source } : {},
1472
+ ...arming !== void 0 ? { arming } : {}
899
1473
  });
900
1474
  if (transition?.kind === "alarm-triggered") this.attribution.clear();
901
1475
  try {
@@ -933,11 +1507,14 @@ var NcAlarmPanelDevice = class extends BaseDevice {
933
1507
  this.ticker = null;
934
1508
  }
935
1509
  /** The machine rides the device's own config blob — it is small, and it must
936
- * survive a restart or the alarm disarms itself on every deploy. */
1510
+ * survive a restart or the alarm disarms itself on every deploy. The
1511
+ * exclusions ride with it, for the same reason and with the same lifetime. */
937
1512
  persist() {
938
1513
  this.config.set("machine", { ...this.machine });
1514
+ this.config.set("exclusions", [...this.exclusions.snapshot()]);
939
1515
  }
940
1516
  restore() {
1517
+ this.exclusions.restore(this.config.get("exclusions"));
941
1518
  const restored = machineFromPersisted(this.config.get("machine"));
942
1519
  if (restored === null) return;
943
1520
  this.machine = restored;
@@ -1713,10 +2290,15 @@ var MAX_ARM_BUTTONS = 3;
1713
2290
  *
1714
2291
  * | notification | buttons |
1715
2292
  * | --- | --- |
2293
+ * | `alarm-arming` | **Disarma**, destructive |
1716
2294
  * | `alarm-triggered` | **Disarma**, destructive |
1717
2295
  * | `alarm-armed` | **Disarma**, destructive |
1718
2296
  * | `alarm-disarmed` | **Arma …** per declared mode, max three |
1719
2297
  *
2298
+ * The `alarm-arming` row is the one that earns its keep: that notification
2299
+ * arrives at the START of the exit delay, so its Disarm is the only alarm
2300
+ * button an operator can press to cancel something rather than to undo it.
2301
+ *
1720
2302
  * `destructive` on the disarm is a RENDERING hint and never a gate (D47) — what
1721
2303
  * actually bounds it is the five-minute TTL the minter gives that grant
1722
2304
  * (`ALARM_DISARM_TTL_MS`). Both exist: the hint is for the person, the TTL is
@@ -3766,10 +4348,52 @@ var en_default = {
3766
4348
  "one": "{{devices}} device armed: {{names}}.",
3767
4349
  "other": "{{devices}} devices armed: {{names}}."
3768
4350
  },
4351
+ "alarm.arm.body.devicesSkipped": {
4352
+ "one": "{{devices}} device armed: {{names}}. Excluded: {{skippedNames}}.",
4353
+ "other": "{{devices}} devices armed: {{names}}. Excluded: {{skippedNames}}."
4354
+ },
4355
+ "alarm.arm.body.uncovered": "Nothing is armed — no enabled rule is gated on this mode.",
4356
+ "alarm.arm.body.allSkipped": {
4357
+ "one": "Nothing is armed: the {{devices}} device this mode covers is excluded ({{names}}).",
4358
+ "other": "Nothing is armed: all {{devices}} devices this mode covers are excluded ({{names}})."
4359
+ },
4360
+ "alarm.arming.body": {
4361
+ "one": "Armed in {{seconds}} second.",
4362
+ "other": "Armed in {{seconds}} seconds."
4363
+ },
4364
+ "alarm.arming.body.uncovered": {
4365
+ "one": "Armed in {{seconds}} second — but no enabled rule is gated on this mode, so it will protect nothing.",
4366
+ "other": "Armed in {{seconds}} seconds — but no enabled rule is gated on this mode, so it will protect nothing."
4367
+ },
4368
+ "alarm.arming.body.allSkipped": {
4369
+ "one": "Armed in {{seconds}} second — but every device this mode covers is excluded ({{names}}), so it will protect nothing.",
4370
+ "other": "Armed in {{seconds}} seconds — but every device this mode covers is excluded ({{names}}), so it will protect nothing."
4371
+ },
4372
+ "alarm.arm.body.bypassed": {
4373
+ "one": "{{devices}} device armed — open and excluded: {{bypassedNames}}.",
4374
+ "other": "{{devices}} devices armed — open and excluded: {{bypassedNames}}."
4375
+ },
4376
+ "alarm.arming.body.bypassed": {
4377
+ "one": "Armed in {{seconds}} second, excluding what is open: {{bypassedNames}}.",
4378
+ "other": "Armed in {{seconds}} seconds, excluding what is open: {{bypassedNames}}."
4379
+ },
4380
+ "alarm.armRefused.body": {
4381
+ "one": "{{names}} is open. Press arm again within {{seconds}}s to arm without it.",
4382
+ "other": "These are open: {{names}}. Press arm again within {{seconds}}s to arm without them."
4383
+ },
3769
4384
  "alarm.device.unnamed": "Device {{id}}",
4385
+ "system.alarm-arm-refused.title.home": "Home NOT armed",
4386
+ "system.alarm-arm-refused.title.away": "Away NOT armed",
4387
+ "system.alarm-arm-refused.title.night": "Night NOT armed",
4388
+ "system.alarm-arm-refused.body": "The alarm could not be armed — something it covers is open.",
4389
+ "system.alarm-arming.title.home": "Arming home",
4390
+ "system.alarm-arming.title.away": "Arming away",
4391
+ "system.alarm-arming.title.night": "Arming night",
4392
+ "system.alarm-arming.body": "The alarm is arming",
3770
4393
  "system.alarm-armed.title.home": "Home armed",
3771
4394
  "system.alarm-armed.title.away": "Away armed",
3772
4395
  "system.alarm-armed.title.night": "Night armed",
4396
+ "system.alarm-armed.body": "The alarm was armed",
3773
4397
  "system.alarm-disarmed.title": "Alarm disarmed",
3774
4398
  "system.alarm-disarmed.body": "The alarm was disarmed",
3775
4399
  "system.alarm-triggered.title": "ALARM",
@@ -3918,10 +4542,52 @@ var it_default = {
3918
4542
  "one": "{{devices}} dispositivo attivo: {{names}}.",
3919
4543
  "other": "{{devices}} dispositivi attivi: {{names}}."
3920
4544
  },
4545
+ "alarm.arm.body.devicesSkipped": {
4546
+ "one": "{{devices}} dispositivo attivo: {{names}}. Esclusi: {{skippedNames}}.",
4547
+ "other": "{{devices}} dispositivi attivi: {{names}}. Esclusi: {{skippedNames}}."
4548
+ },
4549
+ "alarm.arm.body.uncovered": "Non è attivo nulla — nessuna regola abilitata è legata a questa modalità.",
4550
+ "alarm.arm.body.allSkipped": {
4551
+ "one": "Non è attivo nulla: l'unico dispositivo ({{devices}}) coperto da questa modalità è escluso ({{names}}).",
4552
+ "other": "Non è attivo nulla: tutti i {{devices}} dispositivi coperti da questa modalità sono esclusi ({{names}})."
4553
+ },
4554
+ "alarm.arming.body": {
4555
+ "one": "Attivo tra {{seconds}} secondo.",
4556
+ "other": "Attivo tra {{seconds}} secondi."
4557
+ },
4558
+ "alarm.arming.body.uncovered": {
4559
+ "one": "Attivo tra {{seconds}} secondo — ma nessuna regola abilitata è legata a questa modalità: non proteggerà nulla.",
4560
+ "other": "Attivo tra {{seconds}} secondi — ma nessuna regola abilitata è legata a questa modalità: non proteggerà nulla."
4561
+ },
4562
+ "alarm.arming.body.allSkipped": {
4563
+ "one": "Attivo tra {{seconds}} secondo — ma ogni dispositivo coperto da questa modalità è escluso ({{names}}): non proteggerà nulla.",
4564
+ "other": "Attivo tra {{seconds}} secondi — ma ogni dispositivo coperto da questa modalità è escluso ({{names}}): non proteggerà nulla."
4565
+ },
4566
+ "alarm.arm.body.bypassed": {
4567
+ "one": "{{devices}} dispositivo attivo — aperto ed escluso: {{bypassedNames}}.",
4568
+ "other": "{{devices}} dispositivi attivi — aperti ed esclusi: {{bypassedNames}}."
4569
+ },
4570
+ "alarm.arming.body.bypassed": {
4571
+ "one": "Attivo tra {{seconds}} secondo, escludendo ciò che è aperto: {{bypassedNames}}.",
4572
+ "other": "Attivo tra {{seconds}} secondi, escludendo ciò che è aperto: {{bypassedNames}}."
4573
+ },
4574
+ "alarm.armRefused.body": {
4575
+ "one": "{{names}} è aperto. Premi di nuovo entro {{seconds}}s per inserire senza di esso.",
4576
+ "other": "Sono aperti: {{names}}. Premi di nuovo entro {{seconds}}s per inserire senza di essi."
4577
+ },
3921
4578
  "alarm.device.unnamed": "Dispositivo {{id}}",
4579
+ "system.alarm-arm-refused.title.home": "Inserimento Casa rifiutato",
4580
+ "system.alarm-arm-refused.title.away": "Inserimento Fuori rifiutato",
4581
+ "system.alarm-arm-refused.title.night": "Inserimento Notte rifiutato",
4582
+ "system.alarm-arm-refused.body": "L'allarme non è stato inserito — qualcosa che copre è aperto.",
4583
+ "system.alarm-arming.title.home": "Inserimento Casa",
4584
+ "system.alarm-arming.title.away": "Inserimento Fuori",
4585
+ "system.alarm-arming.title.night": "Inserimento Notte",
4586
+ "system.alarm-arming.body": "L'allarme si sta inserendo",
3922
4587
  "system.alarm-armed.title.home": "Casa attivato",
3923
4588
  "system.alarm-armed.title.away": "Fuori attivato",
3924
4589
  "system.alarm-armed.title.night": "Notte attivato",
4590
+ "system.alarm-armed.body": "L'allarme è stato inserito",
3925
4591
  "system.alarm-disarmed.title": "Allarme disattivato",
3926
4592
  "system.alarm-disarmed.body": "L'allarme è stato disattivato",
3927
4593
  "system.alarm-triggered.title": "ALLARME",
@@ -5593,16 +6259,16 @@ function incomingFromAlarmTransition(input) {
5593
6259
  kind,
5594
6260
  subject: `alarm:${String(panelDeviceId)}`,
5595
6261
  titleKey: transition.mode !== void 0 ? `system.${kind}.title.${transition.mode}` : `system.${kind}.title`,
5596
- bodyKey: input.arm?.bodyKey ?? (source !== void 0 ? `system.${kind}.body.rule` : `system.${kind}.body`),
6262
+ bodyKey: input.body?.bodyKey ?? (source !== void 0 ? `system.${kind}.body.rule` : `system.${kind}.body`),
5597
6263
  textParams: {
5598
6264
  ...deviceTextParams(deviceId, identity),
5599
6265
  ...transition.mode !== void 0 ? { mode: transition.mode } : {},
5600
6266
  ...source !== void 0 ? { rule: source.ruleName } : {},
5601
- ...input.arm?.textParams
6267
+ ...input.body?.textParams
5602
6268
  },
5603
6269
  deviceId,
5604
6270
  ...identity !== void 0 ? { deviceType: identity.type } : {},
5605
- ...input.arm !== void 0 ? { textCount: input.arm.textCount } : {},
6271
+ ...input.body !== void 0 ? { textCount: input.body.textCount } : {},
5606
6272
  ...source !== void 0 ? { alarmSource: source } : {}
5607
6273
  });
5608
6274
  }
@@ -6189,6 +6855,16 @@ var OccupancyWatcher = class {
6189
6855
  watched = /* @__PURE__ */ new Map();
6190
6856
  /** Confirmed + pending state, keyed by the full {@link OccupancyKey}. */
6191
6857
  states = /* @__PURE__ */ new Map();
6858
+ /** Rows SEEDED since the last {@link takeSeededRows} — the durable
6859
+ * write-through queue (see that method). */
6860
+ seeded = /* @__PURE__ */ new Map();
6861
+ /** Keys already reported through `onImpossibleScope` (once-per-key gate).
6862
+ * Pruned with the watched set, so re-authoring a rule reports again. */
6863
+ impossibleReported = /* @__PURE__ */ new Set();
6864
+ deps;
6865
+ constructor(deps = {}) {
6866
+ this.deps = deps;
6867
+ }
6192
6868
  /**
6193
6869
  * Replace the watched key set (rule-driven — recomputed on rule change).
6194
6870
  * Each distinct `(zone, class, threshold)` is its OWN watched partial key
@@ -6219,6 +6895,8 @@ var OccupancyWatcher = class {
6219
6895
  });
6220
6896
  }
6221
6897
  for (const key of [...this.states.keys()]) if (!this.watched.has(partialKeyOf(key))) this.states.delete(key);
6898
+ for (const key of [...this.seeded.keys()]) if (!this.watched.has(partialKeyOf(key))) this.seeded.delete(key);
6899
+ for (const key of [...this.impossibleReported]) if (!this.watched.has(partialKeyOf(key))) this.impossibleReported.delete(key);
6222
6900
  }
6223
6901
  /**
6224
6902
  * Feed one camera snapshot at time `now`, returning the edges that COMMIT on
@@ -6231,9 +6909,12 @@ var OccupancyWatcher = class {
6231
6909
  const resolved = resolveOccupancyScope(snapshot, spec);
6232
6910
  if (resolved === null) continue;
6233
6911
  const key = occupancyKey(deviceId, spec.zoneId, spec.className, spec.threshold);
6912
+ this.reportImpossibleScope(deviceId, key, spec);
6234
6913
  const existing = this.states.get(key);
6235
6914
  if (existing === void 0) {
6236
- this.states.set(key, seedState(deviceId, spec, resolved, now));
6915
+ const state = seedState(deviceId, spec, resolved, now);
6916
+ this.states.set(key, state);
6917
+ this.seeded.set(key, stateToRow(key, state, now));
6237
6918
  continue;
6238
6919
  }
6239
6920
  const edge = step(existing, spec, resolved, now);
@@ -6241,6 +6922,38 @@ var OccupancyWatcher = class {
6241
6922
  }
6242
6923
  return edges;
6243
6924
  }
6925
+ /**
6926
+ * Drain the rows seeded since the last call — the caller persists them
6927
+ * through the SAME ledger the committed edges use.
6928
+ *
6929
+ * Drained rather than exposed because a seed is a ONE-TIME fact: re-offering
6930
+ * it would turn a rare write into a per-frame one, which is the thing the
6931
+ * edge-only write-through was designed to avoid. Missing a drain costs
6932
+ * nothing worse than today's behaviour (a cold re-seed at the next boot), so
6933
+ * the caller may fail the write without corrupting anything.
6934
+ */
6935
+ takeSeededRows() {
6936
+ const rows = [...this.seeded.values()];
6937
+ this.seeded.clear();
6938
+ return rows;
6939
+ }
6940
+ /** Fire `onImpossibleScope` the first time a key with an uncountable class
6941
+ * scope is evaluated. See {@link OccupancyImpossibleScope}. */
6942
+ reportImpossibleScope(deviceId, key, spec) {
6943
+ const report = this.deps.onImpossibleScope;
6944
+ if (report === void 0) return;
6945
+ const className = spec.className;
6946
+ if (className === void 0 || isDetectionMacroClass(className)) return;
6947
+ if (this.impossibleReported.has(key)) return;
6948
+ this.impossibleReported.add(key);
6949
+ report({
6950
+ deviceId,
6951
+ key,
6952
+ ...spec.zoneId !== void 0 ? { zoneId: spec.zoneId } : {},
6953
+ className,
6954
+ threshold: spec.threshold
6955
+ });
6956
+ }
6244
6957
  /** Reseed confirmed state from durable rows (boot). Only rows whose key is
6245
6958
  * currently WATCHED are restored — an orphaned durable row (its rule gone)
6246
6959
  * is dropped, keeping the RAM map bounded to the active set. Pending edges
@@ -6263,20 +6976,25 @@ var OccupancyWatcher = class {
6263
6976
  /** Snapshot the CONFIRMED state for durable persistence (pending excluded). */
6264
6977
  snapshotState() {
6265
6978
  const rows = [];
6266
- for (const [key, state] of this.states) rows.push({
6267
- key,
6268
- deviceId: state.deviceId,
6269
- ...state.zoneId !== void 0 ? { zoneId: state.zoneId } : {},
6270
- ...state.className !== void 0 ? { className: state.className } : {},
6271
- threshold: state.threshold,
6272
- confirmedCount: state.confirmedCount,
6273
- occupied: state.occupied,
6274
- lastChangeAt: state.lastChangeAt,
6275
- updatedAt: state.lastChangeAt
6276
- });
6979
+ for (const [key, state] of this.states) rows.push(stateToRow(key, state, state.lastChangeAt));
6277
6980
  return rows;
6278
6981
  }
6279
6982
  };
6983
+ /** The durable projection of one in-RAM state — the ONE place the persisted row
6984
+ * shape is built, so a seeded row and a snapshotted one cannot differ. */
6985
+ function stateToRow(key, state, updatedAt) {
6986
+ return {
6987
+ key,
6988
+ deviceId: state.deviceId,
6989
+ ...state.zoneId !== void 0 ? { zoneId: state.zoneId } : {},
6990
+ ...state.className !== void 0 ? { className: state.className } : {},
6991
+ threshold: state.threshold,
6992
+ confirmedCount: state.confirmedCount,
6993
+ occupied: state.occupied,
6994
+ lastChangeAt: state.lastChangeAt,
6995
+ updatedAt
6996
+ };
6997
+ }
6280
6998
  /**
6281
6999
  * The state a key starts from: WHAT THE FIRST SNAPSHOT SAID.
6282
7000
  *
@@ -6440,7 +7158,11 @@ function probeOccupancyNow(input) {
6440
7158
  * field for them would be a cross-package change for a sentence.
6441
7159
  */
6442
7160
  function occupancyProbeLabel(occupancy, probe, cooldownSec) {
6443
- 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?)";
7161
+ const classNote = impossibleClassNote(occupancy.className);
7162
+ if (probe.kind === "unavailable") {
7163
+ 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?)";
7164
+ return classNote === null ? reason : `${classNote} · ${reason}`;
7165
+ }
6444
7166
  const where = probe.zoneName ?? (occupancy.zoneId === void 0 ? "whole frame" : occupancy.zoneId);
6445
7167
  const what = occupancy.className ?? "objects";
6446
7168
  const parts = [
@@ -6449,7 +7171,21 @@ function occupancyProbeLabel(occupancy, probe, cooldownSec) {
6449
7171
  `fires on the transition, sustained ${String(occupancy.sustainSeconds)} s`
6450
7172
  ];
6451
7173
  if (cooldownSec > 0) parts.push(`cooldown ${String(cooldownSec)} s`);
6452
- return parts.join(" · ");
7174
+ return classNote === null ? parts.join(" · ") : `${classNote} · ${parts.join(" · ")}`;
7175
+ }
7176
+ /**
7177
+ * The one-clause warning for a class scope the snapshots can never key on, or
7178
+ * `null` when the scope is fine.
7179
+ *
7180
+ * The dry-run's whole job is to make "why does this rule never fire?" answerable
7181
+ * before the operator waits a night for it. A sub-type class produced the most
7182
+ * convincing wrong answer it can give — "0/1 car · not occupied now" — which is
7183
+ * pixel-identical to an empty parking space. `byClass` is MACRO-keyed, so that
7184
+ * counter is 0 structurally and no scene can change it.
7185
+ */
7186
+ function impossibleClassNote(className) {
7187
+ if (className === void 0 || isDetectionMacroClass(className)) return null;
7188
+ return `⚠ “${className}” is a sub-type, not a macro class: this counter is always 0 (use ${[...DETECTION_MACRO_CLASSES].join(" / ")})`;
6453
7189
  }
6454
7190
  /**
6455
7191
  * The mechanics, declared once.
@@ -11753,7 +12489,7 @@ var NC_ALARM_ANNOUNCE_RULE_ID = "nc-alarm-announce";
11753
12489
  * notification that says the alarm was disarmed is worse than offering nothing.
11754
12490
  */
11755
12491
  function alarmButtonKind(kind) {
11756
- if (kind === "alarm-triggered" || kind === "alarm-armed" || kind === "alarm-disarmed") return kind;
12492
+ if (kind === "alarm-triggered" || kind === "alarm-armed" || kind === "alarm-arming" || kind === "alarm-disarmed") return kind;
11757
12493
  return null;
11758
12494
  }
11759
12495
  /**
@@ -11838,6 +12574,7 @@ function fireRefFor(rule, subject, kind) {
11838
12574
  ruleId: rule.id,
11839
12575
  ruleName: rule.name,
11840
12576
  deviceId: subject.deviceId,
12577
+ ...subject.sourceDeviceId !== void 0 ? { sourceDeviceId: subject.sourceDeviceId } : {},
11841
12578
  recordId: subject.recordId,
11842
12579
  ...subject.trackId !== void 0 ? { trackId: subject.trackId } : {},
11843
12580
  ...hasEventMedia ? { eventId: subject.recordId } : {},
@@ -11880,17 +12617,55 @@ function historyRecordKind(kind) {
11880
12617
  * showed a key while the phone showed a sentence would be a second vocabulary
11881
12618
  * to keep in step. A pre-catalog row falls back to the pair frozen on it.
11882
12619
  */
12620
+ /**
12621
+ * Cap-facing downgrade of a system-event kind — the SAME device as
12622
+ * {@link historyRecordKind}, and it exists for the same reason.
12623
+ *
12624
+ * This addon bundles its own `@camstack/types` (self-contained externals), so
12625
+ * it can emit a kind the enum in the RUNNING SERVER does not have. The history
12626
+ * router does not: it validates its output against the host's
12627
+ * `NcHistorySubjectSchema`, whose `kind` is a `z.enum`. One row carrying a kind
12628
+ * the host has never heard of therefore does not degrade — it fails the whole
12629
+ * `getHistory` call, for every row, exactly as the enum's own legacy-tail note
12630
+ * warns.
12631
+ *
12632
+ * `alarm-arming` is projected onto `alarm-armed`, its nearest member: same
12633
+ * panel, same lifecycle, adjacent instant. Nothing a person reads changes —
12634
+ * the row's title and body are resolved from the arming keys ("Inserimento
12635
+ * Fuori — attivo tra 30 secondi") — only the machine-facing word does.
12636
+ *
12637
+ * DELETE THIS when a `@camstack/server` closure carrying `alarm-arming` in
12638
+ * `NcSystemEventKindSchema` is installed everywhere this addon runs. Until
12639
+ * then it is what lets the exit-delay notification ship without a train.
12640
+ */
12641
+ /**
12642
+ * The mirrored device-state word, narrowed to a contact reading.
12643
+ *
12644
+ * Anything that is not one of the two contact words is `undefined` — a device
12645
+ * whose slice the state table read as `on` / `armed_home` is not a contact, and
12646
+ * an exclusion must never be lifted by a word that belongs to another cap.
12647
+ */
12648
+ function contactReadingOf(state) {
12649
+ if (state === "open" || state === "closed") return state;
12650
+ }
12651
+ function historySystemEventKind(kind) {
12652
+ if (kind === "alarm-arming") return "alarm-armed";
12653
+ if (kind === "alarm-arm-refused") return "alarm-disarmed";
12654
+ return kind;
12655
+ }
11883
12656
  function historySystemEvent(texts, systemEvent) {
11884
12657
  const vars = systemEvent.textParams ?? {};
12658
+ const count = systemEvent.textCount;
11885
12659
  const resolve = (key, frozen) => {
11886
12660
  if (key !== void 0 && texts.has(key)) return texts.text({
11887
12661
  key,
11888
- vars
12662
+ vars,
12663
+ ...count !== void 0 ? { count } : {}
11889
12664
  });
11890
12665
  return frozen ?? "";
11891
12666
  };
11892
12667
  return {
11893
- kind: systemEvent.kind,
12668
+ kind: historySystemEventKind(systemEvent.kind),
11894
12669
  subject: systemEvent.subject,
11895
12670
  title: resolve(systemEvent.titleKey, systemEvent.title),
11896
12671
  body: resolve(systemEvent.bodyKey, systemEvent.body),
@@ -11952,8 +12727,22 @@ var NotificationCenter = class NotificationCenter {
11952
12727
  /** Debounced occupancy edge state machine (pure) + its durable confirmed
11953
12728
  * state. Fed in-process by {@link observeOccupancy} from ZoneAnalytics
11954
12729
  * snapshots; watched keys are recomputed from the enabled occupancy rules. */
11955
- occupancyWatcher = new OccupancyWatcher();
12730
+ occupancyWatcher = new OccupancyWatcher({ onImpossibleScope: (scope) => {
12731
+ this.logger.warn("occupancy key can never count — class is a sub-type, not a macro", {
12732
+ tags: { deviceId: scope.deviceId },
12733
+ meta: {
12734
+ key: scope.key,
12735
+ zoneId: scope.zoneId ?? "@frame",
12736
+ className: scope.className,
12737
+ threshold: scope.threshold,
12738
+ macros: [...DETECTION_MACRO_CLASSES].join(",")
12739
+ }
12740
+ });
12741
+ } });
11956
12742
  occupancyStore;
12743
+ /** Rendered watched-occupancy-key set as last logged — the change gate for
12744
+ * {@link reportOccupancyWatch} (it runs on every rule-reload tick). */
12745
+ lastOccupancyWatchReport = "";
11957
12746
  /**
11958
12747
  * The sustained-sound sampling-window matcher (pure). Fed in-process by
11959
12748
  * {@link observeAudio} from the pipeline's audio inference frames; watched
@@ -12764,8 +13553,18 @@ var NotificationCenter = class NotificationCenter {
12764
13553
  * before it.
12765
13554
  */
12766
13555
  onSensorEventPersisted(event, markerTrackId) {
13556
+ this.observeContactEvent(event);
12767
13557
  this.consumeEvent(incomingFromSensorEvent(event, markerTrackId));
12768
13558
  }
13559
+ /** The contact reading a sensor row carries, handed to the panel. Nothing at
13560
+ * all for any other sensor kind — see {@link observeAlarmContacts}. */
13561
+ observeContactEvent(event) {
13562
+ const panel = this.alarmPanel;
13563
+ if (panel === null || event.kind !== "contact") return;
13564
+ const entryOpen = event.value?.["entryOpen"];
13565
+ if (typeof entryOpen !== "boolean") return;
13566
+ panel.observeContact(event.sourceDeviceId, entryOpen ? "open" : "closed", event.timestamp);
13567
+ }
12769
13568
  /**
12770
13569
  * Called at the AUDIO-event persist site (`eventStore.insertAudio`), in the
12771
13570
  * SAME moment as the durable insert. Feeds an `immediate` rule that OPTS IN
@@ -12812,7 +13611,20 @@ var NotificationCenter = class NotificationCenter {
12812
13611
  });
12813
13612
  return;
12814
13613
  }
13614
+ this.persistSeededOccupancy(now);
12815
13615
  for (const edge of edges) {
13616
+ this.logger.info("occupancy edge committed", {
13617
+ tags: { deviceId: edge.deviceId },
13618
+ meta: {
13619
+ zoneId: edge.zoneId ?? "@frame",
13620
+ zoneName: edge.zoneName ?? "",
13621
+ className: edge.className ?? "@all",
13622
+ threshold: edge.threshold,
13623
+ count: edge.count,
13624
+ previousCount: edge.previousCount,
13625
+ occupied: edge.occupied
13626
+ }
13627
+ });
12816
13628
  this.persistOccupancyEdge(edge, now);
12817
13629
  this.onOccupancyEdge(edge);
12818
13630
  }
@@ -13017,6 +13829,119 @@ var NotificationCenter = class NotificationCenter {
13017
13829
  setAlarmPanel(panel) {
13018
13830
  this.alarmPanel = panel;
13019
13831
  }
13832
+ /**
13833
+ * What the panel asks before it arms — see {@link NcAlarmArmGuardHook}.
13834
+ *
13835
+ * Handed to the panel by the same wiring that hands it the publish hook: the
13836
+ * panel owns the state machine, the centre owns the rules that say which
13837
+ * contacts a mode covers, and neither can reach the other on its own.
13838
+ */
13839
+ alarmArmGuard() {
13840
+ return {
13841
+ openContacts: (mode) => this.alarmOpenContacts(mode),
13842
+ announceRefusal: (input) => this.announceArmRefused(input)
13843
+ };
13844
+ }
13845
+ /**
13846
+ * Which of the contacts this mode covers read OPEN, right now.
13847
+ *
13848
+ * A TARGETED read of at most the handful of sensors the mode names, not a
13849
+ * refresh of the whole watched set: the arm is a person pressing a button and
13850
+ * the answer has to be about this second, but it must not become a fan-out
13851
+ * over every gated device on the hub.
13852
+ *
13853
+ * Three fail-open rules, and all three exist because refusing an arm on
13854
+ * ignorance is worse than arming:
13855
+ *
13856
+ * - a sensor the read could not answer for falls back to the mirrored state
13857
+ * (bounded staleness, the same contract the `deviceState` gate lives with);
13858
+ * - a sensor NEITHER can answer for is not blocking, and says so in a line;
13859
+ * - a mode whose contact rule names no devices blocks nothing, and says so —
13860
+ * an unscoped contact rule covers every contact on the hub, and a refusal
13861
+ * naming devices the operator never associated with the alarm is one they
13862
+ * cannot act on.
13863
+ */
13864
+ async alarmOpenContacts(mode) {
13865
+ const panel = this.alarmPanel;
13866
+ if (panel === null) return [];
13867
+ const candidates = alarmBlockingCandidates(this.rules.list(), panel.deviceId, mode);
13868
+ if (candidates.length === 0) return [];
13869
+ const fresh = await this.readContactStates(candidates);
13870
+ const name = this.deviceNameFromDirectory();
13871
+ const open = [];
13872
+ const unknown = [];
13873
+ for (const deviceId of candidates) {
13874
+ const state = fresh.get(deviceId) ?? this.deviceStates.get(deviceId);
13875
+ if (state === void 0) {
13876
+ unknown.push(deviceId);
13877
+ continue;
13878
+ }
13879
+ if (state === "open") open.push({
13880
+ deviceId,
13881
+ name: name(deviceId)
13882
+ });
13883
+ }
13884
+ for (const deviceId of unknown) this.logger.info("alarm arm check: no contact state — the sensor cannot block this arm", {
13885
+ tags: { deviceId },
13886
+ meta: {
13887
+ panelDeviceId: panel.deviceId,
13888
+ mode
13889
+ }
13890
+ });
13891
+ this.logger.info("alarm arm check", {
13892
+ tags: { deviceId: panel.deviceId },
13893
+ meta: {
13894
+ mode,
13895
+ candidates: candidates.length,
13896
+ open: open.length,
13897
+ unknown: unknown.length,
13898
+ openIds: open.map((c) => c.deviceId).join(",")
13899
+ }
13900
+ });
13901
+ return open;
13902
+ }
13903
+ /** One bounded read of the named contacts. Never throws — an unreadable
13904
+ * sensor is simply absent, exactly as `NcDeviceStateCache` contracts. */
13905
+ async readContactStates(ids) {
13906
+ const read = this.deps.readDeviceStates;
13907
+ if (read === void 0) return /* @__PURE__ */ new Map();
13908
+ try {
13909
+ return await read(ids);
13910
+ } catch (err) {
13911
+ this.logger.warn("alarm arm check: contact read failed — falling back to the mirror", { meta: {
13912
+ devices: ids.length,
13913
+ error: String(err)
13914
+ } });
13915
+ return /* @__PURE__ */ new Map();
13916
+ }
13917
+ }
13918
+ /**
13919
+ * The refusal, as an ordinary notification.
13920
+ *
13921
+ * Same path the arm announcement takes (D126: the alarm speaks through the
13922
+ * ordinary notification path), so it is durable, retried, in history and
13923
+ * worded in the reader's language at send time. It is the operator's ONLY
13924
+ * copy: the thrown error reaches whoever called the cap, and when that caller
13925
+ * is a notification button on a phone, nobody ever sees it.
13926
+ */
13927
+ announceArmRefused(input) {
13928
+ const panel = this.alarmPanel;
13929
+ if (panel === null) return;
13930
+ const text = buildArmRefusedAnnouncement(input.mode, input.blocking.map((c) => c.name), input.retrySeconds);
13931
+ const transition = {
13932
+ kind: "alarm-arm-refused",
13933
+ state: "disarmed",
13934
+ previous: "disarmed",
13935
+ mode: input.mode,
13936
+ at: input.at
13937
+ };
13938
+ const settings = panel.settings();
13939
+ const targetIds = settings.announceArm ? settings.announceTargets : [];
13940
+ this.scheduleAlarmTransition(panel, transition, {
13941
+ text,
13942
+ targetIds
13943
+ });
13944
+ }
13020
13945
  /** Settings + the DERIVED coverage. Read together — see the cap doc. */
13021
13946
  alarmConfig() {
13022
13947
  const panel = this.alarmPanel;
@@ -13053,7 +13978,7 @@ var NotificationCenter = class NotificationCenter {
13053
13978
  const identity = this.deviceDirectory.get(deviceId);
13054
13979
  if (identity === void 0) return void 0;
13055
13980
  if (identity.online === false) return "offline";
13056
- if (identity.detectionActive === false) return "detection-off";
13981
+ if (identity.isCamera && identity.detectionActive === false) return "detection-off";
13057
13982
  }
13058
13983
  /**
13059
13984
  * Everything the panel reports, on every publish.
@@ -13090,13 +14015,13 @@ var NotificationCenter = class NotificationCenter {
13090
14015
  * the combined notification: one row carrying the alarm's words and that
13091
14016
  * rule's pictures, instead of two pushes about one incident.
13092
14017
  */
13093
- scheduleAlarmTransition(panel, transition) {
13094
- const arm = transition.kind === "alarm-armed" && transition.mode !== void 0 ? this.armAnnouncement(panel, transition.mode) : null;
14018
+ scheduleAlarmTransition(panel, transition, prepared) {
14019
+ const arm = prepared ?? this.armAnnouncement(panel, transition);
13095
14020
  const incoming = incomingFromAlarmTransition({
13096
14021
  transition,
13097
14022
  panelDeviceId: panel.deviceId,
13098
14023
  lookup: this.deviceDirectory.lookup(),
13099
- ...arm !== null ? { arm: arm.text } : {}
14024
+ ...arm !== null ? { body: arm.text } : {}
13100
14025
  });
13101
14026
  this.evalChain = this.evalChain.then(async () => {
13102
14027
  try {
@@ -13117,31 +14042,45 @@ var NotificationCenter = class NotificationCenter {
13117
14042
  });
13118
14043
  }
13119
14044
  /**
13120
- * The arm sentence and the targets it goes to, or null when this arm is not
13121
- * announced at all.
14045
+ * The arm/arming sentence and the targets it goes to, or null when the
14046
+ * transition is not one of those two.
14047
+ *
14048
+ * Both kinds compose from the SAME coverage — "what does this mode actually
14049
+ * watch" — because they are the same answer given thirty seconds apart. The
14050
+ * arming one is the useful one: it reaches the operator while the exit delay
14051
+ * is still running, so a mode that protects nothing can still be fixed.
13122
14052
  *
13123
14053
  * Every way of NOT announcing produces a line: an announcement that silently
13124
14054
  * did not arrive is indistinguishable from the alarm not having armed, which
13125
- * is the exact confusion this feature exists to remove. The loudest of them
13126
- * is the last — a mode nothing is gated on protects nothing, and saying
13127
- * "Away armed" there would be a false assurance.
13128
- */
13129
- armAnnouncement(panel, mode) {
14055
+ * is the exact confusion this feature exists to remove. A COVERAGE GAP no
14056
+ * longer suppresses the sentence — it changes it. Suppressing it left the
14057
+ * operator with a title and an empty line (the fallback body key existed in
14058
+ * neither locale) and no way to learn that the mode was gated on by nothing.
14059
+ */
14060
+ armAnnouncement(panel, transition) {
14061
+ const kind = transition.kind;
14062
+ if (kind !== "alarm-armed" && kind !== "alarm-arming") return null;
14063
+ const mode = transition.mode;
14064
+ if (mode === void 0) return null;
13130
14065
  const settings = panel.settings();
13131
14066
  const tags = { deviceId: panel.deviceId };
13132
14067
  const coverage = this.coverageFor(panel).find((c) => c.mode === mode);
13133
14068
  if (coverage === void 0) return null;
13134
- const text = buildArmAnnouncement(coverage, this.deviceNameFromDirectory());
13135
- if (text === null) {
13136
- this.logger.warn("alarm armed into a mode no enabled rule is gated on", {
13137
- tags,
13138
- meta: {
13139
- mode,
13140
- state: `armed_${mode}`
13141
- }
13142
- });
13143
- return null;
13144
- }
14069
+ const name = this.deviceNameFromDirectory();
14070
+ const bypassed = panel.excludedDevices();
14071
+ const text = kind === "alarm-arming" ? buildArmingAnnouncement(coverage, transition.seconds ?? 0, name, bypassed) : buildArmAnnouncement(coverage, name, bypassed);
14072
+ const gap = alarmCoverageGap(coverage);
14073
+ if (gap !== null) this.logger.warn("alarm armed into a mode that watches nothing", {
14074
+ tags,
14075
+ meta: {
14076
+ mode,
14077
+ kind,
14078
+ gap,
14079
+ rules: coverage.ruleCount,
14080
+ devices: coverage.deviceIds.length,
14081
+ skipped: coverage.skippedDevices.length
14082
+ }
14083
+ });
13145
14084
  if (!settings.announceArm) return {
13146
14085
  text,
13147
14086
  targetIds: []
@@ -13685,6 +14624,7 @@ var NotificationCenter = class NotificationCenter {
13685
14624
  }
13686
14625
  this.occupancyWatcher.setWatchedKeys(specs);
13687
14626
  this.occupancyEnabled = specs.length > 0;
14627
+ this.reportOccupancyWatch(specs);
13688
14628
  const audioSpecs = [];
13689
14629
  for (const rule of this.rules.listEnabled("immediate")) {
13690
14630
  const audio = rule.conditions.audio;
@@ -13711,9 +14651,44 @@ var NotificationCenter = class NotificationCenter {
13711
14651
  const devices = rule.conditions.devices;
13712
14652
  if (zones !== void 0 && zones.ids.length > 0 && devices !== void 0) zoneScoped.push(...devices);
13713
14653
  }
14654
+ const panel = this.alarmPanel;
14655
+ if (panel !== null) gated.push(...alarmContactWatchList(this.rules.list(), panel.deviceId, panel.availableModes()));
13714
14656
  this.deviceStates.setWatched(gated);
13715
14657
  this.zoneOwners.setWatched(zoneScoped);
13716
14658
  }
14659
+ /**
14660
+ * Feed every excluded sensor's mirrored state back to the panel.
14661
+ *
14662
+ * The reconcile half of the two-phase lift. The EVENT half
14663
+ * ({@link onSensorEventPersisted}) is the fast one, but it only exists for a
14664
+ * sensor linked to a camera — the seven contacts on this hub are linked to
14665
+ * none, so without this an exclusion would never lift at all and the sensor
14666
+ * would stay out of the alarm until a disarm.
14667
+ */
14668
+ observeAlarmContacts() {
14669
+ const panel = this.alarmPanel;
14670
+ if (panel === null) return;
14671
+ const excluded = panel.excludedDevices();
14672
+ if (excluded.length === 0) return;
14673
+ const now = this.now();
14674
+ for (const deviceId of excluded) panel.observeContact(deviceId, contactReadingOf(this.deviceStates.get(deviceId)), now);
14675
+ }
14676
+ /**
14677
+ * Log the watched occupancy key set, on CHANGE only.
14678
+ *
14679
+ * Change-only because this runs on every rule-reload tick; a line per tick
14680
+ * would be noise, and no line at all is how a rule that watches an
14681
+ * impossible scope stays invisible for a day.
14682
+ */
14683
+ reportOccupancyWatch(specs) {
14684
+ const rendered = specs.map((s) => `${s.zoneId ?? "@frame"}|${s.className ?? "@all"}|t${String(s.threshold)}@${String(s.sustainSeconds)}s`).toSorted().join(",");
14685
+ if (rendered === this.lastOccupancyWatchReport) return;
14686
+ this.lastOccupancyWatchReport = rendered;
14687
+ this.logger.info("occupancy watched keys", { meta: {
14688
+ keys: specs.length,
14689
+ specs: rendered.length > 0 ? rendered : "(none)"
14690
+ } });
14691
+ }
13717
14692
  /** Boot reseed of confirmed occupancy edge-state (durability, constraint 4) —
13718
14693
  * hydrate the watcher from the store, then prune orphaned durable rows to the
13719
14694
  * active watched set. Runs AFTER {@link refreshOccupancyWatch} so hydrate
@@ -13776,6 +14751,7 @@ var NotificationCenter = class NotificationCenter {
13776
14751
  await this.deviceStates.refresh();
13777
14752
  await this.deviceDirectory.refresh();
13778
14753
  await this.zoneOwners.refresh();
14754
+ this.observeAlarmContacts();
13779
14755
  await this.occupancyStore.pruneExcept(this.activeOccupancyKeys());
13780
14756
  }
13781
14757
  /**
@@ -13801,26 +14777,63 @@ var NotificationCenter = class NotificationCenter {
13801
14777
  activeOccupancyKeys() {
13802
14778
  return new Set(this.occupancyWatcher.snapshotState().map((r) => r.key));
13803
14779
  }
14780
+ /**
14781
+ * Write-through the rows the watcher SEEDED on this observation — the initial
14782
+ * level of a key that had neither a durable row nor RAM state.
14783
+ *
14784
+ * Same ledger, same best-effort contract as an edge: a failed write leaves
14785
+ * exactly today's behaviour (a cold re-seed at the next boot), never a
14786
+ * corrupt level. Drained, so a key is written once and not per frame.
14787
+ */
14788
+ persistSeededOccupancy(now) {
14789
+ const rows = this.occupancyWatcher.takeSeededRows();
14790
+ for (const row of rows) {
14791
+ this.logger.info("occupancy baseline seeded", {
14792
+ tags: { deviceId: row.deviceId },
14793
+ meta: {
14794
+ key: row.key,
14795
+ zoneId: row.zoneId ?? "@frame",
14796
+ className: row.className ?? "@all",
14797
+ threshold: row.threshold,
14798
+ count: row.confirmedCount,
14799
+ occupied: row.occupied
14800
+ }
14801
+ });
14802
+ this.persistOccupancyRow(row, now);
14803
+ }
14804
+ }
13804
14805
  /** Write-through the committed confirmed level for one edge (rare — only on a
13805
14806
  * boolean flip). Best-effort: a failed persist is logged, never thrown into
13806
14807
  * the frame path (the boot reseed + next edge recover). */
13807
- async persistOccupancyEdge(edge, now) {
14808
+ persistOccupancyEdge(edge, now) {
14809
+ return this.persistOccupancyRow({
14810
+ key: occupancyKey(edge.deviceId, edge.zoneId, edge.className, edge.threshold),
14811
+ deviceId: edge.deviceId,
14812
+ ...edge.zoneId !== void 0 ? { zoneId: edge.zoneId } : {},
14813
+ ...edge.className !== void 0 ? { className: edge.className } : {},
14814
+ threshold: edge.threshold,
14815
+ confirmedCount: edge.count,
14816
+ occupied: edge.occupied,
14817
+ lastChangeAt: edge.timestamp,
14818
+ updatedAt: now
14819
+ }, now);
14820
+ }
14821
+ /** The ONE durable write for a confirmed occupancy level, whether it came from
14822
+ * a committed edge or a cold seed. Best-effort by contract — see the two
14823
+ * callers. */
14824
+ async persistOccupancyRow(row, now) {
13808
14825
  try {
13809
14826
  await this.occupancyStore.persist({
13810
- key: occupancyKey(edge.deviceId, edge.zoneId, edge.className, edge.threshold),
13811
- deviceId: edge.deviceId,
13812
- ...edge.zoneId !== void 0 ? { zoneId: edge.zoneId } : {},
13813
- ...edge.className !== void 0 ? { className: edge.className } : {},
13814
- threshold: edge.threshold,
13815
- confirmedCount: edge.count,
13816
- occupied: edge.occupied,
13817
- lastChangeAt: edge.timestamp,
14827
+ ...row,
13818
14828
  updatedAt: now
13819
14829
  });
13820
14830
  } catch (err) {
13821
14831
  this.logger.debug("occupancy persist failed", {
13822
- tags: { deviceId: edge.deviceId },
13823
- meta: { error: String(err) }
14832
+ tags: { deviceId: row.deviceId },
14833
+ meta: {
14834
+ key: row.key,
14835
+ error: String(err)
14836
+ }
13824
14837
  });
13825
14838
  }
13826
14839
  }
@@ -39907,7 +40920,13 @@ var ZoneAnalyticsProvider = class {
39907
40920
  meta: {
39908
40921
  totalObjects: total,
39909
40922
  byClass: snapshot.frame.byClass,
39910
- zones: snapshot.zones.length
40923
+ zones: snapshot.zones.length,
40924
+ zoneCounts: snapshot.zones.map((z) => ({
40925
+ zoneId: z.zoneId,
40926
+ zoneName: z.zoneName,
40927
+ totalObjects: z.totalObjects,
40928
+ byClass: z.byClass
40929
+ }))
39911
40930
  }
39912
40931
  });
39913
40932
  this.ctx.emitOccupancyChanged?.({
@@ -41209,9 +42228,12 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
41209
42228
  settings: () => panel.settings(),
41210
42229
  applySettings: (patch) => panel.applySettings(patch),
41211
42230
  availableModes: () => NC_ALARM_MODES,
41212
- noteRuleTrigger: (source) => panel.noteRuleTrigger(source)
42231
+ noteRuleTrigger: (source) => panel.noteRuleTrigger(source),
42232
+ excludedDevices: () => panel.excludedDevices(),
42233
+ observeContact: (deviceId, reading, now) => panel.observeContact(deviceId, reading, now)
41213
42234
  });
41214
42235
  panel.setPublishHook((report) => center.onAlarmPublish(report));
42236
+ panel.setArmGuard(center.alarmArmGuard());
41215
42237
  center.onAlarmPublish({
41216
42238
  state: panel.currentState(),
41217
42239
  transition: null