@camstack/addon-post-analysis 1.2.69 → 1.2.71

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-DjPaVd2L.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,7 +540,29 @@ function skippedFor(deviceIds, skipProbe) {
524
540
  return out;
525
541
  }
526
542
  /**
527
- * "Away armed — 3 cameras: Front door, Garage, Gate."
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
+ /**
558
+ * "Attivi: Cancello, Garage — Esclusi: Porta retro."
559
+ *
560
+ * A LIST, not a sentence. The mode is in the title and the body is one
561
+ * `Label: names` line per device group, because this is read on a lock screen
562
+ * and the question is always "which one" — the prose version ("3 dispositivi
563
+ * attivi: …. Esclusi: ….") made the operator parse a paragraph to find a
564
+ * window. Each variant still owns its own catalog key, so the Texts tab can
565
+ * re-word "nothing is armed" without touching "these are armed".
528
566
  *
529
567
  * Names, not ids: this is read by a person standing at a door. An id that has
530
568
  * no name falls back to a NAMED, translated placeholder rather than being
@@ -532,14 +570,36 @@ function skippedFor(deviceIds, skipProbe) {
532
570
  * the one error this message must not make. (It used to hard-code the English
533
571
  * `Device <id>` into both languages.)
534
572
  *
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.
573
+ * **Always a sentence.** It used to return null for a mode no rule is gated on,
574
+ * on the grounds that "Night armed" followed by an unwatched night is a false
575
+ * assurance — true, but the caller then emitted `alarm-armed` anyway with a
576
+ * fallback body key that existed in NEITHER locale, so the operator received a
577
+ * title and an empty line. Silence is not the third option here: SAYING that
578
+ * the mode arms nothing is.
538
579
  */
539
- function buildArmAnnouncement(coverage, deviceName) {
540
- if (coverage.ruleCount === 0) return null;
580
+ function buildArmAnnouncement(coverage, deviceName, bypassed = []) {
541
581
  const rules = `${coverage.ruleCount}`;
542
582
  const mode = coverage.mode;
583
+ if (bypassed.length > 0) return armedWithBypass(coverage, bypassed, deviceName);
584
+ const gap = alarmCoverageGap(coverage);
585
+ if (gap === "uncovered") return {
586
+ bodyKey: "alarm.arm.body.uncovered",
587
+ textParams: { mode },
588
+ textCount: 0
589
+ };
590
+ if (gap === "all-skipped") {
591
+ const names = coverage.skippedDevices.map((s) => deviceName(s.deviceId));
592
+ return {
593
+ bodyKey: "alarm.arm.body.allSkipped",
594
+ textParams: {
595
+ rules,
596
+ mode,
597
+ devices: `${names.length}`,
598
+ names: names.join(", ")
599
+ },
600
+ textCount: names.length
601
+ };
602
+ }
543
603
  if (coverage.allDevices) return {
544
604
  bodyKey: "alarm.arm.body.allDevices",
545
605
  textParams: {
@@ -556,8 +616,9 @@ function buildArmAnnouncement(coverage, deviceName) {
556
616
  },
557
617
  textCount: coverage.ruleCount
558
618
  };
559
- const names = coverage.deviceIds.map(deviceName);
560
- return {
619
+ const skipped = new Set(coverage.skippedDevices.map((s) => s.deviceId));
620
+ const names = coverage.deviceIds.filter((id) => !skipped.has(id)).map(deviceName);
621
+ if (skipped.size === 0) return {
561
622
  bodyKey: "alarm.arm.body.devices",
562
623
  textParams: {
563
624
  rules,
@@ -567,7 +628,336 @@ function buildArmAnnouncement(coverage, deviceName) {
567
628
  },
568
629
  textCount: names.length
569
630
  };
631
+ return {
632
+ bodyKey: "alarm.arm.body.devicesSkipped",
633
+ textParams: {
634
+ rules,
635
+ mode,
636
+ devices: `${names.length}`,
637
+ names: names.join(", "),
638
+ skippedNames: coverage.skippedDevices.map((s) => deviceName(s.deviceId)).join(", ")
639
+ },
640
+ textCount: names.length
641
+ };
642
+ }
643
+ /**
644
+ * "Attivi: 2 — Bypassati: Finestra veranda."
645
+ *
646
+ * The bypassed sensors are named and the armed ones are COUNTED, not listed:
647
+ * this sentence's subject is the exception, and burying it under thirteen
648
+ * camera names is how an operator stops reading the arm notification. The
649
+ * count pluralises on what IS armed, like every other arm variant.
650
+ */
651
+ function armedWithBypass(coverage, bypassed, deviceName) {
652
+ const skipped = new Set(coverage.skippedDevices.map((s) => s.deviceId));
653
+ const excluded = new Set(bypassed);
654
+ const armed = coverage.allDevices ? coverage.ruleCount : coverage.deviceIds.filter((id) => !skipped.has(id) && !excluded.has(id)).length;
655
+ return {
656
+ bodyKey: "alarm.arm.body.bypassed",
657
+ textParams: {
658
+ rules: `${coverage.ruleCount}`,
659
+ mode: coverage.mode,
660
+ devices: `${armed}`,
661
+ bypassedNames: bypassed.map(deviceName).join(", ")
662
+ },
663
+ textCount: armed
664
+ };
665
+ }
666
+ /**
667
+ * "Aperti: Finestra veranda, Porta retro" + how to arm anyway.
668
+ *
669
+ * The one alarm sentence about something that did not happen, so it has to
670
+ * carry both halves an operator needs to act: WHICH sensors are open, and that
671
+ * pressing arm again arms without them. Naming them is the whole message — a
672
+ * refusal that says only "cannot arm" is a panel the operator will conclude is
673
+ * broken — so the names are the FIRST line and the retry hint is the second.
674
+ */
675
+ function buildArmRefusedAnnouncement(mode, names, retrySeconds) {
676
+ return {
677
+ bodyKey: "alarm.armRefused.body",
678
+ textParams: {
679
+ mode,
680
+ devices: `${names.length}`,
681
+ names: names.join(", "),
682
+ seconds: `${retrySeconds}`
683
+ },
684
+ textCount: names.length
685
+ };
686
+ }
687
+ /**
688
+ * The EXCEPTIONS to the same coverage, said 30 seconds earlier.
689
+ *
690
+ * The exit-delay sentence is deliberately SHORTER than the arm one — it is read
691
+ * on the way out of a door — and it carries exactly one thing the arm sentence
692
+ * cannot: the warning arrives while the operator can still disarm and fix it.
693
+ * A mode that arms nothing is worth interrupting somebody for at second 0 and
694
+ * merely worth recording at second 30.
695
+ *
696
+ * **It never repeats the device list.** The countdown lives in the TITLE
697
+ * (`system.alarm-arming.title.<mode>` renders `{{seconds}}`), and the armed
698
+ * list arrives thirty seconds later in the arm announcement — printing it
699
+ * twice is the verbosity that made the operator stop reading both. So the body
700
+ * is what is WRONG: what was bypassed, what is excluded, or that nothing is.
701
+ *
702
+ * `seconds` stays in the params because the title asks for it, and `textCount`
703
+ * stays with it: a row enqueued before this deploy still pluralises on the
704
+ * countdown it was written for.
705
+ */
706
+ function buildArmingAnnouncement(coverage, seconds, deviceName, bypassed = []) {
707
+ const textParams = {
708
+ mode: coverage.mode,
709
+ seconds: `${seconds}`
710
+ };
711
+ if (bypassed.length > 0) return {
712
+ bodyKey: "alarm.arming.body.bypassed",
713
+ textParams: {
714
+ ...textParams,
715
+ bypassedNames: bypassed.map(deviceName).join(", ")
716
+ },
717
+ textCount: seconds
718
+ };
719
+ const skippedNames = coverage.skippedDevices.map((s) => deviceName(s.deviceId)).join(", ");
720
+ const gap = alarmCoverageGap(coverage);
721
+ if (gap === "uncovered") return {
722
+ bodyKey: "alarm.arming.body.uncovered",
723
+ textParams,
724
+ textCount: seconds
725
+ };
726
+ if (gap === "all-skipped") return {
727
+ bodyKey: "alarm.arming.body.allSkipped",
728
+ textParams: {
729
+ ...textParams,
730
+ names: skippedNames
731
+ },
732
+ textCount: seconds
733
+ };
734
+ if (coverage.skippedDevices.length > 0) return {
735
+ bodyKey: "alarm.arming.body.skipped",
736
+ textParams: {
737
+ ...textParams,
738
+ names: skippedNames
739
+ },
740
+ textCount: coverage.skippedDevices.length
741
+ };
742
+ return {
743
+ bodyKey: "alarm.arming.body",
744
+ textParams,
745
+ textCount: seconds
746
+ };
570
747
  }
748
+ //#endregion
749
+ //#region src/notification-center/alarm/alarm-arm-guard.ts
750
+ /**
751
+ * How long a refusal stays confirmable.
752
+ *
753
+ * Long enough to read the notification and press again (the operator is
754
+ * standing at the door, phone in hand); short enough that it cannot be the
755
+ * state the panel is left in. Sixty seconds is also under the shortest exit
756
+ * delay this panel ships with, so a bypass can never outlive the arm it
757
+ * belongs to.
758
+ */
759
+ var ARM_BYPASS_WINDOW_MS = 6e4;
760
+ /** The taxonomy kind a door/window contact persists its events under. */
761
+ var CONTACT_SENSOR_KIND = "contact";
762
+ /**
763
+ * The contact sensors a mode would arm, ascending and de-duplicated.
764
+ *
765
+ * A rule qualifies only when it is enabled, gated on THIS panel in THIS mode,
766
+ * and about contacts (`sensorKinds` naming `contact`). Two exclusions are
767
+ * deliberate and both are about refusing an arm the operator cannot fix:
768
+ *
769
+ * - a rule that names CAMERAS (`Intrusione — Fuori casa` names thirteen) is
770
+ * not a contact rule, and a camera is never open;
771
+ * - a contact rule with NO device scope covers every contact on the hub, and
772
+ * blocking on a set the operator never wrote down produces a refusal naming
773
+ * devices they never associated with the alarm. The caller says so in a log
774
+ * line rather than inventing the list.
775
+ */
776
+ function alarmBlockingCandidates(rules, panelDeviceId, mode) {
777
+ const wanted = armedStateFor(mode);
778
+ const ids = /* @__PURE__ */ new Set();
779
+ for (const rule of rules) {
780
+ if (!rule.enabled) continue;
781
+ const gate = rule.conditions.deviceState;
782
+ if (gate === void 0 || gate.deviceId !== panelDeviceId) continue;
783
+ if (!gate.states.includes(wanted)) continue;
784
+ const kinds = rule.conditions.sensorKinds;
785
+ if (kinds === void 0 || !kinds.includes(CONTACT_SENSOR_KIND)) continue;
786
+ const scope = rule.conditions.devices;
787
+ if (scope === void 0 || scope.length === 0) continue;
788
+ for (const id of scope) ids.add(id);
789
+ }
790
+ return [...ids].toSorted((a, b) => a - b);
791
+ }
792
+ /**
793
+ * Every contact any mode of this panel could block on — the set whose state is
794
+ * worth keeping mirrored.
795
+ *
796
+ * Read on the rule-reload tick, so the arm path has a recent answer even when
797
+ * the targeted read at arm time fails, and so an excluded sensor's close can be
798
+ * noticed on a hub where the sensor is linked to no camera (and therefore emits
799
+ * no device event at all — the live installation on 2026-08-13).
800
+ */
801
+ function alarmContactWatchList(rules, panelDeviceId, modes) {
802
+ const ids = /* @__PURE__ */ new Set();
803
+ for (const mode of modes) for (const id of alarmBlockingCandidates(rules, panelDeviceId, mode)) ids.add(id);
804
+ return [...ids].toSorted((a, b) => a - b);
805
+ }
806
+ /**
807
+ * Refuse, bypass or proceed — the whole decision, as one comparison.
808
+ *
809
+ * The bypass requires the SAME mode. "Arm home" refused and "arm away" pressed
810
+ * is a different intent about a different set of sensors, and letting one
811
+ * confirm the other would arm a mode around a window nobody was told about.
812
+ *
813
+ * The bypassed set is what is open NOW, not what was open at the refusal: a
814
+ * window shut between the two presses is armed with everything else, which is
815
+ * the entire point of having refused.
816
+ */
817
+ function decideArm(input) {
818
+ const window = input.windowMs ?? 6e4;
819
+ if (input.openContacts.length === 0) return { kind: "clear" };
820
+ const previous = input.previous;
821
+ if (previous !== null && previous.mode === input.mode && input.at - previous.at <= window) return {
822
+ kind: "bypassed",
823
+ bypassed: input.openContacts
824
+ };
825
+ return {
826
+ kind: "refused",
827
+ blocking: input.openContacts,
828
+ retryUntil: input.at + window
829
+ };
830
+ }
831
+ /**
832
+ * The error `alarmPanel.arm` throws when it refuses.
833
+ *
834
+ * A THROW and not a return value, and the reason is a train: the hub's
835
+ * generated router validates this method's response against the HOST's copy of
836
+ * the cap, which says `z.void()`. An addon that answered a structured refusal
837
+ * would have it rejected by the router in front of it — so the refusal reaches
838
+ * the caller as the one channel that already works across that boundary, and
839
+ * the operator's PHONE gets the sentence (`alarm-arm-refused`, through the
840
+ * ordinary outbox) rather than a UI error being the only trace.
841
+ *
842
+ * The message is deliberately actionable and English (an exception message is
843
+ * a developer/API surface; the notification is the operator's).
844
+ */
845
+ var NcAlarmArmRefusedError = class extends Error {
846
+ code = "alarm-arm-refused";
847
+ mode;
848
+ blocking;
849
+ retryUntil;
850
+ constructor(input) {
851
+ const names = input.blocking.map((c) => c.name).join(", ");
852
+ const seconds = Math.round((input.windowMs ?? 6e4) / 1e3);
853
+ super(`arm refused — open: ${names}. Arm ${input.mode} again within ${String(seconds)}s to arm with them excluded.`);
854
+ this.name = "NcAlarmArmRefusedError";
855
+ this.mode = input.mode;
856
+ this.blocking = input.blocking;
857
+ this.retryUntil = input.retryUntil;
858
+ }
859
+ };
860
+ //#endregion
861
+ //#region src/notification-center/alarm/alarm-exclusions.ts
862
+ function isPhase(value) {
863
+ return value === "open" || value === "closed";
864
+ }
865
+ var NcAlarmExclusions = class {
866
+ entries = [];
867
+ /** Replace the whole set — one arm, one set of exclusions. */
868
+ exclude(deviceIds, at) {
869
+ this.entries = [...new Set(deviceIds)].map((deviceId) => ({
870
+ deviceId,
871
+ phase: "open",
872
+ at
873
+ }));
874
+ }
875
+ /** Every excluded sensor, in the order it was excluded. */
876
+ list() {
877
+ return this.entries.map((e) => e.deviceId);
878
+ }
879
+ /** True while this sensor's events must not reach the panel. */
880
+ blocks(deviceId) {
881
+ return this.entries.some((e) => e.deviceId === deviceId);
882
+ }
883
+ /**
884
+ * Feed one contact reading, and say what it CHANGED.
885
+ *
886
+ * Three answers, not two, because the caller has to persist on both of the
887
+ * changes: the first `alarm-arm-refusal.spec.ts` run of this feature saved
888
+ * only the re-arm, so a hub redeployed between the close and the re-open came
889
+ * back with the sensor still in phase `open` — and the exclusion then needed
890
+ * a SECOND close to lift. A phase that lives only in RAM is half a durable
891
+ * exclusion.
892
+ *
893
+ * An unreadable state changes nothing (D49): a failed read must never lift an
894
+ * exclusion the operator asked for, and must never swallow the close that
895
+ * would have.
896
+ */
897
+ observe(deviceId, reading, _at) {
898
+ if (reading === void 0) return "unchanged";
899
+ const current = this.entries.find((e) => e.deviceId === deviceId);
900
+ if (current === void 0) return "unchanged";
901
+ if (reading === "closed") {
902
+ if (current.phase === "closed") return "unchanged";
903
+ this.entries = this.entries.map((e) => e.deviceId === deviceId ? {
904
+ ...e,
905
+ phase: "closed"
906
+ } : e);
907
+ return "closed";
908
+ }
909
+ if (current.phase === "open") return "unchanged";
910
+ this.entries = this.entries.filter((e) => e.deviceId !== deviceId);
911
+ return "rearmed";
912
+ }
913
+ /** The arm this belongs to is over. */
914
+ clear() {
915
+ this.entries = [];
916
+ }
917
+ /** What to persist. Plain data — it rides the panel's config blob. */
918
+ snapshot() {
919
+ return this.entries.map((e) => ({
920
+ deviceId: e.deviceId,
921
+ phase: e.phase,
922
+ at: e.at
923
+ }));
924
+ }
925
+ /**
926
+ * Rebuild from the persisted blob — a GUARD, never a cast.
927
+ *
928
+ * Anything unrecognised restores NOTHING, which is the failure that shows
929
+ * itself (a bypassed sensor that fires) rather than the one that hides (a
930
+ * sensor excluded forever by a blob nobody can read). A bare id with no phase
931
+ * restores as still-OPEN: it needs a close before it can re-arm, which is the
932
+ * half that cannot cause a false alarm.
933
+ */
934
+ restore(raw) {
935
+ if (!Array.isArray(raw)) return;
936
+ const out = [];
937
+ for (const item of raw) {
938
+ if (typeof item === "number" && Number.isInteger(item)) {
939
+ out.push({
940
+ deviceId: item,
941
+ phase: "open",
942
+ at: 0
943
+ });
944
+ continue;
945
+ }
946
+ if (item === null || typeof item !== "object") continue;
947
+ const rec = { ...item };
948
+ const deviceId = rec["deviceId"];
949
+ if (typeof deviceId !== "number" || !Number.isInteger(deviceId)) continue;
950
+ const phase = isPhase(rec["phase"]) ? rec["phase"] : "open";
951
+ const at = typeof rec["at"] === "number" ? rec["at"] : 0;
952
+ out.push({
953
+ deviceId,
954
+ phase,
955
+ at
956
+ });
957
+ }
958
+ this.entries = out;
959
+ }
960
+ };
571
961
  /**
572
962
  * WHO the panel's current countdown belongs to.
573
963
  *
@@ -591,6 +981,26 @@ var NcAlarmAttribution = class {
591
981
  };
592
982
  }
593
983
  /**
984
+ * The note that has not been claimed yet — WITHOUT consuming it.
985
+ *
986
+ * The panel has to know which SENSOR is about to set the alarm off before it
987
+ * decides whether to accept the trigger at all (an excluded contact does
988
+ * not), and asking must not spend the note: a trigger that is accepted still
989
+ * needs it to build the combined notification. Expired reads as absent, on
990
+ * the same bound {@link claim} uses.
991
+ */
992
+ pending(now) {
993
+ const noted = this.noted;
994
+ if (noted === null) return null;
995
+ if (now - noted.at > 3e4) return null;
996
+ return noted.source;
997
+ }
998
+ /** Throw the un-claimed note away — the trigger it described was REFUSED, and
999
+ * a note left behind would be inherited by the next real one. */
1000
+ dropPending() {
1001
+ this.noted = null;
1002
+ }
1003
+ /**
594
1004
  * Called by an ACCEPTED trigger, and by every accepted trigger — including
595
1005
  * one with no note, so a manual trigger clears whatever a rule left behind
596
1006
  * rather than inheriting it.
@@ -645,6 +1055,7 @@ function alarmTransition(input) {
645
1055
  at,
646
1056
  ...input.source !== void 0 ? { source: input.source } : {}
647
1057
  };
1058
+ if (next === "arming") return armingTransition(previous, input);
648
1059
  if (shouldAnnounceArm(previous, next)) {
649
1060
  const mode = armModeOf(next, input.modes);
650
1061
  if (mode === null) return null;
@@ -664,6 +1075,29 @@ function alarmTransition(input) {
664
1075
  };
665
1076
  return null;
666
1077
  }
1078
+ /**
1079
+ * The exit delay, as one notification at its START.
1080
+ *
1081
+ * Reached only from a state CHANGE into `arming`, so the once-a-second
1082
+ * re-publish is already excluded by the caller's `previous === next` guard —
1083
+ * the countdown is one notification, not thirty. Null when the panel did not
1084
+ * say what it is arming into, or named a mode it does not offer: both would
1085
+ * produce a message with no title and no number in it.
1086
+ */
1087
+ function armingTransition(previous, input) {
1088
+ const arming = input.arming;
1089
+ if (arming === void 0) return null;
1090
+ const mode = armModeOf(arming.target, input.modes);
1091
+ if (mode === null) return null;
1092
+ return {
1093
+ kind: "alarm-arming",
1094
+ state: "arming",
1095
+ previous,
1096
+ mode,
1097
+ seconds: Math.max(0, Math.ceil((arming.armedAt - input.at) / 1e3)),
1098
+ at: input.at
1099
+ };
1100
+ }
667
1101
  //#endregion
668
1102
  //#region src/notification-center/alarm/alarm-panel-device.ts
669
1103
  /**
@@ -710,7 +1144,17 @@ var ncAlarmPanelSchema = require_dist.object({
710
1144
  announceArm: require_dist.boolean().default(false),
711
1145
  /** Target ids that announcement goes to — see `NcAlarmSettingsSchema`. */
712
1146
  announceTargets: require_dist.array(require_dist.string()).default([]),
713
- machine: require_dist.record(require_dist.string(), require_dist.unknown()).optional()
1147
+ machine: require_dist.record(require_dist.string(), require_dist.unknown()).optional(),
1148
+ /**
1149
+ * Sensors the CURRENT arm is ignoring because they were open when the
1150
+ * operator confirmed it, and how far each is through its two-phase lift.
1151
+ *
1152
+ * Persisted for the same reason the machine is: a deploy in the middle of an
1153
+ * armed night would otherwise silently re-arm every sensor the operator
1154
+ * agreed to leave out, and they would learn about it from the siren. Read
1155
+ * back through a guard, never a cast — see `NcAlarmExclusions.restore`.
1156
+ */
1157
+ exclusions: require_dist.array(require_dist.unknown()).default([])
714
1158
  });
715
1159
  /** `armed_home` and friends — the only targets a machine may settle into. */
716
1160
  function isArmTarget(value) {
@@ -760,6 +1204,19 @@ var NcAlarmPanelDevice = class extends require_dist.BaseDevice {
760
1204
  lastPublished = null;
761
1205
  /** WHO the current countdown belongs to — see {@link NcAlarmAttribution}. */
762
1206
  attribution = new NcAlarmAttribution();
1207
+ /** Sensors this arm is ignoring — see {@link NcAlarmExclusions}. */
1208
+ exclusions = new NcAlarmExclusions();
1209
+ /** Who answers "is anything open" — null until the centre wires itself in. */
1210
+ armGuard = null;
1211
+ /**
1212
+ * The arm attempt that was REFUSED, and is therefore confirmable.
1213
+ *
1214
+ * In RAM and only in RAM, deliberately: it is a confirmation of something the
1215
+ * operator read seconds ago, and a confirmation that survived a restart would
1216
+ * let a process respawn arm the alarm around an open window nobody re-read.
1217
+ * Losing it costs one extra press.
1218
+ */
1219
+ lastRefusal = null;
763
1220
  constructor(ctx) {
764
1221
  super(ctx, ncAlarmPanelSchema, { type: ctx.deviceMeta.type });
765
1222
  this.delays = this.readDelays();
@@ -817,26 +1274,164 @@ var NcAlarmPanelDevice = class extends require_dist.BaseDevice {
817
1274
  currentState(now = Date.now()) {
818
1275
  return stateAt(this.machine, now);
819
1276
  }
1277
+ /** Wire the open-contact check and the refusal announcement. */
1278
+ setArmGuard(hook) {
1279
+ this.armGuard = hook;
1280
+ }
1281
+ /**
1282
+ * Sensors the current arm is ignoring — what the armed/arming sentence names
1283
+ * as excluded, and the only place that list is published.
1284
+ */
1285
+ excludedDevices() {
1286
+ return this.exclusions.list();
1287
+ }
1288
+ /**
1289
+ * One contact reading, from whoever saw it — the sensor's own event, or the
1290
+ * centre's reconcile tick.
1291
+ *
1292
+ * The lift is two-phase (`alarm-exclusions.ts`), so this is safe to call with
1293
+ * the same reading repeatedly, and an unreadable state changes nothing. Fed
1294
+ * BEFORE the event that carried it is evaluated, so the open that re-arms a
1295
+ * sensor is already lifted when the rule it fires reaches the panel.
1296
+ */
1297
+ observeContact(deviceId, reading, now = Date.now()) {
1298
+ const change = this.exclusions.observe(deviceId, reading, now);
1299
+ if (change === "unchanged") return;
1300
+ this.persist();
1301
+ if (change === "closed") return;
1302
+ this.ctx.logger.info("alarm exclusion lifted — the sensor closed and opened again", {
1303
+ tags: { deviceId },
1304
+ meta: {
1305
+ panelDeviceId: this.id,
1306
+ remaining: this.exclusions.list().length
1307
+ }
1308
+ });
1309
+ }
820
1310
  /** Stop the countdown when the device goes away. The state itself is
821
1311
  * persisted, so nothing is lost — only the announcements stop. */
822
1312
  stopCountdown() {
823
1313
  this.stopTicking();
824
1314
  }
1315
+ /**
1316
+ * Refuse the arm, bypass around it, or let it through — and leave the
1317
+ * exclusions the arm will run with.
1318
+ *
1319
+ * Fail-OPEN by construction, in three places, and each one is a decision:
1320
+ *
1321
+ * - no guard wired (an agent node, a boot pass before the centre exists) ⇒
1322
+ * arm. A panel that cannot ask must not refuse.
1323
+ * - the guard threw ⇒ arm, and SAY so. Refusing on a failed read would tell
1324
+ * the operator to go and shut a window that is already shut (the coverage
1325
+ * derivation makes the same choice: an unreadable authority answers
1326
+ * "unknown", never "excluded").
1327
+ * - nothing reads open ⇒ arm clean, and any exclusion a previous arm left
1328
+ * is dropped.
1329
+ *
1330
+ * The refusal throws. See {@link NcAlarmArmRefusedError} for why that is the
1331
+ * channel rather than a return value.
1332
+ */
1333
+ async applyArmGuard(mode, now) {
1334
+ const guard = this.armGuard;
1335
+ if (guard === null) return;
1336
+ let openContacts;
1337
+ try {
1338
+ openContacts = await guard.openContacts(mode);
1339
+ } catch (err) {
1340
+ this.ctx.logger.warn("alarm arm guard could not read the contacts — arming anyway", {
1341
+ tags: { deviceId: this.id },
1342
+ meta: {
1343
+ mode,
1344
+ error: String(err)
1345
+ }
1346
+ });
1347
+ return;
1348
+ }
1349
+ const verdict = decideArm({
1350
+ mode,
1351
+ openContacts,
1352
+ previous: this.lastRefusal,
1353
+ at: now
1354
+ });
1355
+ if (verdict.kind === "clear") {
1356
+ this.lastRefusal = null;
1357
+ if (this.exclusions.list().length > 0) {
1358
+ this.exclusions.clear();
1359
+ this.persist();
1360
+ }
1361
+ return;
1362
+ }
1363
+ if (verdict.kind === "bypassed") {
1364
+ this.lastRefusal = null;
1365
+ this.exclusions.exclude(verdict.bypassed.map((c) => c.deviceId), now);
1366
+ this.persist();
1367
+ for (const contact of verdict.bypassed) this.ctx.logger.info("alarm armed with an open sensor excluded", {
1368
+ tags: { deviceId: contact.deviceId },
1369
+ meta: {
1370
+ panelDeviceId: this.id,
1371
+ mode,
1372
+ name: contact.name
1373
+ }
1374
+ });
1375
+ return;
1376
+ }
1377
+ this.lastRefusal = {
1378
+ mode,
1379
+ at: now
1380
+ };
1381
+ for (const contact of verdict.blocking) this.ctx.logger.warn("alarm arm REFUSED — this sensor is open", {
1382
+ tags: { deviceId: contact.deviceId },
1383
+ meta: {
1384
+ panelDeviceId: this.id,
1385
+ mode,
1386
+ name: contact.name
1387
+ }
1388
+ });
1389
+ guard.announceRefusal({
1390
+ mode,
1391
+ blocking: verdict.blocking,
1392
+ retrySeconds: Math.round(ARM_BYPASS_WINDOW_MS / 1e3),
1393
+ at: now
1394
+ });
1395
+ throw new NcAlarmArmRefusedError({
1396
+ mode,
1397
+ blocking: verdict.blocking,
1398
+ retryUntil: verdict.retryUntil
1399
+ });
1400
+ }
825
1401
  registerAlarmCap() {
826
1402
  this.ctx.registerNativeCap(require_dist.alarmPanelCapability, {
827
1403
  getStatus: async () => this.status(),
828
1404
  arm: async ({ mode }) => {
829
- this.machine = arm(mode, this.delays, Date.now());
1405
+ const now = Date.now();
1406
+ await this.applyArmGuard(mode, now);
1407
+ this.machine = arm(mode, this.delays, now);
830
1408
  this.attribution.clear();
831
- this.publish();
1409
+ this.publish(now);
832
1410
  },
833
1411
  disarm: async () => {
834
1412
  this.machine = disarm(Date.now());
835
1413
  this.attribution.clear();
1414
+ this.exclusions.clear();
1415
+ this.lastRefusal = null;
836
1416
  this.publish();
837
1417
  },
838
1418
  trigger: async () => {
839
1419
  const now = Date.now();
1420
+ const pending = this.attribution.pending(now);
1421
+ const source = pending?.sourceDeviceId;
1422
+ if (source !== void 0 && this.exclusions.blocks(source)) {
1423
+ this.attribution.dropPending();
1424
+ this.ctx.logger.info("alarm trigger ignored — the sensor was open when the alarm was armed", {
1425
+ tags: { deviceId: source },
1426
+ meta: {
1427
+ panelDeviceId: this.id,
1428
+ ruleId: pending?.ruleId,
1429
+ rule: pending?.ruleName,
1430
+ state: stateAt(this.machine, now)
1431
+ }
1432
+ });
1433
+ return;
1434
+ }
840
1435
  const next = trigger(this.machine, this.delays, now);
841
1436
  if (next === this.machine) {
842
1437
  this.ctx.logger.info("alarm trigger ignored — the panel is not armed", {
@@ -896,12 +1491,14 @@ var NcAlarmPanelDevice = class extends require_dist.BaseDevice {
896
1491
  const hook = this.onPublish;
897
1492
  if (hook === null) return;
898
1493
  const source = this.attribution.current();
1494
+ const arming = exitDelayAt(this.machine, now);
899
1495
  const transition = alarmTransition({
900
1496
  previous,
901
1497
  next: state,
902
1498
  modes: NC_ALARM_MODES,
903
1499
  at: now,
904
- ...source !== null ? { source } : {}
1500
+ ...source !== null ? { source } : {},
1501
+ ...arming !== void 0 ? { arming } : {}
905
1502
  });
906
1503
  if (transition?.kind === "alarm-triggered") this.attribution.clear();
907
1504
  try {
@@ -939,11 +1536,14 @@ var NcAlarmPanelDevice = class extends require_dist.BaseDevice {
939
1536
  this.ticker = null;
940
1537
  }
941
1538
  /** 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. */
1539
+ * survive a restart or the alarm disarms itself on every deploy. The
1540
+ * exclusions ride with it, for the same reason and with the same lifetime. */
943
1541
  persist() {
944
1542
  this.config.set("machine", { ...this.machine });
1543
+ this.config.set("exclusions", [...this.exclusions.snapshot()]);
945
1544
  }
946
1545
  restore() {
1546
+ this.exclusions.restore(this.config.get("exclusions"));
947
1547
  const restored = machineFromPersisted(this.config.get("machine"));
948
1548
  if (restored === null) return;
949
1549
  this.machine = restored;
@@ -1719,10 +2319,15 @@ var MAX_ARM_BUTTONS = 3;
1719
2319
  *
1720
2320
  * | notification | buttons |
1721
2321
  * | --- | --- |
2322
+ * | `alarm-arming` | **Disarma**, destructive |
1722
2323
  * | `alarm-triggered` | **Disarma**, destructive |
1723
2324
  * | `alarm-armed` | **Disarma**, destructive |
1724
2325
  * | `alarm-disarmed` | **Arma …** per declared mode, max three |
1725
2326
  *
2327
+ * The `alarm-arming` row is the one that earns its keep: that notification
2328
+ * arrives at the START of the exit delay, so its Disarm is the only alarm
2329
+ * button an operator can press to cancel something rather than to undo it.
2330
+ *
1726
2331
  * `destructive` on the disarm is a RENDERING hint and never a gate (D47) — what
1727
2332
  * actually bounds it is the five-minute TTL the minter gives that grant
1728
2333
  * (`ALARM_DISARM_TTL_MS`). Both exist: the hint is for the person, the TTL is
@@ -3760,27 +4365,38 @@ var en_default = {
3760
4365
  "snooze.scope.anyDevice": "A camera",
3761
4366
  "snooze.scope.anyRule": "A rule",
3762
4367
  "snooze.scope.rule": "“{{rule}}”",
3763
- "alarm.arm.body.allDevices": {
3764
- "one": "Every device is armed ({{rules}} rule).",
3765
- "other": "Every device is armed ({{rules}} rules)."
3766
- },
3767
- "alarm.arm.body.noDevices": {
3768
- "one": "{{rules}} rule, no device restriction.",
3769
- "other": "{{rules}} rules, no device restriction."
3770
- },
3771
- "alarm.arm.body.devices": {
3772
- "one": "{{devices}} device armed: {{names}}.",
3773
- "other": "{{devices}} devices armed: {{names}}."
3774
- },
4368
+ "alarm.arm.body.allDevices": "Active: all devices",
4369
+ "alarm.arm.body.noDevices": "Active: no device restriction",
4370
+ "alarm.arm.body.devices": "Active: {{names}}",
4371
+ "alarm.arm.body.devicesSkipped": "Active: {{names}}\nExcluded: {{skippedNames}}",
4372
+ "alarm.arm.body.uncovered": "Active: none — no rule uses this mode",
4373
+ "alarm.arm.body.allSkipped": "Active: none\nExcluded: {{names}}",
4374
+ "alarm.arming.body": "No exclusions",
4375
+ "alarm.arming.body.uncovered": "Active: none — no rule uses this mode",
4376
+ "alarm.arming.body.allSkipped": "Active: none\nExcluded: {{names}}",
4377
+ "alarm.arming.body.skipped": "Excluded: {{names}}",
4378
+ "alarm.arm.body.bypassed": "Active: {{devices}}\nBypassed: {{bypassedNames}}",
4379
+ "alarm.arming.body.bypassed": "Bypassed: {{bypassedNames}}",
4380
+ "alarm.armRefused.body": "Open: {{names}}\nRepeat within {{seconds}}s to arm with bypass.",
3775
4381
  "alarm.device.unnamed": "Device {{id}}",
3776
- "system.alarm-armed.title.home": "Home armed",
3777
- "system.alarm-armed.title.away": "Away armed",
3778
- "system.alarm-armed.title.night": "Night armed",
3779
- "system.alarm-disarmed.title": "Alarm disarmed",
3780
- "system.alarm-disarmed.body": "The alarm was disarmed",
4382
+ "alarm.combined.title": "Alarm: {{deviceName}}",
4383
+ "system.alarm-arm-refused.title.home": "Arming home refused",
4384
+ "system.alarm-arm-refused.title.away": "Arming away refused",
4385
+ "system.alarm-arm-refused.title.night": "Arming night refused",
4386
+ "system.alarm-arm-refused.body": "Arming refused",
4387
+ "system.alarm-arming.title.home": "Arming home — {{seconds}}s",
4388
+ "system.alarm-arming.title.away": "Arming away — {{seconds}}s",
4389
+ "system.alarm-arming.title.night": "Arming night — {{seconds}}s",
4390
+ "system.alarm-arming.body": "Arming",
4391
+ "system.alarm-armed.title.home": "Armed: home",
4392
+ "system.alarm-armed.title.away": "Armed: away",
4393
+ "system.alarm-armed.title.night": "Armed: night",
4394
+ "system.alarm-armed.body": "Alarm armed",
4395
+ "system.alarm-disarmed.title": "Disarmed",
4396
+ "system.alarm-disarmed.body": "Alarm disarmed",
3781
4397
  "system.alarm-triggered.title": "ALARM",
3782
- "system.alarm-triggered.body": "The alarm was triggered",
3783
- "system.alarm-triggered.body.rule": "The alarm was triggered by {{rule}} on {{deviceName}}",
4398
+ "system.alarm-triggered.body": "Alarm triggered",
4399
+ "system.alarm-triggered.body.rule": "Triggered by {{rule}} on {{deviceName}}",
3784
4400
  "button.snooze.minutes": "Snooze {{minutes}} min",
3785
4401
  "button.snooze.hours": {
3786
4402
  "one": "Snooze {{hours}} hour",
@@ -3912,27 +4528,38 @@ var it_default = {
3912
4528
  "snooze.scope.anyDevice": "Una camera",
3913
4529
  "snooze.scope.anyRule": "Una regola",
3914
4530
  "snooze.scope.rule": "“{{rule}}”",
3915
- "alarm.arm.body.allDevices": {
3916
- "one": "Ogni dispositivo è attivo ({{rules}} regola).",
3917
- "other": "Ogni dispositivo è attivo ({{rules}} regole)."
3918
- },
3919
- "alarm.arm.body.noDevices": {
3920
- "one": "{{rules}} regola, nessuna restrizione sui dispositivi.",
3921
- "other": "{{rules}} regole, nessuna restrizione sui dispositivi."
3922
- },
3923
- "alarm.arm.body.devices": {
3924
- "one": "{{devices}} dispositivo attivo: {{names}}.",
3925
- "other": "{{devices}} dispositivi attivi: {{names}}."
3926
- },
4531
+ "alarm.arm.body.allDevices": "Attivi: tutti i dispositivi",
4532
+ "alarm.arm.body.noDevices": "Attivi: nessuna restrizione",
4533
+ "alarm.arm.body.devices": "Attivi: {{names}}",
4534
+ "alarm.arm.body.devicesSkipped": "Attivi: {{names}}\nEsclusi: {{skippedNames}}",
4535
+ "alarm.arm.body.uncovered": "Attivi: nessuno — nessuna regola usa questa modalità",
4536
+ "alarm.arm.body.allSkipped": "Attivi: nessuno\nEsclusi: {{names}}",
4537
+ "alarm.arming.body": "Nessuna esclusione",
4538
+ "alarm.arming.body.uncovered": "Attivi: nessuno — nessuna regola usa questa modalità",
4539
+ "alarm.arming.body.allSkipped": "Attivi: nessuno\nEsclusi: {{names}}",
4540
+ "alarm.arming.body.skipped": "Esclusi: {{names}}",
4541
+ "alarm.arm.body.bypassed": "Attivi: {{devices}}\nBypassati: {{bypassedNames}}",
4542
+ "alarm.arming.body.bypassed": "Bypassati: {{bypassedNames}}",
4543
+ "alarm.armRefused.body": "Aperti: {{names}}\nRipeti entro {{seconds}}s per inserire con bypass.",
3927
4544
  "alarm.device.unnamed": "Dispositivo {{id}}",
3928
- "system.alarm-armed.title.home": "Casa attivato",
3929
- "system.alarm-armed.title.away": "Fuori attivato",
3930
- "system.alarm-armed.title.night": "Notte attivato",
3931
- "system.alarm-disarmed.title": "Allarme disattivato",
3932
- "system.alarm-disarmed.body": "L'allarme è stato disattivato",
4545
+ "alarm.combined.title": "Allarme: {{deviceName}}",
4546
+ "system.alarm-arm-refused.title.home": "Inserimento Casa rifiutato",
4547
+ "system.alarm-arm-refused.title.away": "Inserimento Fuori rifiutato",
4548
+ "system.alarm-arm-refused.title.night": "Inserimento Notte rifiutato",
4549
+ "system.alarm-arm-refused.body": "Inserimento rifiutato",
4550
+ "system.alarm-arming.title.home": "Inserimento Casa — {{seconds}}s",
4551
+ "system.alarm-arming.title.away": "Inserimento Fuori — {{seconds}}s",
4552
+ "system.alarm-arming.title.night": "Inserimento Notte — {{seconds}}s",
4553
+ "system.alarm-arming.body": "Inserimento in corso",
4554
+ "system.alarm-armed.title.home": "Inserito: Casa",
4555
+ "system.alarm-armed.title.away": "Inserito: Fuori",
4556
+ "system.alarm-armed.title.night": "Inserito: Notte",
4557
+ "system.alarm-armed.body": "Allarme inserito",
4558
+ "system.alarm-disarmed.title": "Disinserito",
4559
+ "system.alarm-disarmed.body": "Allarme disinserito",
3933
4560
  "system.alarm-triggered.title": "ALLARME",
3934
- "system.alarm-triggered.body": "L'allarme è scattato",
3935
- "system.alarm-triggered.body.rule": "L'allarme è scattato per {{rule}} su {{deviceName}}",
4561
+ "system.alarm-triggered.body": "Allarme scattato",
4562
+ "system.alarm-triggered.body.rule": "Scattato da {{rule}} su {{deviceName}}",
3936
4563
  "button.snooze.minutes": "Silenzia {{minutes}} min",
3937
4564
  "button.snooze.hours": {
3938
4565
  "one": "Silenzia {{hours}} ora",
@@ -4651,7 +5278,8 @@ var NcDispatcher = class {
4651
5278
  const systemEvent = subject.systemEvent;
4652
5279
  const displayDeviceId = systemEvent?.deviceId ?? subject.deviceId;
4653
5280
  const deviceName = systemEvent !== void 0 && systemEvent.deviceId === void 0 ? "system" : await this.deps.getDeviceName(displayDeviceId).catch(() => null) ?? `camera ${displayDeviceId}`;
4654
- const zoneLabels = systemEvent !== void 0 ? [] : await resolveZoneLabels(this.deps.getZoneNames, subject.deviceId, subject.zones);
5281
+ const alarmSource = systemEvent?.alarmSource;
5282
+ const zoneLabels = systemEvent === void 0 ? await resolveZoneLabels(this.deps.getZoneNames, subject.deviceId, subject.zones) : alarmSource?.zones !== void 0 && alarmSource.zones.length > 0 ? await resolveZoneLabels(this.deps.getZoneNames, alarmSource.deviceId, alarmSource.zones) : [];
4655
5283
  const vars = buildTemplateVars(this.texts, entry, deviceName, zoneLabels);
4656
5284
  const systemCount = systemEvent?.textCount !== void 0 ? { count: systemEvent.textCount } : {};
4657
5285
  const title = this.renderText({
@@ -4994,6 +5622,10 @@ async function resolveZoneLabels(getZoneNames, deviceId, zoneIds) {
4994
5622
  return [...zoneIds];
4995
5623
  }
4996
5624
  }
5625
+ /** `{{confidence}}` as a whole percent, or empty when nothing measured one. */
5626
+ function percentOf(confidence) {
5627
+ return confidence !== void 0 ? `${Math.round(confidence * 100)}%` : "";
5628
+ }
4997
5629
  /**
4998
5630
  * The variable map ONE render pass sees: scalars, then the composites built
4999
5631
  * from them, then the params a system-event row froze at intake.
@@ -5009,19 +5641,23 @@ function buildTemplateVars(texts, entry, deviceName, zoneLabels) {
5009
5641
  const subject = entry.payload.subject;
5010
5642
  const occupancy = subject.occupancy;
5011
5643
  const systemEvent = subject.systemEvent;
5644
+ const detection = systemEvent?.alarmSource?.className !== void 0 ? systemEvent.alarmSource : void 0;
5012
5645
  return {
5013
5646
  camera: deviceName,
5014
- class: subject.className,
5015
- label: subject.label ?? "",
5647
+ class: detection?.className ?? subject.className,
5648
+ label: detection?.label ?? subject.label ?? "",
5016
5649
  zones: zoneLabels.join(", "),
5017
- subject: systemEvent?.subject ?? ncSubjectText(texts, {
5650
+ subject: detection?.className !== void 0 ? ncSubjectText(texts, {
5651
+ className: detection.className,
5652
+ ...detection.label !== void 0 ? { label: detection.label } : {}
5653
+ }) : systemEvent?.subject ?? ncSubjectText(texts, {
5018
5654
  className: subject.className,
5019
5655
  ...subject.label !== void 0 ? { label: subject.label } : {}
5020
5656
  }),
5021
5657
  inZones: ncInZonesText(texts, zoneLabels),
5022
5658
  scope: occupancy?.zone ?? deviceName,
5023
5659
  zone: occupancy?.zone ?? zoneLabels[0] ?? "",
5024
- confidence: subject.confidence !== void 0 ? `${Math.round(subject.confidence * 100)}%` : "",
5660
+ confidence: percentOf(detection?.confidence ?? subject.confidence),
5025
5661
  time: new Date(subject.timestamp).toLocaleTimeString(),
5026
5662
  rule: entry.payload.ruleName,
5027
5663
  count: occupancy !== void 0 ? `${occupancy.count}` : "",
@@ -5561,6 +6197,35 @@ function incomingFromSystemEvent(event, lookup) {
5561
6197
  }
5562
6198
  }
5563
6199
  /**
6200
+ * The COMBINED row's two keys, or null when this transition is not one.
6201
+ *
6202
+ * A rule that trips the alarm produces ONE notification, and the operator has
6203
+ * to be able to read it: the title is the severity marker over the camera
6204
+ * (`alarm.combined.title`, shipped as `Alarm: {{deviceName}}` and re-wordable
6205
+ * per rule or in the Texts tab), and the body is `detection.body` — the exact
6206
+ * sentence that rule's ordinary notification would have carried, with its own
6207
+ * class, identity and zones.
6208
+ *
6209
+ * The alarm used to write its own prose here ("The alarm was triggered by
6210
+ * Person at gate on Ingresso"), which made the one notification that matters
6211
+ * the only one in the system that did not look like the rule's others: no
6212
+ * identity, no zone, a different subject. The prefix carries the severity; the
6213
+ * familiar line carries the meaning.
6214
+ *
6215
+ * **Gated on the detection vocabulary, not on the source.** `detection.body`
6216
+ * renders `{{subject}}`, and a fire ref with no class composes it from the
6217
+ * system event's dedup slot — "alarm:42 su Ingresso". A source without a class
6218
+ * therefore keeps the alarm's own sentence, which is also what every row
6219
+ * enqueued before this deploy carries.
6220
+ */
6221
+ function combinedAlarmText(source) {
6222
+ if (source?.className === void 0) return null;
6223
+ return {
6224
+ titleKey: "alarm.combined.title",
6225
+ bodyKey: "detection.body"
6226
+ };
6227
+ }
6228
+ /**
5564
6229
  * The panel's own transition, as a system-event subject.
5565
6230
  *
5566
6231
  * IN-PROCESS, not off the bus. The alarm and the Notification Center live in
@@ -5583,6 +6248,7 @@ function incomingFromAlarmTransition(input) {
5583
6248
  const deviceId = source?.deviceId ?? panelDeviceId;
5584
6249
  const identity = input.lookup(deviceId);
5585
6250
  const kind = transition.kind;
6251
+ const combined = combinedAlarmText(source);
5586
6252
  return systemSubject({
5587
6253
  facts: {
5588
6254
  eventId: `alarm:${kind}:${String(transition.at)}`,
@@ -5598,17 +6264,18 @@ function incomingFromAlarmTransition(input) {
5598
6264
  },
5599
6265
  kind,
5600
6266
  subject: `alarm:${String(panelDeviceId)}`,
5601
- 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`),
6267
+ titleKey: combined !== null ? combined.titleKey : transition.mode !== void 0 ? `system.${kind}.title.${transition.mode}` : `system.${kind}.title`,
6268
+ bodyKey: combined?.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
+ ...kind === "alarm-arming" ? { seconds: `${transition.seconds ?? 0}` } : {},
6274
+ ...input.body?.textParams
5608
6275
  },
5609
6276
  deviceId,
5610
6277
  ...identity !== void 0 ? { deviceType: identity.type } : {},
5611
- ...input.arm !== void 0 ? { textCount: input.arm.textCount } : {},
6278
+ ...input.body !== void 0 ? { textCount: input.body.textCount } : {},
5612
6279
  ...source !== void 0 ? { alarmSource: source } : {}
5613
6280
  });
5614
6281
  }
@@ -6195,6 +6862,16 @@ var OccupancyWatcher = class {
6195
6862
  watched = /* @__PURE__ */ new Map();
6196
6863
  /** Confirmed + pending state, keyed by the full {@link OccupancyKey}. */
6197
6864
  states = /* @__PURE__ */ new Map();
6865
+ /** Rows SEEDED since the last {@link takeSeededRows} — the durable
6866
+ * write-through queue (see that method). */
6867
+ seeded = /* @__PURE__ */ new Map();
6868
+ /** Keys already reported through `onImpossibleScope` (once-per-key gate).
6869
+ * Pruned with the watched set, so re-authoring a rule reports again. */
6870
+ impossibleReported = /* @__PURE__ */ new Set();
6871
+ deps;
6872
+ constructor(deps = {}) {
6873
+ this.deps = deps;
6874
+ }
6198
6875
  /**
6199
6876
  * Replace the watched key set (rule-driven — recomputed on rule change).
6200
6877
  * Each distinct `(zone, class, threshold)` is its OWN watched partial key
@@ -6225,6 +6902,8 @@ var OccupancyWatcher = class {
6225
6902
  });
6226
6903
  }
6227
6904
  for (const key of [...this.states.keys()]) if (!this.watched.has(partialKeyOf(key))) this.states.delete(key);
6905
+ for (const key of [...this.seeded.keys()]) if (!this.watched.has(partialKeyOf(key))) this.seeded.delete(key);
6906
+ for (const key of [...this.impossibleReported]) if (!this.watched.has(partialKeyOf(key))) this.impossibleReported.delete(key);
6228
6907
  }
6229
6908
  /**
6230
6909
  * Feed one camera snapshot at time `now`, returning the edges that COMMIT on
@@ -6237,9 +6916,12 @@ var OccupancyWatcher = class {
6237
6916
  const resolved = resolveOccupancyScope(snapshot, spec);
6238
6917
  if (resolved === null) continue;
6239
6918
  const key = occupancyKey(deviceId, spec.zoneId, spec.className, spec.threshold);
6919
+ this.reportImpossibleScope(deviceId, key, spec);
6240
6920
  const existing = this.states.get(key);
6241
6921
  if (existing === void 0) {
6242
- this.states.set(key, seedState(deviceId, spec, resolved, now));
6922
+ const state = seedState(deviceId, spec, resolved, now);
6923
+ this.states.set(key, state);
6924
+ this.seeded.set(key, stateToRow(key, state, now));
6243
6925
  continue;
6244
6926
  }
6245
6927
  const edge = step(existing, spec, resolved, now);
@@ -6247,6 +6929,38 @@ var OccupancyWatcher = class {
6247
6929
  }
6248
6930
  return edges;
6249
6931
  }
6932
+ /**
6933
+ * Drain the rows seeded since the last call — the caller persists them
6934
+ * through the SAME ledger the committed edges use.
6935
+ *
6936
+ * Drained rather than exposed because a seed is a ONE-TIME fact: re-offering
6937
+ * it would turn a rare write into a per-frame one, which is the thing the
6938
+ * edge-only write-through was designed to avoid. Missing a drain costs
6939
+ * nothing worse than today's behaviour (a cold re-seed at the next boot), so
6940
+ * the caller may fail the write without corrupting anything.
6941
+ */
6942
+ takeSeededRows() {
6943
+ const rows = [...this.seeded.values()];
6944
+ this.seeded.clear();
6945
+ return rows;
6946
+ }
6947
+ /** Fire `onImpossibleScope` the first time a key with an uncountable class
6948
+ * scope is evaluated. See {@link OccupancyImpossibleScope}. */
6949
+ reportImpossibleScope(deviceId, key, spec) {
6950
+ const report = this.deps.onImpossibleScope;
6951
+ if (report === void 0) return;
6952
+ const className = spec.className;
6953
+ if (className === void 0 || require_dist.isDetectionMacroClass(className)) return;
6954
+ if (this.impossibleReported.has(key)) return;
6955
+ this.impossibleReported.add(key);
6956
+ report({
6957
+ deviceId,
6958
+ key,
6959
+ ...spec.zoneId !== void 0 ? { zoneId: spec.zoneId } : {},
6960
+ className,
6961
+ threshold: spec.threshold
6962
+ });
6963
+ }
6250
6964
  /** Reseed confirmed state from durable rows (boot). Only rows whose key is
6251
6965
  * currently WATCHED are restored — an orphaned durable row (its rule gone)
6252
6966
  * is dropped, keeping the RAM map bounded to the active set. Pending edges
@@ -6269,20 +6983,25 @@ var OccupancyWatcher = class {
6269
6983
  /** Snapshot the CONFIRMED state for durable persistence (pending excluded). */
6270
6984
  snapshotState() {
6271
6985
  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
- });
6986
+ for (const [key, state] of this.states) rows.push(stateToRow(key, state, state.lastChangeAt));
6283
6987
  return rows;
6284
6988
  }
6285
6989
  };
6990
+ /** The durable projection of one in-RAM state — the ONE place the persisted row
6991
+ * shape is built, so a seeded row and a snapshotted one cannot differ. */
6992
+ function stateToRow(key, state, updatedAt) {
6993
+ return {
6994
+ key,
6995
+ deviceId: state.deviceId,
6996
+ ...state.zoneId !== void 0 ? { zoneId: state.zoneId } : {},
6997
+ ...state.className !== void 0 ? { className: state.className } : {},
6998
+ threshold: state.threshold,
6999
+ confirmedCount: state.confirmedCount,
7000
+ occupied: state.occupied,
7001
+ lastChangeAt: state.lastChangeAt,
7002
+ updatedAt
7003
+ };
7004
+ }
6286
7005
  /**
6287
7006
  * The state a key starts from: WHAT THE FIRST SNAPSHOT SAID.
6288
7007
  *
@@ -6446,7 +7165,11 @@ function probeOccupancyNow(input) {
6446
7165
  * field for them would be a cross-package change for a sentence.
6447
7166
  */
6448
7167
  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?)";
7168
+ const classNote = impossibleClassNote(occupancy.className);
7169
+ if (probe.kind === "unavailable") {
7170
+ 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?)";
7171
+ return classNote === null ? reason : `${classNote} · ${reason}`;
7172
+ }
6450
7173
  const where = probe.zoneName ?? (occupancy.zoneId === void 0 ? "whole frame" : occupancy.zoneId);
6451
7174
  const what = occupancy.className ?? "objects";
6452
7175
  const parts = [
@@ -6455,7 +7178,21 @@ function occupancyProbeLabel(occupancy, probe, cooldownSec) {
6455
7178
  `fires on the transition, sustained ${String(occupancy.sustainSeconds)} s`
6456
7179
  ];
6457
7180
  if (cooldownSec > 0) parts.push(`cooldown ${String(cooldownSec)} s`);
6458
- return parts.join(" · ");
7181
+ return classNote === null ? parts.join(" · ") : `${classNote} · ${parts.join(" · ")}`;
7182
+ }
7183
+ /**
7184
+ * The one-clause warning for a class scope the snapshots can never key on, or
7185
+ * `null` when the scope is fine.
7186
+ *
7187
+ * The dry-run's whole job is to make "why does this rule never fire?" answerable
7188
+ * before the operator waits a night for it. A sub-type class produced the most
7189
+ * convincing wrong answer it can give — "0/1 car · not occupied now" — which is
7190
+ * pixel-identical to an empty parking space. `byClass` is MACRO-keyed, so that
7191
+ * counter is 0 structurally and no scene can change it.
7192
+ */
7193
+ function impossibleClassNote(className) {
7194
+ if (className === void 0 || require_dist.isDetectionMacroClass(className)) return null;
7195
+ return `⚠ “${className}” is a sub-type, not a macro class: this counter is always 0 (use ${[...require_dist.DETECTION_MACRO_CLASSES].join(" / ")})`;
6459
7196
  }
6460
7197
  /**
6461
7198
  * The mechanics, declared once.
@@ -11767,7 +12504,7 @@ var NC_ALARM_ANNOUNCE_RULE_ID = "nc-alarm-announce";
11767
12504
  * notification that says the alarm was disarmed is worse than offering nothing.
11768
12505
  */
11769
12506
  function alarmButtonKind(kind) {
11770
- if (kind === "alarm-triggered" || kind === "alarm-armed" || kind === "alarm-disarmed") return kind;
12507
+ if (kind === "alarm-triggered" || kind === "alarm-armed" || kind === "alarm-arming" || kind === "alarm-disarmed") return kind;
11771
12508
  return null;
11772
12509
  }
11773
12510
  /**
@@ -11848,14 +12585,20 @@ function frozenOccupancy(rule, occ) {
11848
12585
  */
11849
12586
  function fireRefFor(rule, subject, kind) {
11850
12587
  const hasEventMedia = kind === "object-event" || kind === "package-event";
12588
+ const className = subject.classNames[0];
11851
12589
  return {
11852
12590
  ruleId: rule.id,
11853
12591
  ruleName: rule.name,
11854
12592
  deviceId: subject.deviceId,
12593
+ ...subject.sourceDeviceId !== void 0 ? { sourceDeviceId: subject.sourceDeviceId } : {},
11855
12594
  recordId: subject.recordId,
11856
12595
  ...subject.trackId !== void 0 ? { trackId: subject.trackId } : {},
11857
12596
  ...hasEventMedia ? { eventId: subject.recordId } : {},
11858
- timestamp: subject.timestamp
12597
+ timestamp: subject.timestamp,
12598
+ ...className !== void 0 ? { className } : {},
12599
+ ...subject.label !== void 0 ? { label: subject.label } : {},
12600
+ zones: notificationZones(rule, subject),
12601
+ ...subject.confidence !== void 0 ? { confidence: subject.confidence } : {}
11859
12602
  };
11860
12603
  }
11861
12604
  function notificationZones(rule, subject) {
@@ -11894,17 +12637,55 @@ function historyRecordKind(kind) {
11894
12637
  * showed a key while the phone showed a sentence would be a second vocabulary
11895
12638
  * to keep in step. A pre-catalog row falls back to the pair frozen on it.
11896
12639
  */
12640
+ /**
12641
+ * Cap-facing downgrade of a system-event kind — the SAME device as
12642
+ * {@link historyRecordKind}, and it exists for the same reason.
12643
+ *
12644
+ * This addon bundles its own `@camstack/types` (self-contained externals), so
12645
+ * it can emit a kind the enum in the RUNNING SERVER does not have. The history
12646
+ * router does not: it validates its output against the host's
12647
+ * `NcHistorySubjectSchema`, whose `kind` is a `z.enum`. One row carrying a kind
12648
+ * the host has never heard of therefore does not degrade — it fails the whole
12649
+ * `getHistory` call, for every row, exactly as the enum's own legacy-tail note
12650
+ * warns.
12651
+ *
12652
+ * `alarm-arming` is projected onto `alarm-armed`, its nearest member: same
12653
+ * panel, same lifecycle, adjacent instant. Nothing a person reads changes —
12654
+ * the row's title and body are resolved from the arming keys ("Inserimento
12655
+ * Fuori — attivo tra 30 secondi") — only the machine-facing word does.
12656
+ *
12657
+ * DELETE THIS when a `@camstack/server` closure carrying `alarm-arming` in
12658
+ * `NcSystemEventKindSchema` is installed everywhere this addon runs. Until
12659
+ * then it is what lets the exit-delay notification ship without a train.
12660
+ */
12661
+ /**
12662
+ * The mirrored device-state word, narrowed to a contact reading.
12663
+ *
12664
+ * Anything that is not one of the two contact words is `undefined` — a device
12665
+ * whose slice the state table read as `on` / `armed_home` is not a contact, and
12666
+ * an exclusion must never be lifted by a word that belongs to another cap.
12667
+ */
12668
+ function contactReadingOf(state) {
12669
+ if (state === "open" || state === "closed") return state;
12670
+ }
12671
+ function historySystemEventKind(kind) {
12672
+ if (kind === "alarm-arming") return "alarm-armed";
12673
+ if (kind === "alarm-arm-refused") return "alarm-disarmed";
12674
+ return kind;
12675
+ }
11897
12676
  function historySystemEvent(texts, systemEvent) {
11898
12677
  const vars = systemEvent.textParams ?? {};
12678
+ const count = systemEvent.textCount;
11899
12679
  const resolve = (key, frozen) => {
11900
12680
  if (key !== void 0 && texts.has(key)) return texts.text({
11901
12681
  key,
11902
- vars
12682
+ vars,
12683
+ ...count !== void 0 ? { count } : {}
11903
12684
  });
11904
12685
  return frozen ?? "";
11905
12686
  };
11906
12687
  return {
11907
- kind: systemEvent.kind,
12688
+ kind: historySystemEventKind(systemEvent.kind),
11908
12689
  subject: systemEvent.subject,
11909
12690
  title: resolve(systemEvent.titleKey, systemEvent.title),
11910
12691
  body: resolve(systemEvent.bodyKey, systemEvent.body),
@@ -11966,8 +12747,22 @@ var NotificationCenter = class NotificationCenter {
11966
12747
  /** Debounced occupancy edge state machine (pure) + its durable confirmed
11967
12748
  * state. Fed in-process by {@link observeOccupancy} from ZoneAnalytics
11968
12749
  * snapshots; watched keys are recomputed from the enabled occupancy rules. */
11969
- occupancyWatcher = new OccupancyWatcher();
12750
+ occupancyWatcher = new OccupancyWatcher({ onImpossibleScope: (scope) => {
12751
+ this.logger.warn("occupancy key can never count — class is a sub-type, not a macro", {
12752
+ tags: { deviceId: scope.deviceId },
12753
+ meta: {
12754
+ key: scope.key,
12755
+ zoneId: scope.zoneId ?? "@frame",
12756
+ className: scope.className,
12757
+ threshold: scope.threshold,
12758
+ macros: [...require_dist.DETECTION_MACRO_CLASSES].join(",")
12759
+ }
12760
+ });
12761
+ } });
11970
12762
  occupancyStore;
12763
+ /** Rendered watched-occupancy-key set as last logged — the change gate for
12764
+ * {@link reportOccupancyWatch} (it runs on every rule-reload tick). */
12765
+ lastOccupancyWatchReport = "";
11971
12766
  /**
11972
12767
  * The sustained-sound sampling-window matcher (pure). Fed in-process by
11973
12768
  * {@link observeAudio} from the pipeline's audio inference frames; watched
@@ -12778,8 +13573,18 @@ var NotificationCenter = class NotificationCenter {
12778
13573
  * before it.
12779
13574
  */
12780
13575
  onSensorEventPersisted(event, markerTrackId) {
13576
+ this.observeContactEvent(event);
12781
13577
  this.consumeEvent(incomingFromSensorEvent(event, markerTrackId));
12782
13578
  }
13579
+ /** The contact reading a sensor row carries, handed to the panel. Nothing at
13580
+ * all for any other sensor kind — see {@link observeAlarmContacts}. */
13581
+ observeContactEvent(event) {
13582
+ const panel = this.alarmPanel;
13583
+ if (panel === null || event.kind !== "contact") return;
13584
+ const entryOpen = event.value?.["entryOpen"];
13585
+ if (typeof entryOpen !== "boolean") return;
13586
+ panel.observeContact(event.sourceDeviceId, entryOpen ? "open" : "closed", event.timestamp);
13587
+ }
12783
13588
  /**
12784
13589
  * Called at the AUDIO-event persist site (`eventStore.insertAudio`), in the
12785
13590
  * SAME moment as the durable insert. Feeds an `immediate` rule that OPTS IN
@@ -12826,7 +13631,20 @@ var NotificationCenter = class NotificationCenter {
12826
13631
  });
12827
13632
  return;
12828
13633
  }
13634
+ this.persistSeededOccupancy(now);
12829
13635
  for (const edge of edges) {
13636
+ this.logger.info("occupancy edge committed", {
13637
+ tags: { deviceId: edge.deviceId },
13638
+ meta: {
13639
+ zoneId: edge.zoneId ?? "@frame",
13640
+ zoneName: edge.zoneName ?? "",
13641
+ className: edge.className ?? "@all",
13642
+ threshold: edge.threshold,
13643
+ count: edge.count,
13644
+ previousCount: edge.previousCount,
13645
+ occupied: edge.occupied
13646
+ }
13647
+ });
12830
13648
  this.persistOccupancyEdge(edge, now);
12831
13649
  this.onOccupancyEdge(edge);
12832
13650
  }
@@ -13031,6 +13849,119 @@ var NotificationCenter = class NotificationCenter {
13031
13849
  setAlarmPanel(panel) {
13032
13850
  this.alarmPanel = panel;
13033
13851
  }
13852
+ /**
13853
+ * What the panel asks before it arms — see {@link NcAlarmArmGuardHook}.
13854
+ *
13855
+ * Handed to the panel by the same wiring that hands it the publish hook: the
13856
+ * panel owns the state machine, the centre owns the rules that say which
13857
+ * contacts a mode covers, and neither can reach the other on its own.
13858
+ */
13859
+ alarmArmGuard() {
13860
+ return {
13861
+ openContacts: (mode) => this.alarmOpenContacts(mode),
13862
+ announceRefusal: (input) => this.announceArmRefused(input)
13863
+ };
13864
+ }
13865
+ /**
13866
+ * Which of the contacts this mode covers read OPEN, right now.
13867
+ *
13868
+ * A TARGETED read of at most the handful of sensors the mode names, not a
13869
+ * refresh of the whole watched set: the arm is a person pressing a button and
13870
+ * the answer has to be about this second, but it must not become a fan-out
13871
+ * over every gated device on the hub.
13872
+ *
13873
+ * Three fail-open rules, and all three exist because refusing an arm on
13874
+ * ignorance is worse than arming:
13875
+ *
13876
+ * - a sensor the read could not answer for falls back to the mirrored state
13877
+ * (bounded staleness, the same contract the `deviceState` gate lives with);
13878
+ * - a sensor NEITHER can answer for is not blocking, and says so in a line;
13879
+ * - a mode whose contact rule names no devices blocks nothing, and says so —
13880
+ * an unscoped contact rule covers every contact on the hub, and a refusal
13881
+ * naming devices the operator never associated with the alarm is one they
13882
+ * cannot act on.
13883
+ */
13884
+ async alarmOpenContacts(mode) {
13885
+ const panel = this.alarmPanel;
13886
+ if (panel === null) return [];
13887
+ const candidates = alarmBlockingCandidates(this.rules.list(), panel.deviceId, mode);
13888
+ if (candidates.length === 0) return [];
13889
+ const fresh = await this.readContactStates(candidates);
13890
+ const name = this.deviceNameFromDirectory();
13891
+ const open = [];
13892
+ const unknown = [];
13893
+ for (const deviceId of candidates) {
13894
+ const state = fresh.get(deviceId) ?? this.deviceStates.get(deviceId);
13895
+ if (state === void 0) {
13896
+ unknown.push(deviceId);
13897
+ continue;
13898
+ }
13899
+ if (state === "open") open.push({
13900
+ deviceId,
13901
+ name: name(deviceId)
13902
+ });
13903
+ }
13904
+ for (const deviceId of unknown) this.logger.info("alarm arm check: no contact state — the sensor cannot block this arm", {
13905
+ tags: { deviceId },
13906
+ meta: {
13907
+ panelDeviceId: panel.deviceId,
13908
+ mode
13909
+ }
13910
+ });
13911
+ this.logger.info("alarm arm check", {
13912
+ tags: { deviceId: panel.deviceId },
13913
+ meta: {
13914
+ mode,
13915
+ candidates: candidates.length,
13916
+ open: open.length,
13917
+ unknown: unknown.length,
13918
+ openIds: open.map((c) => c.deviceId).join(",")
13919
+ }
13920
+ });
13921
+ return open;
13922
+ }
13923
+ /** One bounded read of the named contacts. Never throws — an unreadable
13924
+ * sensor is simply absent, exactly as `NcDeviceStateCache` contracts. */
13925
+ async readContactStates(ids) {
13926
+ const read = this.deps.readDeviceStates;
13927
+ if (read === void 0) return /* @__PURE__ */ new Map();
13928
+ try {
13929
+ return await read(ids);
13930
+ } catch (err) {
13931
+ this.logger.warn("alarm arm check: contact read failed — falling back to the mirror", { meta: {
13932
+ devices: ids.length,
13933
+ error: String(err)
13934
+ } });
13935
+ return /* @__PURE__ */ new Map();
13936
+ }
13937
+ }
13938
+ /**
13939
+ * The refusal, as an ordinary notification.
13940
+ *
13941
+ * Same path the arm announcement takes (D126: the alarm speaks through the
13942
+ * ordinary notification path), so it is durable, retried, in history and
13943
+ * worded in the reader's language at send time. It is the operator's ONLY
13944
+ * copy: the thrown error reaches whoever called the cap, and when that caller
13945
+ * is a notification button on a phone, nobody ever sees it.
13946
+ */
13947
+ announceArmRefused(input) {
13948
+ const panel = this.alarmPanel;
13949
+ if (panel === null) return;
13950
+ const text = buildArmRefusedAnnouncement(input.mode, input.blocking.map((c) => c.name), input.retrySeconds);
13951
+ const transition = {
13952
+ kind: "alarm-arm-refused",
13953
+ state: "disarmed",
13954
+ previous: "disarmed",
13955
+ mode: input.mode,
13956
+ at: input.at
13957
+ };
13958
+ const settings = panel.settings();
13959
+ const targetIds = settings.announceArm ? settings.announceTargets : [];
13960
+ this.scheduleAlarmTransition(panel, transition, {
13961
+ text,
13962
+ targetIds
13963
+ });
13964
+ }
13034
13965
  /** Settings + the DERIVED coverage. Read together — see the cap doc. */
13035
13966
  alarmConfig() {
13036
13967
  const panel = this.alarmPanel;
@@ -13067,7 +13998,7 @@ var NotificationCenter = class NotificationCenter {
13067
13998
  const identity = this.deviceDirectory.get(deviceId);
13068
13999
  if (identity === void 0) return void 0;
13069
14000
  if (identity.online === false) return "offline";
13070
- if (identity.detectionActive === false) return "detection-off";
14001
+ if (identity.isCamera && identity.detectionActive === false) return "detection-off";
13071
14002
  }
13072
14003
  /**
13073
14004
  * Everything the panel reports, on every publish.
@@ -13104,13 +14035,13 @@ var NotificationCenter = class NotificationCenter {
13104
14035
  * the combined notification: one row carrying the alarm's words and that
13105
14036
  * rule's pictures, instead of two pushes about one incident.
13106
14037
  */
13107
- scheduleAlarmTransition(panel, transition) {
13108
- const arm = transition.kind === "alarm-armed" && transition.mode !== void 0 ? this.armAnnouncement(panel, transition.mode) : null;
14038
+ scheduleAlarmTransition(panel, transition, prepared) {
14039
+ const arm = prepared ?? this.armAnnouncement(panel, transition);
13109
14040
  const incoming = incomingFromAlarmTransition({
13110
14041
  transition,
13111
14042
  panelDeviceId: panel.deviceId,
13112
14043
  lookup: this.deviceDirectory.lookup(),
13113
- ...arm !== null ? { arm: arm.text } : {}
14044
+ ...arm !== null ? { body: arm.text } : {}
13114
14045
  });
13115
14046
  this.evalChain = this.evalChain.then(async () => {
13116
14047
  try {
@@ -13131,31 +14062,45 @@ var NotificationCenter = class NotificationCenter {
13131
14062
  });
13132
14063
  }
13133
14064
  /**
13134
- * The arm sentence and the targets it goes to, or null when this arm is not
13135
- * announced at all.
14065
+ * The arm/arming sentence and the targets it goes to, or null when the
14066
+ * transition is not one of those two.
14067
+ *
14068
+ * Both kinds compose from the SAME coverage — "what does this mode actually
14069
+ * watch" — because they are the same answer given thirty seconds apart. The
14070
+ * arming one is the useful one: it reaches the operator while the exit delay
14071
+ * is still running, so a mode that protects nothing can still be fixed.
13136
14072
  *
13137
14073
  * Every way of NOT announcing produces a line: an announcement that silently
13138
14074
  * 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) {
14075
+ * is the exact confusion this feature exists to remove. A COVERAGE GAP no
14076
+ * longer suppresses the sentence — it changes it. Suppressing it left the
14077
+ * operator with a title and an empty line (the fallback body key existed in
14078
+ * neither locale) and no way to learn that the mode was gated on by nothing.
14079
+ */
14080
+ armAnnouncement(panel, transition) {
14081
+ const kind = transition.kind;
14082
+ if (kind !== "alarm-armed" && kind !== "alarm-arming") return null;
14083
+ const mode = transition.mode;
14084
+ if (mode === void 0) return null;
13144
14085
  const settings = panel.settings();
13145
14086
  const tags = { deviceId: panel.deviceId };
13146
14087
  const coverage = this.coverageFor(panel).find((c) => c.mode === mode);
13147
14088
  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
- }
14089
+ const name = this.deviceNameFromDirectory();
14090
+ const bypassed = panel.excludedDevices();
14091
+ const text = kind === "alarm-arming" ? buildArmingAnnouncement(coverage, transition.seconds ?? 0, name, bypassed) : buildArmAnnouncement(coverage, name, bypassed);
14092
+ const gap = alarmCoverageGap(coverage);
14093
+ if (gap !== null) this.logger.warn("alarm armed into a mode that watches nothing", {
14094
+ tags,
14095
+ meta: {
14096
+ mode,
14097
+ kind,
14098
+ gap,
14099
+ rules: coverage.ruleCount,
14100
+ devices: coverage.deviceIds.length,
14101
+ skipped: coverage.skippedDevices.length
14102
+ }
14103
+ });
13159
14104
  if (!settings.announceArm) return {
13160
14105
  text,
13161
14106
  targetIds: []
@@ -13699,6 +14644,7 @@ var NotificationCenter = class NotificationCenter {
13699
14644
  }
13700
14645
  this.occupancyWatcher.setWatchedKeys(specs);
13701
14646
  this.occupancyEnabled = specs.length > 0;
14647
+ this.reportOccupancyWatch(specs);
13702
14648
  const audioSpecs = [];
13703
14649
  for (const rule of this.rules.listEnabled("immediate")) {
13704
14650
  const audio = rule.conditions.audio;
@@ -13725,9 +14671,44 @@ var NotificationCenter = class NotificationCenter {
13725
14671
  const devices = rule.conditions.devices;
13726
14672
  if (zones !== void 0 && zones.ids.length > 0 && devices !== void 0) zoneScoped.push(...devices);
13727
14673
  }
14674
+ const panel = this.alarmPanel;
14675
+ if (panel !== null) gated.push(...alarmContactWatchList(this.rules.list(), panel.deviceId, panel.availableModes()));
13728
14676
  this.deviceStates.setWatched(gated);
13729
14677
  this.zoneOwners.setWatched(zoneScoped);
13730
14678
  }
14679
+ /**
14680
+ * Feed every excluded sensor's mirrored state back to the panel.
14681
+ *
14682
+ * The reconcile half of the two-phase lift. The EVENT half
14683
+ * ({@link onSensorEventPersisted}) is the fast one, but it only exists for a
14684
+ * sensor linked to a camera — the seven contacts on this hub are linked to
14685
+ * none, so without this an exclusion would never lift at all and the sensor
14686
+ * would stay out of the alarm until a disarm.
14687
+ */
14688
+ observeAlarmContacts() {
14689
+ const panel = this.alarmPanel;
14690
+ if (panel === null) return;
14691
+ const excluded = panel.excludedDevices();
14692
+ if (excluded.length === 0) return;
14693
+ const now = this.now();
14694
+ for (const deviceId of excluded) panel.observeContact(deviceId, contactReadingOf(this.deviceStates.get(deviceId)), now);
14695
+ }
14696
+ /**
14697
+ * Log the watched occupancy key set, on CHANGE only.
14698
+ *
14699
+ * Change-only because this runs on every rule-reload tick; a line per tick
14700
+ * would be noise, and no line at all is how a rule that watches an
14701
+ * impossible scope stays invisible for a day.
14702
+ */
14703
+ reportOccupancyWatch(specs) {
14704
+ const rendered = specs.map((s) => `${s.zoneId ?? "@frame"}|${s.className ?? "@all"}|t${String(s.threshold)}@${String(s.sustainSeconds)}s`).toSorted().join(",");
14705
+ if (rendered === this.lastOccupancyWatchReport) return;
14706
+ this.lastOccupancyWatchReport = rendered;
14707
+ this.logger.info("occupancy watched keys", { meta: {
14708
+ keys: specs.length,
14709
+ specs: rendered.length > 0 ? rendered : "(none)"
14710
+ } });
14711
+ }
13731
14712
  /** Boot reseed of confirmed occupancy edge-state (durability, constraint 4) —
13732
14713
  * hydrate the watcher from the store, then prune orphaned durable rows to the
13733
14714
  * active watched set. Runs AFTER {@link refreshOccupancyWatch} so hydrate
@@ -13790,6 +14771,7 @@ var NotificationCenter = class NotificationCenter {
13790
14771
  await this.deviceStates.refresh();
13791
14772
  await this.deviceDirectory.refresh();
13792
14773
  await this.zoneOwners.refresh();
14774
+ this.observeAlarmContacts();
13793
14775
  await this.occupancyStore.pruneExcept(this.activeOccupancyKeys());
13794
14776
  }
13795
14777
  /**
@@ -13815,26 +14797,63 @@ var NotificationCenter = class NotificationCenter {
13815
14797
  activeOccupancyKeys() {
13816
14798
  return new Set(this.occupancyWatcher.snapshotState().map((r) => r.key));
13817
14799
  }
14800
+ /**
14801
+ * Write-through the rows the watcher SEEDED on this observation — the initial
14802
+ * level of a key that had neither a durable row nor RAM state.
14803
+ *
14804
+ * Same ledger, same best-effort contract as an edge: a failed write leaves
14805
+ * exactly today's behaviour (a cold re-seed at the next boot), never a
14806
+ * corrupt level. Drained, so a key is written once and not per frame.
14807
+ */
14808
+ persistSeededOccupancy(now) {
14809
+ const rows = this.occupancyWatcher.takeSeededRows();
14810
+ for (const row of rows) {
14811
+ this.logger.info("occupancy baseline seeded", {
14812
+ tags: { deviceId: row.deviceId },
14813
+ meta: {
14814
+ key: row.key,
14815
+ zoneId: row.zoneId ?? "@frame",
14816
+ className: row.className ?? "@all",
14817
+ threshold: row.threshold,
14818
+ count: row.confirmedCount,
14819
+ occupied: row.occupied
14820
+ }
14821
+ });
14822
+ this.persistOccupancyRow(row, now);
14823
+ }
14824
+ }
13818
14825
  /** Write-through the committed confirmed level for one edge (rare — only on a
13819
14826
  * boolean flip). Best-effort: a failed persist is logged, never thrown into
13820
14827
  * the frame path (the boot reseed + next edge recover). */
13821
- async persistOccupancyEdge(edge, now) {
14828
+ persistOccupancyEdge(edge, now) {
14829
+ return this.persistOccupancyRow({
14830
+ key: occupancyKey(edge.deviceId, edge.zoneId, edge.className, edge.threshold),
14831
+ deviceId: edge.deviceId,
14832
+ ...edge.zoneId !== void 0 ? { zoneId: edge.zoneId } : {},
14833
+ ...edge.className !== void 0 ? { className: edge.className } : {},
14834
+ threshold: edge.threshold,
14835
+ confirmedCount: edge.count,
14836
+ occupied: edge.occupied,
14837
+ lastChangeAt: edge.timestamp,
14838
+ updatedAt: now
14839
+ }, now);
14840
+ }
14841
+ /** The ONE durable write for a confirmed occupancy level, whether it came from
14842
+ * a committed edge or a cold seed. Best-effort by contract — see the two
14843
+ * callers. */
14844
+ async persistOccupancyRow(row, now) {
13822
14845
  try {
13823
14846
  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,
14847
+ ...row,
13832
14848
  updatedAt: now
13833
14849
  });
13834
14850
  } catch (err) {
13835
14851
  this.logger.debug("occupancy persist failed", {
13836
- tags: { deviceId: edge.deviceId },
13837
- meta: { error: String(err) }
14852
+ tags: { deviceId: row.deviceId },
14853
+ meta: {
14854
+ key: row.key,
14855
+ error: String(err)
14856
+ }
13838
14857
  });
13839
14858
  }
13840
14859
  }
@@ -35057,6 +36076,138 @@ var BindingCache = class {
35057
36076
  }
35058
36077
  };
35059
36078
  //#endregion
36079
+ //#region src/pipeline-analytics/runtime/detection-coverage-cache.ts
36080
+ /**
36081
+ * DetectionCoverageCache — fleet-seeded, event-invalidated mirror of WHICH
36082
+ * devices carry a WRAPPED `detection-pipeline` binding. This is the alarm's
36083
+ * coverage authority ("Away armed, 12 cameras" is false if three of them are
36084
+ * offline or no longer wrapped), read by the notification centre's device
36085
+ * directory (`listDeviceIdentities`).
36086
+ *
36087
+ * WHY a mirror, not a per-tick sweep. The directory refreshes on the NC's 30 s
36088
+ * rule-reload tick. Answering "who still has detection?" with a fleet-wide
36089
+ * `getAllBindings` on EVERY tick cost 903 ms / 373 KB / ~505 `getBindings:
36090
+ * default wrappers skipped` log lines on the live hub (524 devices), forever —
36091
+ * ~3% of hub-main's main thread plus a permanent log-spam baseline. Same defect
36092
+ * and fix as `SystemMirror.refreshBinding` (639073e8): ONE fleet read to warm,
36093
+ * then per-device updates driven by `DeviceBindingsChanged` events. A wrapper
36094
+ * toggle emits that event carrying the device's id and `capName`
36095
+ * (`detection-pipeline`), so a single `getBindings(deviceId)` — 21 ms / 613 B —
36096
+ * keeps the mirror current without ever touching the fleet again.
36097
+ *
36098
+ * ── Destructive-direction discipline (D49) ──────────────────────────────────
36099
+ *
36100
+ * `detectionActive: false` reports a camera as UNCOVERED / switched-off (D62) —
36101
+ * the destructive answer, and the one an operator reads as "my alarm is not
36102
+ * watching that door". A transient `getBindings` blip must never flip a
36103
+ * known-covered camera to uncovered:
36104
+ * • a failed refresh KEEPS the previous value (a read that throws changes
36105
+ * nothing), and
36106
+ * • a single read that would flip a known `true → false` is confirmed by a
36107
+ * concordant SECOND read before it lands — an empty binding list is not
36108
+ * proof the wrapper is gone.
36109
+ * A COLD mirror (seed not yet landed, or a seed that failed) answers `undefined`
36110
+ * for every id — and unknown never reports a camera as excluded from coverage.
36111
+ */
36112
+ var DetectionCoverageCache = class {
36113
+ api;
36114
+ logger;
36115
+ /** deviceId → "has a WRAPPED detection-pipeline binding". Absent = unknown. */
36116
+ state = /* @__PURE__ */ new Map();
36117
+ warm = false;
36118
+ seeding = null;
36119
+ constructor(deps) {
36120
+ this.api = deps.api;
36121
+ this.logger = deps.logger;
36122
+ }
36123
+ /** Has the ONE fleet seed ever landed? A cold mirror knows nothing. */
36124
+ isWarm() {
36125
+ return this.warm;
36126
+ }
36127
+ /** Do we have an answer for this id at all? (`false` ⇒ absent ⇒ unknown.) */
36128
+ has(deviceId) {
36129
+ return this.state.has(deviceId);
36130
+ }
36131
+ /** The coverage answer, or `undefined` when this id is unknown. No I/O. */
36132
+ get(deviceId) {
36133
+ return this.state.get(deviceId);
36134
+ }
36135
+ /**
36136
+ * Seed the whole fleet ONCE with a single `getAllBindings`. Idempotent: a
36137
+ * warm cache is a no-op, and concurrent callers coalesce onto one read. A
36138
+ * failed seed leaves the mirror COLD so the next tick retries — during that
36139
+ * window every id answers unknown, which never reports a camera as excluded.
36140
+ */
36141
+ async ensureWarm() {
36142
+ if (this.warm) return;
36143
+ if (this.seeding) return this.seeding;
36144
+ this.seeding = this.seed();
36145
+ try {
36146
+ await this.seeding;
36147
+ } finally {
36148
+ this.seeding = null;
36149
+ }
36150
+ }
36151
+ async seed() {
36152
+ try {
36153
+ const rows = await this.api.deviceManager.getAllBindings.query({});
36154
+ for (const row of rows) {
36155
+ if (this.state.has(row.deviceId)) continue;
36156
+ this.state.set(row.deviceId, hasWrappedDetection(row.entries));
36157
+ }
36158
+ this.warm = true;
36159
+ this.logger.info("detection-coverage mirror warm", { meta: { devices: this.state.size } });
36160
+ } catch (err) {
36161
+ this.logger.warn("detection-coverage seed failed — coverage unknown until it lands", { meta: { error: String(err) } });
36162
+ }
36163
+ }
36164
+ /**
36165
+ * A `DeviceBindingsChanged` for the detection cap re-reads THAT ONE device
36166
+ * (never the fleet). Fire-and-forget from the synchronous event handler.
36167
+ */
36168
+ onBindingsChanged(event) {
36169
+ if (event.capName !== "detection-pipeline") return Promise.resolve();
36170
+ return this.refreshDevice(event.deviceId);
36171
+ }
36172
+ /** Drop a device the fleet no longer has (`device.unregistered`). */
36173
+ forget(deviceId) {
36174
+ this.state.delete(deviceId);
36175
+ }
36176
+ /** Return to cold — used on addon shutdown. */
36177
+ clearAll() {
36178
+ this.state.clear();
36179
+ this.warm = false;
36180
+ this.seeding = null;
36181
+ }
36182
+ async refreshDevice(deviceId) {
36183
+ try {
36184
+ const active = await this.readWrappedDetection(deviceId);
36185
+ const prev = this.state.get(deviceId);
36186
+ if (!active && prev === true) {
36187
+ if (await this.readWrappedDetection(deviceId)) {
36188
+ this.state.set(deviceId, true);
36189
+ return;
36190
+ }
36191
+ }
36192
+ this.state.set(deviceId, active);
36193
+ } catch (err) {
36194
+ this.logger.debug("detection-coverage refresh failed — keeping the previous value", {
36195
+ tags: { deviceId },
36196
+ meta: { error: String(err) }
36197
+ });
36198
+ }
36199
+ }
36200
+ async readWrappedDetection(deviceId) {
36201
+ return hasWrappedDetection((await this.api.deviceManager.getBindings.query({ deviceId })).entries);
36202
+ }
36203
+ };
36204
+ /** The one authority: a WRAPPED `detection-pipeline` binding is present. A
36205
+ * native binding of the same cap is NOT coverage (D62: absence of the WRAPPED
36206
+ * binding is how a deactivated wrapper is represented). */
36207
+ function hasWrappedDetection(entries) {
36208
+ return entries.some((e) => e.capName === "detection-pipeline" && e.kind === "wrapped");
36209
+ }
36210
+ //#endregion
35060
36211
  //#region src/pipeline-analytics/runtime/capture-scheduler.ts
35061
36212
  /**
35062
36213
  * Kind-derived default priorities (0 = highest). Explicit `priority` wins.
@@ -39921,7 +41072,13 @@ var ZoneAnalyticsProvider = class {
39921
41072
  meta: {
39922
41073
  totalObjects: total,
39923
41074
  byClass: snapshot.frame.byClass,
39924
- zones: snapshot.zones.length
41075
+ zones: snapshot.zones.length,
41076
+ zoneCounts: snapshot.zones.map((z) => ({
41077
+ zoneId: z.zoneId,
41078
+ zoneName: z.zoneName,
41079
+ totalObjects: z.totalObjects,
41080
+ byClass: z.byClass
41081
+ }))
39925
41082
  }
39926
41083
  });
39927
41084
  this.ctx.emitOccupancyChanged?.({
@@ -40433,6 +41590,12 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
40433
41590
  * path). Null on non-post-processing nodes or when disabled. */
40434
41591
  embeddingDispatcher = null;
40435
41592
  bindingCache = null;
41593
+ /** Alarm-coverage mirror the NC device directory reads — WHICH devices carry
41594
+ * a wrapped `detection-pipeline` binding. Fleet-seeded ONCE, then per-device
41595
+ * updates off `DeviceBindingsChanged`, so the 30 s rule-reload tick stops
41596
+ * sweeping `getAllBindings` over the whole fleet. See
41597
+ * `runtime/detection-coverage-cache.ts`. */
41598
+ detectionCoverage = null;
40436
41599
  /** Per-device throttle for the inactive-binding frame-drop warn (5 min). */
40437
41600
  inactiveBindingDropWarnAt = /* @__PURE__ */ new Map();
40438
41601
  zoneAnalytics = null;
@@ -41223,9 +42386,12 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
41223
42386
  settings: () => panel.settings(),
41224
42387
  applySettings: (patch) => panel.applySettings(patch),
41225
42388
  availableModes: () => NC_ALARM_MODES,
41226
- noteRuleTrigger: (source) => panel.noteRuleTrigger(source)
42389
+ noteRuleTrigger: (source) => panel.noteRuleTrigger(source),
42390
+ excludedDevices: () => panel.excludedDevices(),
42391
+ observeContact: (deviceId, reading, now) => panel.observeContact(deviceId, reading, now)
41227
42392
  });
41228
42393
  panel.setPublishHook((report) => center.onAlarmPublish(report));
42394
+ panel.setArmGuard(center.alarmArmGuard());
41229
42395
  center.onAlarmPublish({
41230
42396
  state: panel.currentState(),
41231
42397
  transition: null
@@ -41613,6 +42779,10 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
41613
42779
  api,
41614
42780
  logger: logger.child("BindingCache")
41615
42781
  });
42782
+ this.detectionCoverage = new DetectionCoverageCache({
42783
+ api,
42784
+ logger: logger.child("DetectionCoverage")
42785
+ });
41616
42786
  const zoneAnalytics = new ZoneAnalyticsProvider({
41617
42787
  logger: logger.child("ZoneAnalytics"),
41618
42788
  onOccupancySample: (deviceId, count, at, zoneId) => this.lts?.note("occupancy", deviceId, count, at, zoneId ?? ""),
@@ -41694,17 +42864,15 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
41694
42864
  },
41695
42865
  listDeviceIdentities: async () => {
41696
42866
  const all = await api.deviceManager.listAll.query({ projection: "slim" });
41697
- const detection = await api.deviceManager.getAllBindings.query({}).then((rows) => new Map(rows.map((row) => [row.deviceId, row.entries.some((e) => e.capName === "detection-pipeline" && e.kind === "wrapped")]))).catch((err) => {
41698
- this.ctx.logger.debug("bindings read failed — alarm coverage cannot report skips", { meta: { error: String(err) } });
41699
- return /* @__PURE__ */ new Map();
41700
- });
42867
+ const coverage = this.detectionCoverage;
42868
+ if (coverage) await coverage.ensureWarm();
41701
42869
  return all.map((d) => ({
41702
42870
  deviceId: d.id,
41703
42871
  type: d.type,
41704
42872
  name: d.name,
41705
42873
  isCamera: d.isCamera,
41706
42874
  online: d.online && !d.disabled,
41707
- ...detection.has(d.id) ? { detectionActive: detection.get(d.id) === true } : {}
42875
+ ...coverage?.has(d.id) ? { detectionActive: coverage.get(d.id) === true } : {}
41708
42876
  }));
41709
42877
  },
41710
42878
  readDeviceZoneIds: async (ids) => {
@@ -42412,6 +43580,7 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
42412
43580
  * `runtime/wire-subscriptions.ts`. */
42413
43581
  handleBindingsChangedEvent(data) {
42414
43582
  this.bindingCache?.onBindingsChanged(data);
43583
+ this.detectionCoverage?.onBindingsChanged(data);
42415
43584
  if (data.capName === "pipeline-analytics" && data.reason === "wrapper-deactivated") {
42416
43585
  this.trackStore?.clearDevice(data.deviceId);
42417
43586
  this.residents.closeDevice(data.deviceId);
@@ -42437,6 +43606,7 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
42437
43606
  this.audioMarkers?.forgetDevice(deviceId);
42438
43607
  this.settingsCaches.clearDevice(deviceId);
42439
43608
  this.bindingCache?.invalidate(deviceId);
43609
+ this.detectionCoverage?.forget(deviceId);
42440
43610
  this.zoneAnalytics?.forgetDevice(deviceId);
42441
43611
  this.audioMetrics?.forgetDevice(deviceId);
42442
43612
  this.releaseProxy(deviceId);
@@ -42642,6 +43812,7 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
42642
43812
  this.trackStore?.clearAll();
42643
43813
  this.stationaryRegistry = null;
42644
43814
  this.bindingCache?.clearAll();
43815
+ this.detectionCoverage?.clearAll();
42645
43816
  await this.eventMediaDataPlane?.dispose();
42646
43817
  this.eventMediaDataPlane = null;
42647
43818
  this.eventMediaBaseUrl = null;