@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.
@@ -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-B--dE3Ry.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,7 +534,29 @@ function skippedFor(deviceIds, skipProbe) {
518
534
  return out;
519
535
  }
520
536
  /**
521
- * "Away armed — 3 cameras: Front door, Garage, Gate."
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
+ /**
552
+ * "Attivi: Cancello, Garage — Esclusi: Porta retro."
553
+ *
554
+ * A LIST, not a sentence. The mode is in the title and the body is one
555
+ * `Label: names` line per device group, because this is read on a lock screen
556
+ * and the question is always "which one" — the prose version ("3 dispositivi
557
+ * attivi: …. Esclusi: ….") made the operator parse a paragraph to find a
558
+ * window. Each variant still owns its own catalog key, so the Texts tab can
559
+ * re-word "nothing is armed" without touching "these are armed".
522
560
  *
523
561
  * Names, not ids: this is read by a person standing at a door. An id that has
524
562
  * no name falls back to a NAMED, translated placeholder rather than being
@@ -526,14 +564,36 @@ function skippedFor(deviceIds, skipProbe) {
526
564
  * the one error this message must not make. (It used to hard-code the English
527
565
  * `Device <id>` into both languages.)
528
566
  *
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.
567
+ * **Always a sentence.** It used to return null for a mode no rule is gated on,
568
+ * on the grounds that "Night armed" followed by an unwatched night is a false
569
+ * assurance — true, but the caller then emitted `alarm-armed` anyway with a
570
+ * fallback body key that existed in NEITHER locale, so the operator received a
571
+ * title and an empty line. Silence is not the third option here: SAYING that
572
+ * the mode arms nothing is.
532
573
  */
533
- function buildArmAnnouncement(coverage, deviceName) {
534
- if (coverage.ruleCount === 0) return null;
574
+ function buildArmAnnouncement(coverage, deviceName, bypassed = []) {
535
575
  const rules = `${coverage.ruleCount}`;
536
576
  const mode = coverage.mode;
577
+ if (bypassed.length > 0) return armedWithBypass(coverage, bypassed, deviceName);
578
+ const gap = alarmCoverageGap(coverage);
579
+ if (gap === "uncovered") return {
580
+ bodyKey: "alarm.arm.body.uncovered",
581
+ textParams: { mode },
582
+ textCount: 0
583
+ };
584
+ if (gap === "all-skipped") {
585
+ const names = coverage.skippedDevices.map((s) => deviceName(s.deviceId));
586
+ return {
587
+ bodyKey: "alarm.arm.body.allSkipped",
588
+ textParams: {
589
+ rules,
590
+ mode,
591
+ devices: `${names.length}`,
592
+ names: names.join(", ")
593
+ },
594
+ textCount: names.length
595
+ };
596
+ }
537
597
  if (coverage.allDevices) return {
538
598
  bodyKey: "alarm.arm.body.allDevices",
539
599
  textParams: {
@@ -550,8 +610,9 @@ function buildArmAnnouncement(coverage, deviceName) {
550
610
  },
551
611
  textCount: coverage.ruleCount
552
612
  };
553
- const names = coverage.deviceIds.map(deviceName);
554
- return {
613
+ const skipped = new Set(coverage.skippedDevices.map((s) => s.deviceId));
614
+ const names = coverage.deviceIds.filter((id) => !skipped.has(id)).map(deviceName);
615
+ if (skipped.size === 0) return {
555
616
  bodyKey: "alarm.arm.body.devices",
556
617
  textParams: {
557
618
  rules,
@@ -561,7 +622,336 @@ function buildArmAnnouncement(coverage, deviceName) {
561
622
  },
562
623
  textCount: names.length
563
624
  };
625
+ return {
626
+ bodyKey: "alarm.arm.body.devicesSkipped",
627
+ textParams: {
628
+ rules,
629
+ mode,
630
+ devices: `${names.length}`,
631
+ names: names.join(", "),
632
+ skippedNames: coverage.skippedDevices.map((s) => deviceName(s.deviceId)).join(", ")
633
+ },
634
+ textCount: names.length
635
+ };
636
+ }
637
+ /**
638
+ * "Attivi: 2 — Bypassati: Finestra veranda."
639
+ *
640
+ * The bypassed sensors are named and the armed ones are COUNTED, not listed:
641
+ * this sentence's subject is the exception, and burying it under thirteen
642
+ * camera names is how an operator stops reading the arm notification. The
643
+ * count pluralises on what IS armed, like every other arm variant.
644
+ */
645
+ function armedWithBypass(coverage, bypassed, deviceName) {
646
+ const skipped = new Set(coverage.skippedDevices.map((s) => s.deviceId));
647
+ const excluded = new Set(bypassed);
648
+ const armed = coverage.allDevices ? coverage.ruleCount : coverage.deviceIds.filter((id) => !skipped.has(id) && !excluded.has(id)).length;
649
+ return {
650
+ bodyKey: "alarm.arm.body.bypassed",
651
+ textParams: {
652
+ rules: `${coverage.ruleCount}`,
653
+ mode: coverage.mode,
654
+ devices: `${armed}`,
655
+ bypassedNames: bypassed.map(deviceName).join(", ")
656
+ },
657
+ textCount: armed
658
+ };
659
+ }
660
+ /**
661
+ * "Aperti: Finestra veranda, Porta retro" + how to arm anyway.
662
+ *
663
+ * The one alarm sentence about something that did not happen, so it has to
664
+ * carry both halves an operator needs to act: WHICH sensors are open, and that
665
+ * pressing arm again arms without them. Naming them is the whole message — a
666
+ * refusal that says only "cannot arm" is a panel the operator will conclude is
667
+ * broken — so the names are the FIRST line and the retry hint is the second.
668
+ */
669
+ function buildArmRefusedAnnouncement(mode, names, retrySeconds) {
670
+ return {
671
+ bodyKey: "alarm.armRefused.body",
672
+ textParams: {
673
+ mode,
674
+ devices: `${names.length}`,
675
+ names: names.join(", "),
676
+ seconds: `${retrySeconds}`
677
+ },
678
+ textCount: names.length
679
+ };
680
+ }
681
+ /**
682
+ * The EXCEPTIONS to the same coverage, said 30 seconds earlier.
683
+ *
684
+ * The exit-delay sentence is deliberately SHORTER than the arm one — it is read
685
+ * on the way out of a door — and it carries exactly one thing the arm sentence
686
+ * cannot: the warning arrives while the operator can still disarm and fix it.
687
+ * A mode that arms nothing is worth interrupting somebody for at second 0 and
688
+ * merely worth recording at second 30.
689
+ *
690
+ * **It never repeats the device list.** The countdown lives in the TITLE
691
+ * (`system.alarm-arming.title.<mode>` renders `{{seconds}}`), and the armed
692
+ * list arrives thirty seconds later in the arm announcement — printing it
693
+ * twice is the verbosity that made the operator stop reading both. So the body
694
+ * is what is WRONG: what was bypassed, what is excluded, or that nothing is.
695
+ *
696
+ * `seconds` stays in the params because the title asks for it, and `textCount`
697
+ * stays with it: a row enqueued before this deploy still pluralises on the
698
+ * countdown it was written for.
699
+ */
700
+ function buildArmingAnnouncement(coverage, seconds, deviceName, bypassed = []) {
701
+ const textParams = {
702
+ mode: coverage.mode,
703
+ seconds: `${seconds}`
704
+ };
705
+ if (bypassed.length > 0) return {
706
+ bodyKey: "alarm.arming.body.bypassed",
707
+ textParams: {
708
+ ...textParams,
709
+ bypassedNames: bypassed.map(deviceName).join(", ")
710
+ },
711
+ textCount: seconds
712
+ };
713
+ const skippedNames = coverage.skippedDevices.map((s) => deviceName(s.deviceId)).join(", ");
714
+ const gap = alarmCoverageGap(coverage);
715
+ if (gap === "uncovered") return {
716
+ bodyKey: "alarm.arming.body.uncovered",
717
+ textParams,
718
+ textCount: seconds
719
+ };
720
+ if (gap === "all-skipped") return {
721
+ bodyKey: "alarm.arming.body.allSkipped",
722
+ textParams: {
723
+ ...textParams,
724
+ names: skippedNames
725
+ },
726
+ textCount: seconds
727
+ };
728
+ if (coverage.skippedDevices.length > 0) return {
729
+ bodyKey: "alarm.arming.body.skipped",
730
+ textParams: {
731
+ ...textParams,
732
+ names: skippedNames
733
+ },
734
+ textCount: coverage.skippedDevices.length
735
+ };
736
+ return {
737
+ bodyKey: "alarm.arming.body",
738
+ textParams,
739
+ textCount: seconds
740
+ };
564
741
  }
742
+ //#endregion
743
+ //#region src/notification-center/alarm/alarm-arm-guard.ts
744
+ /**
745
+ * How long a refusal stays confirmable.
746
+ *
747
+ * Long enough to read the notification and press again (the operator is
748
+ * standing at the door, phone in hand); short enough that it cannot be the
749
+ * state the panel is left in. Sixty seconds is also under the shortest exit
750
+ * delay this panel ships with, so a bypass can never outlive the arm it
751
+ * belongs to.
752
+ */
753
+ var ARM_BYPASS_WINDOW_MS = 6e4;
754
+ /** The taxonomy kind a door/window contact persists its events under. */
755
+ var CONTACT_SENSOR_KIND = "contact";
756
+ /**
757
+ * The contact sensors a mode would arm, ascending and de-duplicated.
758
+ *
759
+ * A rule qualifies only when it is enabled, gated on THIS panel in THIS mode,
760
+ * and about contacts (`sensorKinds` naming `contact`). Two exclusions are
761
+ * deliberate and both are about refusing an arm the operator cannot fix:
762
+ *
763
+ * - a rule that names CAMERAS (`Intrusione — Fuori casa` names thirteen) is
764
+ * not a contact rule, and a camera is never open;
765
+ * - a contact rule with NO device scope covers every contact on the hub, and
766
+ * blocking on a set the operator never wrote down produces a refusal naming
767
+ * devices they never associated with the alarm. The caller says so in a log
768
+ * line rather than inventing the list.
769
+ */
770
+ function alarmBlockingCandidates(rules, panelDeviceId, mode) {
771
+ const wanted = armedStateFor(mode);
772
+ const ids = /* @__PURE__ */ new Set();
773
+ for (const rule of rules) {
774
+ if (!rule.enabled) continue;
775
+ const gate = rule.conditions.deviceState;
776
+ if (gate === void 0 || gate.deviceId !== panelDeviceId) continue;
777
+ if (!gate.states.includes(wanted)) continue;
778
+ const kinds = rule.conditions.sensorKinds;
779
+ if (kinds === void 0 || !kinds.includes(CONTACT_SENSOR_KIND)) continue;
780
+ const scope = rule.conditions.devices;
781
+ if (scope === void 0 || scope.length === 0) continue;
782
+ for (const id of scope) ids.add(id);
783
+ }
784
+ return [...ids].toSorted((a, b) => a - b);
785
+ }
786
+ /**
787
+ * Every contact any mode of this panel could block on — the set whose state is
788
+ * worth keeping mirrored.
789
+ *
790
+ * Read on the rule-reload tick, so the arm path has a recent answer even when
791
+ * the targeted read at arm time fails, and so an excluded sensor's close can be
792
+ * noticed on a hub where the sensor is linked to no camera (and therefore emits
793
+ * no device event at all — the live installation on 2026-08-13).
794
+ */
795
+ function alarmContactWatchList(rules, panelDeviceId, modes) {
796
+ const ids = /* @__PURE__ */ new Set();
797
+ for (const mode of modes) for (const id of alarmBlockingCandidates(rules, panelDeviceId, mode)) ids.add(id);
798
+ return [...ids].toSorted((a, b) => a - b);
799
+ }
800
+ /**
801
+ * Refuse, bypass or proceed — the whole decision, as one comparison.
802
+ *
803
+ * The bypass requires the SAME mode. "Arm home" refused and "arm away" pressed
804
+ * is a different intent about a different set of sensors, and letting one
805
+ * confirm the other would arm a mode around a window nobody was told about.
806
+ *
807
+ * The bypassed set is what is open NOW, not what was open at the refusal: a
808
+ * window shut between the two presses is armed with everything else, which is
809
+ * the entire point of having refused.
810
+ */
811
+ function decideArm(input) {
812
+ const window = input.windowMs ?? 6e4;
813
+ if (input.openContacts.length === 0) return { kind: "clear" };
814
+ const previous = input.previous;
815
+ if (previous !== null && previous.mode === input.mode && input.at - previous.at <= window) return {
816
+ kind: "bypassed",
817
+ bypassed: input.openContacts
818
+ };
819
+ return {
820
+ kind: "refused",
821
+ blocking: input.openContacts,
822
+ retryUntil: input.at + window
823
+ };
824
+ }
825
+ /**
826
+ * The error `alarmPanel.arm` throws when it refuses.
827
+ *
828
+ * A THROW and not a return value, and the reason is a train: the hub's
829
+ * generated router validates this method's response against the HOST's copy of
830
+ * the cap, which says `z.void()`. An addon that answered a structured refusal
831
+ * would have it rejected by the router in front of it — so the refusal reaches
832
+ * the caller as the one channel that already works across that boundary, and
833
+ * the operator's PHONE gets the sentence (`alarm-arm-refused`, through the
834
+ * ordinary outbox) rather than a UI error being the only trace.
835
+ *
836
+ * The message is deliberately actionable and English (an exception message is
837
+ * a developer/API surface; the notification is the operator's).
838
+ */
839
+ var NcAlarmArmRefusedError = class extends Error {
840
+ code = "alarm-arm-refused";
841
+ mode;
842
+ blocking;
843
+ retryUntil;
844
+ constructor(input) {
845
+ const names = input.blocking.map((c) => c.name).join(", ");
846
+ const seconds = Math.round((input.windowMs ?? 6e4) / 1e3);
847
+ super(`arm refused — open: ${names}. Arm ${input.mode} again within ${String(seconds)}s to arm with them excluded.`);
848
+ this.name = "NcAlarmArmRefusedError";
849
+ this.mode = input.mode;
850
+ this.blocking = input.blocking;
851
+ this.retryUntil = input.retryUntil;
852
+ }
853
+ };
854
+ //#endregion
855
+ //#region src/notification-center/alarm/alarm-exclusions.ts
856
+ function isPhase(value) {
857
+ return value === "open" || value === "closed";
858
+ }
859
+ var NcAlarmExclusions = class {
860
+ entries = [];
861
+ /** Replace the whole set — one arm, one set of exclusions. */
862
+ exclude(deviceIds, at) {
863
+ this.entries = [...new Set(deviceIds)].map((deviceId) => ({
864
+ deviceId,
865
+ phase: "open",
866
+ at
867
+ }));
868
+ }
869
+ /** Every excluded sensor, in the order it was excluded. */
870
+ list() {
871
+ return this.entries.map((e) => e.deviceId);
872
+ }
873
+ /** True while this sensor's events must not reach the panel. */
874
+ blocks(deviceId) {
875
+ return this.entries.some((e) => e.deviceId === deviceId);
876
+ }
877
+ /**
878
+ * Feed one contact reading, and say what it CHANGED.
879
+ *
880
+ * Three answers, not two, because the caller has to persist on both of the
881
+ * changes: the first `alarm-arm-refusal.spec.ts` run of this feature saved
882
+ * only the re-arm, so a hub redeployed between the close and the re-open came
883
+ * back with the sensor still in phase `open` — and the exclusion then needed
884
+ * a SECOND close to lift. A phase that lives only in RAM is half a durable
885
+ * exclusion.
886
+ *
887
+ * An unreadable state changes nothing (D49): a failed read must never lift an
888
+ * exclusion the operator asked for, and must never swallow the close that
889
+ * would have.
890
+ */
891
+ observe(deviceId, reading, _at) {
892
+ if (reading === void 0) return "unchanged";
893
+ const current = this.entries.find((e) => e.deviceId === deviceId);
894
+ if (current === void 0) return "unchanged";
895
+ if (reading === "closed") {
896
+ if (current.phase === "closed") return "unchanged";
897
+ this.entries = this.entries.map((e) => e.deviceId === deviceId ? {
898
+ ...e,
899
+ phase: "closed"
900
+ } : e);
901
+ return "closed";
902
+ }
903
+ if (current.phase === "open") return "unchanged";
904
+ this.entries = this.entries.filter((e) => e.deviceId !== deviceId);
905
+ return "rearmed";
906
+ }
907
+ /** The arm this belongs to is over. */
908
+ clear() {
909
+ this.entries = [];
910
+ }
911
+ /** What to persist. Plain data — it rides the panel's config blob. */
912
+ snapshot() {
913
+ return this.entries.map((e) => ({
914
+ deviceId: e.deviceId,
915
+ phase: e.phase,
916
+ at: e.at
917
+ }));
918
+ }
919
+ /**
920
+ * Rebuild from the persisted blob — a GUARD, never a cast.
921
+ *
922
+ * Anything unrecognised restores NOTHING, which is the failure that shows
923
+ * itself (a bypassed sensor that fires) rather than the one that hides (a
924
+ * sensor excluded forever by a blob nobody can read). A bare id with no phase
925
+ * restores as still-OPEN: it needs a close before it can re-arm, which is the
926
+ * half that cannot cause a false alarm.
927
+ */
928
+ restore(raw) {
929
+ if (!Array.isArray(raw)) return;
930
+ const out = [];
931
+ for (const item of raw) {
932
+ if (typeof item === "number" && Number.isInteger(item)) {
933
+ out.push({
934
+ deviceId: item,
935
+ phase: "open",
936
+ at: 0
937
+ });
938
+ continue;
939
+ }
940
+ if (item === null || typeof item !== "object") continue;
941
+ const rec = { ...item };
942
+ const deviceId = rec["deviceId"];
943
+ if (typeof deviceId !== "number" || !Number.isInteger(deviceId)) continue;
944
+ const phase = isPhase(rec["phase"]) ? rec["phase"] : "open";
945
+ const at = typeof rec["at"] === "number" ? rec["at"] : 0;
946
+ out.push({
947
+ deviceId,
948
+ phase,
949
+ at
950
+ });
951
+ }
952
+ this.entries = out;
953
+ }
954
+ };
565
955
  /**
566
956
  * WHO the panel's current countdown belongs to.
567
957
  *
@@ -585,6 +975,26 @@ var NcAlarmAttribution = class {
585
975
  };
586
976
  }
587
977
  /**
978
+ * The note that has not been claimed yet — WITHOUT consuming it.
979
+ *
980
+ * The panel has to know which SENSOR is about to set the alarm off before it
981
+ * decides whether to accept the trigger at all (an excluded contact does
982
+ * not), and asking must not spend the note: a trigger that is accepted still
983
+ * needs it to build the combined notification. Expired reads as absent, on
984
+ * the same bound {@link claim} uses.
985
+ */
986
+ pending(now) {
987
+ const noted = this.noted;
988
+ if (noted === null) return null;
989
+ if (now - noted.at > 3e4) return null;
990
+ return noted.source;
991
+ }
992
+ /** Throw the un-claimed note away — the trigger it described was REFUSED, and
993
+ * a note left behind would be inherited by the next real one. */
994
+ dropPending() {
995
+ this.noted = null;
996
+ }
997
+ /**
588
998
  * Called by an ACCEPTED trigger, and by every accepted trigger — including
589
999
  * one with no note, so a manual trigger clears whatever a rule left behind
590
1000
  * rather than inheriting it.
@@ -639,6 +1049,7 @@ function alarmTransition(input) {
639
1049
  at,
640
1050
  ...input.source !== void 0 ? { source: input.source } : {}
641
1051
  };
1052
+ if (next === "arming") return armingTransition(previous, input);
642
1053
  if (shouldAnnounceArm(previous, next)) {
643
1054
  const mode = armModeOf(next, input.modes);
644
1055
  if (mode === null) return null;
@@ -658,6 +1069,29 @@ function alarmTransition(input) {
658
1069
  };
659
1070
  return null;
660
1071
  }
1072
+ /**
1073
+ * The exit delay, as one notification at its START.
1074
+ *
1075
+ * Reached only from a state CHANGE into `arming`, so the once-a-second
1076
+ * re-publish is already excluded by the caller's `previous === next` guard —
1077
+ * the countdown is one notification, not thirty. Null when the panel did not
1078
+ * say what it is arming into, or named a mode it does not offer: both would
1079
+ * produce a message with no title and no number in it.
1080
+ */
1081
+ function armingTransition(previous, input) {
1082
+ const arming = input.arming;
1083
+ if (arming === void 0) return null;
1084
+ const mode = armModeOf(arming.target, input.modes);
1085
+ if (mode === null) return null;
1086
+ return {
1087
+ kind: "alarm-arming",
1088
+ state: "arming",
1089
+ previous,
1090
+ mode,
1091
+ seconds: Math.max(0, Math.ceil((arming.armedAt - input.at) / 1e3)),
1092
+ at: input.at
1093
+ };
1094
+ }
661
1095
  //#endregion
662
1096
  //#region src/notification-center/alarm/alarm-panel-device.ts
663
1097
  /**
@@ -704,7 +1138,17 @@ var ncAlarmPanelSchema = object({
704
1138
  announceArm: boolean().default(false),
705
1139
  /** Target ids that announcement goes to — see `NcAlarmSettingsSchema`. */
706
1140
  announceTargets: array(string()).default([]),
707
- machine: record(string(), unknown()).optional()
1141
+ machine: record(string(), unknown()).optional(),
1142
+ /**
1143
+ * Sensors the CURRENT arm is ignoring because they were open when the
1144
+ * operator confirmed it, and how far each is through its two-phase lift.
1145
+ *
1146
+ * Persisted for the same reason the machine is: a deploy in the middle of an
1147
+ * armed night would otherwise silently re-arm every sensor the operator
1148
+ * agreed to leave out, and they would learn about it from the siren. Read
1149
+ * back through a guard, never a cast — see `NcAlarmExclusions.restore`.
1150
+ */
1151
+ exclusions: array(unknown()).default([])
708
1152
  });
709
1153
  /** `armed_home` and friends — the only targets a machine may settle into. */
710
1154
  function isArmTarget(value) {
@@ -754,6 +1198,19 @@ var NcAlarmPanelDevice = class extends BaseDevice {
754
1198
  lastPublished = null;
755
1199
  /** WHO the current countdown belongs to — see {@link NcAlarmAttribution}. */
756
1200
  attribution = new NcAlarmAttribution();
1201
+ /** Sensors this arm is ignoring — see {@link NcAlarmExclusions}. */
1202
+ exclusions = new NcAlarmExclusions();
1203
+ /** Who answers "is anything open" — null until the centre wires itself in. */
1204
+ armGuard = null;
1205
+ /**
1206
+ * The arm attempt that was REFUSED, and is therefore confirmable.
1207
+ *
1208
+ * In RAM and only in RAM, deliberately: it is a confirmation of something the
1209
+ * operator read seconds ago, and a confirmation that survived a restart would
1210
+ * let a process respawn arm the alarm around an open window nobody re-read.
1211
+ * Losing it costs one extra press.
1212
+ */
1213
+ lastRefusal = null;
757
1214
  constructor(ctx) {
758
1215
  super(ctx, ncAlarmPanelSchema, { type: ctx.deviceMeta.type });
759
1216
  this.delays = this.readDelays();
@@ -811,26 +1268,164 @@ var NcAlarmPanelDevice = class extends BaseDevice {
811
1268
  currentState(now = Date.now()) {
812
1269
  return stateAt(this.machine, now);
813
1270
  }
1271
+ /** Wire the open-contact check and the refusal announcement. */
1272
+ setArmGuard(hook) {
1273
+ this.armGuard = hook;
1274
+ }
1275
+ /**
1276
+ * Sensors the current arm is ignoring — what the armed/arming sentence names
1277
+ * as excluded, and the only place that list is published.
1278
+ */
1279
+ excludedDevices() {
1280
+ return this.exclusions.list();
1281
+ }
1282
+ /**
1283
+ * One contact reading, from whoever saw it — the sensor's own event, or the
1284
+ * centre's reconcile tick.
1285
+ *
1286
+ * The lift is two-phase (`alarm-exclusions.ts`), so this is safe to call with
1287
+ * the same reading repeatedly, and an unreadable state changes nothing. Fed
1288
+ * BEFORE the event that carried it is evaluated, so the open that re-arms a
1289
+ * sensor is already lifted when the rule it fires reaches the panel.
1290
+ */
1291
+ observeContact(deviceId, reading, now = Date.now()) {
1292
+ const change = this.exclusions.observe(deviceId, reading, now);
1293
+ if (change === "unchanged") return;
1294
+ this.persist();
1295
+ if (change === "closed") return;
1296
+ this.ctx.logger.info("alarm exclusion lifted — the sensor closed and opened again", {
1297
+ tags: { deviceId },
1298
+ meta: {
1299
+ panelDeviceId: this.id,
1300
+ remaining: this.exclusions.list().length
1301
+ }
1302
+ });
1303
+ }
814
1304
  /** Stop the countdown when the device goes away. The state itself is
815
1305
  * persisted, so nothing is lost — only the announcements stop. */
816
1306
  stopCountdown() {
817
1307
  this.stopTicking();
818
1308
  }
1309
+ /**
1310
+ * Refuse the arm, bypass around it, or let it through — and leave the
1311
+ * exclusions the arm will run with.
1312
+ *
1313
+ * Fail-OPEN by construction, in three places, and each one is a decision:
1314
+ *
1315
+ * - no guard wired (an agent node, a boot pass before the centre exists) ⇒
1316
+ * arm. A panel that cannot ask must not refuse.
1317
+ * - the guard threw ⇒ arm, and SAY so. Refusing on a failed read would tell
1318
+ * the operator to go and shut a window that is already shut (the coverage
1319
+ * derivation makes the same choice: an unreadable authority answers
1320
+ * "unknown", never "excluded").
1321
+ * - nothing reads open ⇒ arm clean, and any exclusion a previous arm left
1322
+ * is dropped.
1323
+ *
1324
+ * The refusal throws. See {@link NcAlarmArmRefusedError} for why that is the
1325
+ * channel rather than a return value.
1326
+ */
1327
+ async applyArmGuard(mode, now) {
1328
+ const guard = this.armGuard;
1329
+ if (guard === null) return;
1330
+ let openContacts;
1331
+ try {
1332
+ openContacts = await guard.openContacts(mode);
1333
+ } catch (err) {
1334
+ this.ctx.logger.warn("alarm arm guard could not read the contacts — arming anyway", {
1335
+ tags: { deviceId: this.id },
1336
+ meta: {
1337
+ mode,
1338
+ error: String(err)
1339
+ }
1340
+ });
1341
+ return;
1342
+ }
1343
+ const verdict = decideArm({
1344
+ mode,
1345
+ openContacts,
1346
+ previous: this.lastRefusal,
1347
+ at: now
1348
+ });
1349
+ if (verdict.kind === "clear") {
1350
+ this.lastRefusal = null;
1351
+ if (this.exclusions.list().length > 0) {
1352
+ this.exclusions.clear();
1353
+ this.persist();
1354
+ }
1355
+ return;
1356
+ }
1357
+ if (verdict.kind === "bypassed") {
1358
+ this.lastRefusal = null;
1359
+ this.exclusions.exclude(verdict.bypassed.map((c) => c.deviceId), now);
1360
+ this.persist();
1361
+ for (const contact of verdict.bypassed) this.ctx.logger.info("alarm armed with an open sensor excluded", {
1362
+ tags: { deviceId: contact.deviceId },
1363
+ meta: {
1364
+ panelDeviceId: this.id,
1365
+ mode,
1366
+ name: contact.name
1367
+ }
1368
+ });
1369
+ return;
1370
+ }
1371
+ this.lastRefusal = {
1372
+ mode,
1373
+ at: now
1374
+ };
1375
+ for (const contact of verdict.blocking) this.ctx.logger.warn("alarm arm REFUSED — this sensor is open", {
1376
+ tags: { deviceId: contact.deviceId },
1377
+ meta: {
1378
+ panelDeviceId: this.id,
1379
+ mode,
1380
+ name: contact.name
1381
+ }
1382
+ });
1383
+ guard.announceRefusal({
1384
+ mode,
1385
+ blocking: verdict.blocking,
1386
+ retrySeconds: Math.round(ARM_BYPASS_WINDOW_MS / 1e3),
1387
+ at: now
1388
+ });
1389
+ throw new NcAlarmArmRefusedError({
1390
+ mode,
1391
+ blocking: verdict.blocking,
1392
+ retryUntil: verdict.retryUntil
1393
+ });
1394
+ }
819
1395
  registerAlarmCap() {
820
1396
  this.ctx.registerNativeCap(alarmPanelCapability, {
821
1397
  getStatus: async () => this.status(),
822
1398
  arm: async ({ mode }) => {
823
- this.machine = arm(mode, this.delays, Date.now());
1399
+ const now = Date.now();
1400
+ await this.applyArmGuard(mode, now);
1401
+ this.machine = arm(mode, this.delays, now);
824
1402
  this.attribution.clear();
825
- this.publish();
1403
+ this.publish(now);
826
1404
  },
827
1405
  disarm: async () => {
828
1406
  this.machine = disarm(Date.now());
829
1407
  this.attribution.clear();
1408
+ this.exclusions.clear();
1409
+ this.lastRefusal = null;
830
1410
  this.publish();
831
1411
  },
832
1412
  trigger: async () => {
833
1413
  const now = Date.now();
1414
+ const pending = this.attribution.pending(now);
1415
+ const source = pending?.sourceDeviceId;
1416
+ if (source !== void 0 && this.exclusions.blocks(source)) {
1417
+ this.attribution.dropPending();
1418
+ this.ctx.logger.info("alarm trigger ignored — the sensor was open when the alarm was armed", {
1419
+ tags: { deviceId: source },
1420
+ meta: {
1421
+ panelDeviceId: this.id,
1422
+ ruleId: pending?.ruleId,
1423
+ rule: pending?.ruleName,
1424
+ state: stateAt(this.machine, now)
1425
+ }
1426
+ });
1427
+ return;
1428
+ }
834
1429
  const next = trigger(this.machine, this.delays, now);
835
1430
  if (next === this.machine) {
836
1431
  this.ctx.logger.info("alarm trigger ignored — the panel is not armed", {
@@ -890,12 +1485,14 @@ var NcAlarmPanelDevice = class extends BaseDevice {
890
1485
  const hook = this.onPublish;
891
1486
  if (hook === null) return;
892
1487
  const source = this.attribution.current();
1488
+ const arming = exitDelayAt(this.machine, now);
893
1489
  const transition = alarmTransition({
894
1490
  previous,
895
1491
  next: state,
896
1492
  modes: NC_ALARM_MODES,
897
1493
  at: now,
898
- ...source !== null ? { source } : {}
1494
+ ...source !== null ? { source } : {},
1495
+ ...arming !== void 0 ? { arming } : {}
899
1496
  });
900
1497
  if (transition?.kind === "alarm-triggered") this.attribution.clear();
901
1498
  try {
@@ -933,11 +1530,14 @@ var NcAlarmPanelDevice = class extends BaseDevice {
933
1530
  this.ticker = null;
934
1531
  }
935
1532
  /** 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. */
1533
+ * survive a restart or the alarm disarms itself on every deploy. The
1534
+ * exclusions ride with it, for the same reason and with the same lifetime. */
937
1535
  persist() {
938
1536
  this.config.set("machine", { ...this.machine });
1537
+ this.config.set("exclusions", [...this.exclusions.snapshot()]);
939
1538
  }
940
1539
  restore() {
1540
+ this.exclusions.restore(this.config.get("exclusions"));
941
1541
  const restored = machineFromPersisted(this.config.get("machine"));
942
1542
  if (restored === null) return;
943
1543
  this.machine = restored;
@@ -1713,10 +2313,15 @@ var MAX_ARM_BUTTONS = 3;
1713
2313
  *
1714
2314
  * | notification | buttons |
1715
2315
  * | --- | --- |
2316
+ * | `alarm-arming` | **Disarma**, destructive |
1716
2317
  * | `alarm-triggered` | **Disarma**, destructive |
1717
2318
  * | `alarm-armed` | **Disarma**, destructive |
1718
2319
  * | `alarm-disarmed` | **Arma …** per declared mode, max three |
1719
2320
  *
2321
+ * The `alarm-arming` row is the one that earns its keep: that notification
2322
+ * arrives at the START of the exit delay, so its Disarm is the only alarm
2323
+ * button an operator can press to cancel something rather than to undo it.
2324
+ *
1720
2325
  * `destructive` on the disarm is a RENDERING hint and never a gate (D47) — what
1721
2326
  * actually bounds it is the five-minute TTL the minter gives that grant
1722
2327
  * (`ALARM_DISARM_TTL_MS`). Both exist: the hint is for the person, the TTL is
@@ -3754,27 +4359,38 @@ var en_default = {
3754
4359
  "snooze.scope.anyDevice": "A camera",
3755
4360
  "snooze.scope.anyRule": "A rule",
3756
4361
  "snooze.scope.rule": "“{{rule}}”",
3757
- "alarm.arm.body.allDevices": {
3758
- "one": "Every device is armed ({{rules}} rule).",
3759
- "other": "Every device is armed ({{rules}} rules)."
3760
- },
3761
- "alarm.arm.body.noDevices": {
3762
- "one": "{{rules}} rule, no device restriction.",
3763
- "other": "{{rules}} rules, no device restriction."
3764
- },
3765
- "alarm.arm.body.devices": {
3766
- "one": "{{devices}} device armed: {{names}}.",
3767
- "other": "{{devices}} devices armed: {{names}}."
3768
- },
4362
+ "alarm.arm.body.allDevices": "Active: all devices",
4363
+ "alarm.arm.body.noDevices": "Active: no device restriction",
4364
+ "alarm.arm.body.devices": "Active: {{names}}",
4365
+ "alarm.arm.body.devicesSkipped": "Active: {{names}}\nExcluded: {{skippedNames}}",
4366
+ "alarm.arm.body.uncovered": "Active: none — no rule uses this mode",
4367
+ "alarm.arm.body.allSkipped": "Active: none\nExcluded: {{names}}",
4368
+ "alarm.arming.body": "No exclusions",
4369
+ "alarm.arming.body.uncovered": "Active: none — no rule uses this mode",
4370
+ "alarm.arming.body.allSkipped": "Active: none\nExcluded: {{names}}",
4371
+ "alarm.arming.body.skipped": "Excluded: {{names}}",
4372
+ "alarm.arm.body.bypassed": "Active: {{devices}}\nBypassed: {{bypassedNames}}",
4373
+ "alarm.arming.body.bypassed": "Bypassed: {{bypassedNames}}",
4374
+ "alarm.armRefused.body": "Open: {{names}}\nRepeat within {{seconds}}s to arm with bypass.",
3769
4375
  "alarm.device.unnamed": "Device {{id}}",
3770
- "system.alarm-armed.title.home": "Home armed",
3771
- "system.alarm-armed.title.away": "Away armed",
3772
- "system.alarm-armed.title.night": "Night armed",
3773
- "system.alarm-disarmed.title": "Alarm disarmed",
3774
- "system.alarm-disarmed.body": "The alarm was disarmed",
4376
+ "alarm.combined.title": "Alarm: {{deviceName}}",
4377
+ "system.alarm-arm-refused.title.home": "Arming home refused",
4378
+ "system.alarm-arm-refused.title.away": "Arming away refused",
4379
+ "system.alarm-arm-refused.title.night": "Arming night refused",
4380
+ "system.alarm-arm-refused.body": "Arming refused",
4381
+ "system.alarm-arming.title.home": "Arming home — {{seconds}}s",
4382
+ "system.alarm-arming.title.away": "Arming away — {{seconds}}s",
4383
+ "system.alarm-arming.title.night": "Arming night — {{seconds}}s",
4384
+ "system.alarm-arming.body": "Arming",
4385
+ "system.alarm-armed.title.home": "Armed: home",
4386
+ "system.alarm-armed.title.away": "Armed: away",
4387
+ "system.alarm-armed.title.night": "Armed: night",
4388
+ "system.alarm-armed.body": "Alarm armed",
4389
+ "system.alarm-disarmed.title": "Disarmed",
4390
+ "system.alarm-disarmed.body": "Alarm disarmed",
3775
4391
  "system.alarm-triggered.title": "ALARM",
3776
- "system.alarm-triggered.body": "The alarm was triggered",
3777
- "system.alarm-triggered.body.rule": "The alarm was triggered by {{rule}} on {{deviceName}}",
4392
+ "system.alarm-triggered.body": "Alarm triggered",
4393
+ "system.alarm-triggered.body.rule": "Triggered by {{rule}} on {{deviceName}}",
3778
4394
  "button.snooze.minutes": "Snooze {{minutes}} min",
3779
4395
  "button.snooze.hours": {
3780
4396
  "one": "Snooze {{hours}} hour",
@@ -3906,27 +4522,38 @@ var it_default = {
3906
4522
  "snooze.scope.anyDevice": "Una camera",
3907
4523
  "snooze.scope.anyRule": "Una regola",
3908
4524
  "snooze.scope.rule": "“{{rule}}”",
3909
- "alarm.arm.body.allDevices": {
3910
- "one": "Ogni dispositivo è attivo ({{rules}} regola).",
3911
- "other": "Ogni dispositivo è attivo ({{rules}} regole)."
3912
- },
3913
- "alarm.arm.body.noDevices": {
3914
- "one": "{{rules}} regola, nessuna restrizione sui dispositivi.",
3915
- "other": "{{rules}} regole, nessuna restrizione sui dispositivi."
3916
- },
3917
- "alarm.arm.body.devices": {
3918
- "one": "{{devices}} dispositivo attivo: {{names}}.",
3919
- "other": "{{devices}} dispositivi attivi: {{names}}."
3920
- },
4525
+ "alarm.arm.body.allDevices": "Attivi: tutti i dispositivi",
4526
+ "alarm.arm.body.noDevices": "Attivi: nessuna restrizione",
4527
+ "alarm.arm.body.devices": "Attivi: {{names}}",
4528
+ "alarm.arm.body.devicesSkipped": "Attivi: {{names}}\nEsclusi: {{skippedNames}}",
4529
+ "alarm.arm.body.uncovered": "Attivi: nessuno — nessuna regola usa questa modalità",
4530
+ "alarm.arm.body.allSkipped": "Attivi: nessuno\nEsclusi: {{names}}",
4531
+ "alarm.arming.body": "Nessuna esclusione",
4532
+ "alarm.arming.body.uncovered": "Attivi: nessuno — nessuna regola usa questa modalità",
4533
+ "alarm.arming.body.allSkipped": "Attivi: nessuno\nEsclusi: {{names}}",
4534
+ "alarm.arming.body.skipped": "Esclusi: {{names}}",
4535
+ "alarm.arm.body.bypassed": "Attivi: {{devices}}\nBypassati: {{bypassedNames}}",
4536
+ "alarm.arming.body.bypassed": "Bypassati: {{bypassedNames}}",
4537
+ "alarm.armRefused.body": "Aperti: {{names}}\nRipeti entro {{seconds}}s per inserire con bypass.",
3921
4538
  "alarm.device.unnamed": "Dispositivo {{id}}",
3922
- "system.alarm-armed.title.home": "Casa attivato",
3923
- "system.alarm-armed.title.away": "Fuori attivato",
3924
- "system.alarm-armed.title.night": "Notte attivato",
3925
- "system.alarm-disarmed.title": "Allarme disattivato",
3926
- "system.alarm-disarmed.body": "L'allarme è stato disattivato",
4539
+ "alarm.combined.title": "Allarme: {{deviceName}}",
4540
+ "system.alarm-arm-refused.title.home": "Inserimento Casa rifiutato",
4541
+ "system.alarm-arm-refused.title.away": "Inserimento Fuori rifiutato",
4542
+ "system.alarm-arm-refused.title.night": "Inserimento Notte rifiutato",
4543
+ "system.alarm-arm-refused.body": "Inserimento rifiutato",
4544
+ "system.alarm-arming.title.home": "Inserimento Casa — {{seconds}}s",
4545
+ "system.alarm-arming.title.away": "Inserimento Fuori — {{seconds}}s",
4546
+ "system.alarm-arming.title.night": "Inserimento Notte — {{seconds}}s",
4547
+ "system.alarm-arming.body": "Inserimento in corso",
4548
+ "system.alarm-armed.title.home": "Inserito: Casa",
4549
+ "system.alarm-armed.title.away": "Inserito: Fuori",
4550
+ "system.alarm-armed.title.night": "Inserito: Notte",
4551
+ "system.alarm-armed.body": "Allarme inserito",
4552
+ "system.alarm-disarmed.title": "Disinserito",
4553
+ "system.alarm-disarmed.body": "Allarme disinserito",
3927
4554
  "system.alarm-triggered.title": "ALLARME",
3928
- "system.alarm-triggered.body": "L'allarme è scattato",
3929
- "system.alarm-triggered.body.rule": "L'allarme è scattato per {{rule}} su {{deviceName}}",
4555
+ "system.alarm-triggered.body": "Allarme scattato",
4556
+ "system.alarm-triggered.body.rule": "Scattato da {{rule}} su {{deviceName}}",
3930
4557
  "button.snooze.minutes": "Silenzia {{minutes}} min",
3931
4558
  "button.snooze.hours": {
3932
4559
  "one": "Silenzia {{hours}} ora",
@@ -4645,7 +5272,8 @@ var NcDispatcher = class {
4645
5272
  const systemEvent = subject.systemEvent;
4646
5273
  const displayDeviceId = systemEvent?.deviceId ?? subject.deviceId;
4647
5274
  const deviceName = systemEvent !== void 0 && systemEvent.deviceId === void 0 ? "system" : await this.deps.getDeviceName(displayDeviceId).catch(() => null) ?? `camera ${displayDeviceId}`;
4648
- const zoneLabels = systemEvent !== void 0 ? [] : await resolveZoneLabels(this.deps.getZoneNames, subject.deviceId, subject.zones);
5275
+ const alarmSource = systemEvent?.alarmSource;
5276
+ 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) : [];
4649
5277
  const vars = buildTemplateVars(this.texts, entry, deviceName, zoneLabels);
4650
5278
  const systemCount = systemEvent?.textCount !== void 0 ? { count: systemEvent.textCount } : {};
4651
5279
  const title = this.renderText({
@@ -4988,6 +5616,10 @@ async function resolveZoneLabels(getZoneNames, deviceId, zoneIds) {
4988
5616
  return [...zoneIds];
4989
5617
  }
4990
5618
  }
5619
+ /** `{{confidence}}` as a whole percent, or empty when nothing measured one. */
5620
+ function percentOf(confidence) {
5621
+ return confidence !== void 0 ? `${Math.round(confidence * 100)}%` : "";
5622
+ }
4991
5623
  /**
4992
5624
  * The variable map ONE render pass sees: scalars, then the composites built
4993
5625
  * from them, then the params a system-event row froze at intake.
@@ -5003,19 +5635,23 @@ function buildTemplateVars(texts, entry, deviceName, zoneLabels) {
5003
5635
  const subject = entry.payload.subject;
5004
5636
  const occupancy = subject.occupancy;
5005
5637
  const systemEvent = subject.systemEvent;
5638
+ const detection = systemEvent?.alarmSource?.className !== void 0 ? systemEvent.alarmSource : void 0;
5006
5639
  return {
5007
5640
  camera: deviceName,
5008
- class: subject.className,
5009
- label: subject.label ?? "",
5641
+ class: detection?.className ?? subject.className,
5642
+ label: detection?.label ?? subject.label ?? "",
5010
5643
  zones: zoneLabels.join(", "),
5011
- subject: systemEvent?.subject ?? ncSubjectText(texts, {
5644
+ subject: detection?.className !== void 0 ? ncSubjectText(texts, {
5645
+ className: detection.className,
5646
+ ...detection.label !== void 0 ? { label: detection.label } : {}
5647
+ }) : systemEvent?.subject ?? ncSubjectText(texts, {
5012
5648
  className: subject.className,
5013
5649
  ...subject.label !== void 0 ? { label: subject.label } : {}
5014
5650
  }),
5015
5651
  inZones: ncInZonesText(texts, zoneLabels),
5016
5652
  scope: occupancy?.zone ?? deviceName,
5017
5653
  zone: occupancy?.zone ?? zoneLabels[0] ?? "",
5018
- confidence: subject.confidence !== void 0 ? `${Math.round(subject.confidence * 100)}%` : "",
5654
+ confidence: percentOf(detection?.confidence ?? subject.confidence),
5019
5655
  time: new Date(subject.timestamp).toLocaleTimeString(),
5020
5656
  rule: entry.payload.ruleName,
5021
5657
  count: occupancy !== void 0 ? `${occupancy.count}` : "",
@@ -5555,6 +6191,35 @@ function incomingFromSystemEvent(event, lookup) {
5555
6191
  }
5556
6192
  }
5557
6193
  /**
6194
+ * The COMBINED row's two keys, or null when this transition is not one.
6195
+ *
6196
+ * A rule that trips the alarm produces ONE notification, and the operator has
6197
+ * to be able to read it: the title is the severity marker over the camera
6198
+ * (`alarm.combined.title`, shipped as `Alarm: {{deviceName}}` and re-wordable
6199
+ * per rule or in the Texts tab), and the body is `detection.body` — the exact
6200
+ * sentence that rule's ordinary notification would have carried, with its own
6201
+ * class, identity and zones.
6202
+ *
6203
+ * The alarm used to write its own prose here ("The alarm was triggered by
6204
+ * Person at gate on Ingresso"), which made the one notification that matters
6205
+ * the only one in the system that did not look like the rule's others: no
6206
+ * identity, no zone, a different subject. The prefix carries the severity; the
6207
+ * familiar line carries the meaning.
6208
+ *
6209
+ * **Gated on the detection vocabulary, not on the source.** `detection.body`
6210
+ * renders `{{subject}}`, and a fire ref with no class composes it from the
6211
+ * system event's dedup slot — "alarm:42 su Ingresso". A source without a class
6212
+ * therefore keeps the alarm's own sentence, which is also what every row
6213
+ * enqueued before this deploy carries.
6214
+ */
6215
+ function combinedAlarmText(source) {
6216
+ if (source?.className === void 0) return null;
6217
+ return {
6218
+ titleKey: "alarm.combined.title",
6219
+ bodyKey: "detection.body"
6220
+ };
6221
+ }
6222
+ /**
5558
6223
  * The panel's own transition, as a system-event subject.
5559
6224
  *
5560
6225
  * IN-PROCESS, not off the bus. The alarm and the Notification Center live in
@@ -5577,6 +6242,7 @@ function incomingFromAlarmTransition(input) {
5577
6242
  const deviceId = source?.deviceId ?? panelDeviceId;
5578
6243
  const identity = input.lookup(deviceId);
5579
6244
  const kind = transition.kind;
6245
+ const combined = combinedAlarmText(source);
5580
6246
  return systemSubject({
5581
6247
  facts: {
5582
6248
  eventId: `alarm:${kind}:${String(transition.at)}`,
@@ -5592,17 +6258,18 @@ function incomingFromAlarmTransition(input) {
5592
6258
  },
5593
6259
  kind,
5594
6260
  subject: `alarm:${String(panelDeviceId)}`,
5595
- 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`),
6261
+ titleKey: combined !== null ? combined.titleKey : transition.mode !== void 0 ? `system.${kind}.title.${transition.mode}` : `system.${kind}.title`,
6262
+ bodyKey: combined?.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
+ ...kind === "alarm-arming" ? { seconds: `${transition.seconds ?? 0}` } : {},
6268
+ ...input.body?.textParams
5602
6269
  },
5603
6270
  deviceId,
5604
6271
  ...identity !== void 0 ? { deviceType: identity.type } : {},
5605
- ...input.arm !== void 0 ? { textCount: input.arm.textCount } : {},
6272
+ ...input.body !== void 0 ? { textCount: input.body.textCount } : {},
5606
6273
  ...source !== void 0 ? { alarmSource: source } : {}
5607
6274
  });
5608
6275
  }
@@ -6189,6 +6856,16 @@ var OccupancyWatcher = class {
6189
6856
  watched = /* @__PURE__ */ new Map();
6190
6857
  /** Confirmed + pending state, keyed by the full {@link OccupancyKey}. */
6191
6858
  states = /* @__PURE__ */ new Map();
6859
+ /** Rows SEEDED since the last {@link takeSeededRows} — the durable
6860
+ * write-through queue (see that method). */
6861
+ seeded = /* @__PURE__ */ new Map();
6862
+ /** Keys already reported through `onImpossibleScope` (once-per-key gate).
6863
+ * Pruned with the watched set, so re-authoring a rule reports again. */
6864
+ impossibleReported = /* @__PURE__ */ new Set();
6865
+ deps;
6866
+ constructor(deps = {}) {
6867
+ this.deps = deps;
6868
+ }
6192
6869
  /**
6193
6870
  * Replace the watched key set (rule-driven — recomputed on rule change).
6194
6871
  * Each distinct `(zone, class, threshold)` is its OWN watched partial key
@@ -6219,6 +6896,8 @@ var OccupancyWatcher = class {
6219
6896
  });
6220
6897
  }
6221
6898
  for (const key of [...this.states.keys()]) if (!this.watched.has(partialKeyOf(key))) this.states.delete(key);
6899
+ for (const key of [...this.seeded.keys()]) if (!this.watched.has(partialKeyOf(key))) this.seeded.delete(key);
6900
+ for (const key of [...this.impossibleReported]) if (!this.watched.has(partialKeyOf(key))) this.impossibleReported.delete(key);
6222
6901
  }
6223
6902
  /**
6224
6903
  * Feed one camera snapshot at time `now`, returning the edges that COMMIT on
@@ -6231,9 +6910,12 @@ var OccupancyWatcher = class {
6231
6910
  const resolved = resolveOccupancyScope(snapshot, spec);
6232
6911
  if (resolved === null) continue;
6233
6912
  const key = occupancyKey(deviceId, spec.zoneId, spec.className, spec.threshold);
6913
+ this.reportImpossibleScope(deviceId, key, spec);
6234
6914
  const existing = this.states.get(key);
6235
6915
  if (existing === void 0) {
6236
- this.states.set(key, seedState(deviceId, spec, resolved, now));
6916
+ const state = seedState(deviceId, spec, resolved, now);
6917
+ this.states.set(key, state);
6918
+ this.seeded.set(key, stateToRow(key, state, now));
6237
6919
  continue;
6238
6920
  }
6239
6921
  const edge = step(existing, spec, resolved, now);
@@ -6241,6 +6923,38 @@ var OccupancyWatcher = class {
6241
6923
  }
6242
6924
  return edges;
6243
6925
  }
6926
+ /**
6927
+ * Drain the rows seeded since the last call — the caller persists them
6928
+ * through the SAME ledger the committed edges use.
6929
+ *
6930
+ * Drained rather than exposed because a seed is a ONE-TIME fact: re-offering
6931
+ * it would turn a rare write into a per-frame one, which is the thing the
6932
+ * edge-only write-through was designed to avoid. Missing a drain costs
6933
+ * nothing worse than today's behaviour (a cold re-seed at the next boot), so
6934
+ * the caller may fail the write without corrupting anything.
6935
+ */
6936
+ takeSeededRows() {
6937
+ const rows = [...this.seeded.values()];
6938
+ this.seeded.clear();
6939
+ return rows;
6940
+ }
6941
+ /** Fire `onImpossibleScope` the first time a key with an uncountable class
6942
+ * scope is evaluated. See {@link OccupancyImpossibleScope}. */
6943
+ reportImpossibleScope(deviceId, key, spec) {
6944
+ const report = this.deps.onImpossibleScope;
6945
+ if (report === void 0) return;
6946
+ const className = spec.className;
6947
+ if (className === void 0 || isDetectionMacroClass(className)) return;
6948
+ if (this.impossibleReported.has(key)) return;
6949
+ this.impossibleReported.add(key);
6950
+ report({
6951
+ deviceId,
6952
+ key,
6953
+ ...spec.zoneId !== void 0 ? { zoneId: spec.zoneId } : {},
6954
+ className,
6955
+ threshold: spec.threshold
6956
+ });
6957
+ }
6244
6958
  /** Reseed confirmed state from durable rows (boot). Only rows whose key is
6245
6959
  * currently WATCHED are restored — an orphaned durable row (its rule gone)
6246
6960
  * is dropped, keeping the RAM map bounded to the active set. Pending edges
@@ -6263,20 +6977,25 @@ var OccupancyWatcher = class {
6263
6977
  /** Snapshot the CONFIRMED state for durable persistence (pending excluded). */
6264
6978
  snapshotState() {
6265
6979
  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
- });
6980
+ for (const [key, state] of this.states) rows.push(stateToRow(key, state, state.lastChangeAt));
6277
6981
  return rows;
6278
6982
  }
6279
6983
  };
6984
+ /** The durable projection of one in-RAM state — the ONE place the persisted row
6985
+ * shape is built, so a seeded row and a snapshotted one cannot differ. */
6986
+ function stateToRow(key, state, updatedAt) {
6987
+ return {
6988
+ key,
6989
+ deviceId: state.deviceId,
6990
+ ...state.zoneId !== void 0 ? { zoneId: state.zoneId } : {},
6991
+ ...state.className !== void 0 ? { className: state.className } : {},
6992
+ threshold: state.threshold,
6993
+ confirmedCount: state.confirmedCount,
6994
+ occupied: state.occupied,
6995
+ lastChangeAt: state.lastChangeAt,
6996
+ updatedAt
6997
+ };
6998
+ }
6280
6999
  /**
6281
7000
  * The state a key starts from: WHAT THE FIRST SNAPSHOT SAID.
6282
7001
  *
@@ -6440,7 +7159,11 @@ function probeOccupancyNow(input) {
6440
7159
  * field for them would be a cross-package change for a sentence.
6441
7160
  */
6442
7161
  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?)";
7162
+ const classNote = impossibleClassNote(occupancy.className);
7163
+ if (probe.kind === "unavailable") {
7164
+ 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?)";
7165
+ return classNote === null ? reason : `${classNote} · ${reason}`;
7166
+ }
6444
7167
  const where = probe.zoneName ?? (occupancy.zoneId === void 0 ? "whole frame" : occupancy.zoneId);
6445
7168
  const what = occupancy.className ?? "objects";
6446
7169
  const parts = [
@@ -6449,7 +7172,21 @@ function occupancyProbeLabel(occupancy, probe, cooldownSec) {
6449
7172
  `fires on the transition, sustained ${String(occupancy.sustainSeconds)} s`
6450
7173
  ];
6451
7174
  if (cooldownSec > 0) parts.push(`cooldown ${String(cooldownSec)} s`);
6452
- return parts.join(" · ");
7175
+ return classNote === null ? parts.join(" · ") : `${classNote} · ${parts.join(" · ")}`;
7176
+ }
7177
+ /**
7178
+ * The one-clause warning for a class scope the snapshots can never key on, or
7179
+ * `null` when the scope is fine.
7180
+ *
7181
+ * The dry-run's whole job is to make "why does this rule never fire?" answerable
7182
+ * before the operator waits a night for it. A sub-type class produced the most
7183
+ * convincing wrong answer it can give — "0/1 car · not occupied now" — which is
7184
+ * pixel-identical to an empty parking space. `byClass` is MACRO-keyed, so that
7185
+ * counter is 0 structurally and no scene can change it.
7186
+ */
7187
+ function impossibleClassNote(className) {
7188
+ if (className === void 0 || isDetectionMacroClass(className)) return null;
7189
+ return `⚠ “${className}” is a sub-type, not a macro class: this counter is always 0 (use ${[...DETECTION_MACRO_CLASSES].join(" / ")})`;
6453
7190
  }
6454
7191
  /**
6455
7192
  * The mechanics, declared once.
@@ -11753,7 +12490,7 @@ var NC_ALARM_ANNOUNCE_RULE_ID = "nc-alarm-announce";
11753
12490
  * notification that says the alarm was disarmed is worse than offering nothing.
11754
12491
  */
11755
12492
  function alarmButtonKind(kind) {
11756
- if (kind === "alarm-triggered" || kind === "alarm-armed" || kind === "alarm-disarmed") return kind;
12493
+ if (kind === "alarm-triggered" || kind === "alarm-armed" || kind === "alarm-arming" || kind === "alarm-disarmed") return kind;
11757
12494
  return null;
11758
12495
  }
11759
12496
  /**
@@ -11834,14 +12571,20 @@ function frozenOccupancy(rule, occ) {
11834
12571
  */
11835
12572
  function fireRefFor(rule, subject, kind) {
11836
12573
  const hasEventMedia = kind === "object-event" || kind === "package-event";
12574
+ const className = subject.classNames[0];
11837
12575
  return {
11838
12576
  ruleId: rule.id,
11839
12577
  ruleName: rule.name,
11840
12578
  deviceId: subject.deviceId,
12579
+ ...subject.sourceDeviceId !== void 0 ? { sourceDeviceId: subject.sourceDeviceId } : {},
11841
12580
  recordId: subject.recordId,
11842
12581
  ...subject.trackId !== void 0 ? { trackId: subject.trackId } : {},
11843
12582
  ...hasEventMedia ? { eventId: subject.recordId } : {},
11844
- timestamp: subject.timestamp
12583
+ timestamp: subject.timestamp,
12584
+ ...className !== void 0 ? { className } : {},
12585
+ ...subject.label !== void 0 ? { label: subject.label } : {},
12586
+ zones: notificationZones(rule, subject),
12587
+ ...subject.confidence !== void 0 ? { confidence: subject.confidence } : {}
11845
12588
  };
11846
12589
  }
11847
12590
  function notificationZones(rule, subject) {
@@ -11880,17 +12623,55 @@ function historyRecordKind(kind) {
11880
12623
  * showed a key while the phone showed a sentence would be a second vocabulary
11881
12624
  * to keep in step. A pre-catalog row falls back to the pair frozen on it.
11882
12625
  */
12626
+ /**
12627
+ * Cap-facing downgrade of a system-event kind — the SAME device as
12628
+ * {@link historyRecordKind}, and it exists for the same reason.
12629
+ *
12630
+ * This addon bundles its own `@camstack/types` (self-contained externals), so
12631
+ * it can emit a kind the enum in the RUNNING SERVER does not have. The history
12632
+ * router does not: it validates its output against the host's
12633
+ * `NcHistorySubjectSchema`, whose `kind` is a `z.enum`. One row carrying a kind
12634
+ * the host has never heard of therefore does not degrade — it fails the whole
12635
+ * `getHistory` call, for every row, exactly as the enum's own legacy-tail note
12636
+ * warns.
12637
+ *
12638
+ * `alarm-arming` is projected onto `alarm-armed`, its nearest member: same
12639
+ * panel, same lifecycle, adjacent instant. Nothing a person reads changes —
12640
+ * the row's title and body are resolved from the arming keys ("Inserimento
12641
+ * Fuori — attivo tra 30 secondi") — only the machine-facing word does.
12642
+ *
12643
+ * DELETE THIS when a `@camstack/server` closure carrying `alarm-arming` in
12644
+ * `NcSystemEventKindSchema` is installed everywhere this addon runs. Until
12645
+ * then it is what lets the exit-delay notification ship without a train.
12646
+ */
12647
+ /**
12648
+ * The mirrored device-state word, narrowed to a contact reading.
12649
+ *
12650
+ * Anything that is not one of the two contact words is `undefined` — a device
12651
+ * whose slice the state table read as `on` / `armed_home` is not a contact, and
12652
+ * an exclusion must never be lifted by a word that belongs to another cap.
12653
+ */
12654
+ function contactReadingOf(state) {
12655
+ if (state === "open" || state === "closed") return state;
12656
+ }
12657
+ function historySystemEventKind(kind) {
12658
+ if (kind === "alarm-arming") return "alarm-armed";
12659
+ if (kind === "alarm-arm-refused") return "alarm-disarmed";
12660
+ return kind;
12661
+ }
11883
12662
  function historySystemEvent(texts, systemEvent) {
11884
12663
  const vars = systemEvent.textParams ?? {};
12664
+ const count = systemEvent.textCount;
11885
12665
  const resolve = (key, frozen) => {
11886
12666
  if (key !== void 0 && texts.has(key)) return texts.text({
11887
12667
  key,
11888
- vars
12668
+ vars,
12669
+ ...count !== void 0 ? { count } : {}
11889
12670
  });
11890
12671
  return frozen ?? "";
11891
12672
  };
11892
12673
  return {
11893
- kind: systemEvent.kind,
12674
+ kind: historySystemEventKind(systemEvent.kind),
11894
12675
  subject: systemEvent.subject,
11895
12676
  title: resolve(systemEvent.titleKey, systemEvent.title),
11896
12677
  body: resolve(systemEvent.bodyKey, systemEvent.body),
@@ -11952,8 +12733,22 @@ var NotificationCenter = class NotificationCenter {
11952
12733
  /** Debounced occupancy edge state machine (pure) + its durable confirmed
11953
12734
  * state. Fed in-process by {@link observeOccupancy} from ZoneAnalytics
11954
12735
  * snapshots; watched keys are recomputed from the enabled occupancy rules. */
11955
- occupancyWatcher = new OccupancyWatcher();
12736
+ occupancyWatcher = new OccupancyWatcher({ onImpossibleScope: (scope) => {
12737
+ this.logger.warn("occupancy key can never count — class is a sub-type, not a macro", {
12738
+ tags: { deviceId: scope.deviceId },
12739
+ meta: {
12740
+ key: scope.key,
12741
+ zoneId: scope.zoneId ?? "@frame",
12742
+ className: scope.className,
12743
+ threshold: scope.threshold,
12744
+ macros: [...DETECTION_MACRO_CLASSES].join(",")
12745
+ }
12746
+ });
12747
+ } });
11956
12748
  occupancyStore;
12749
+ /** Rendered watched-occupancy-key set as last logged — the change gate for
12750
+ * {@link reportOccupancyWatch} (it runs on every rule-reload tick). */
12751
+ lastOccupancyWatchReport = "";
11957
12752
  /**
11958
12753
  * The sustained-sound sampling-window matcher (pure). Fed in-process by
11959
12754
  * {@link observeAudio} from the pipeline's audio inference frames; watched
@@ -12764,8 +13559,18 @@ var NotificationCenter = class NotificationCenter {
12764
13559
  * before it.
12765
13560
  */
12766
13561
  onSensorEventPersisted(event, markerTrackId) {
13562
+ this.observeContactEvent(event);
12767
13563
  this.consumeEvent(incomingFromSensorEvent(event, markerTrackId));
12768
13564
  }
13565
+ /** The contact reading a sensor row carries, handed to the panel. Nothing at
13566
+ * all for any other sensor kind — see {@link observeAlarmContacts}. */
13567
+ observeContactEvent(event) {
13568
+ const panel = this.alarmPanel;
13569
+ if (panel === null || event.kind !== "contact") return;
13570
+ const entryOpen = event.value?.["entryOpen"];
13571
+ if (typeof entryOpen !== "boolean") return;
13572
+ panel.observeContact(event.sourceDeviceId, entryOpen ? "open" : "closed", event.timestamp);
13573
+ }
12769
13574
  /**
12770
13575
  * Called at the AUDIO-event persist site (`eventStore.insertAudio`), in the
12771
13576
  * SAME moment as the durable insert. Feeds an `immediate` rule that OPTS IN
@@ -12812,7 +13617,20 @@ var NotificationCenter = class NotificationCenter {
12812
13617
  });
12813
13618
  return;
12814
13619
  }
13620
+ this.persistSeededOccupancy(now);
12815
13621
  for (const edge of edges) {
13622
+ this.logger.info("occupancy edge committed", {
13623
+ tags: { deviceId: edge.deviceId },
13624
+ meta: {
13625
+ zoneId: edge.zoneId ?? "@frame",
13626
+ zoneName: edge.zoneName ?? "",
13627
+ className: edge.className ?? "@all",
13628
+ threshold: edge.threshold,
13629
+ count: edge.count,
13630
+ previousCount: edge.previousCount,
13631
+ occupied: edge.occupied
13632
+ }
13633
+ });
12816
13634
  this.persistOccupancyEdge(edge, now);
12817
13635
  this.onOccupancyEdge(edge);
12818
13636
  }
@@ -13017,6 +13835,119 @@ var NotificationCenter = class NotificationCenter {
13017
13835
  setAlarmPanel(panel) {
13018
13836
  this.alarmPanel = panel;
13019
13837
  }
13838
+ /**
13839
+ * What the panel asks before it arms — see {@link NcAlarmArmGuardHook}.
13840
+ *
13841
+ * Handed to the panel by the same wiring that hands it the publish hook: the
13842
+ * panel owns the state machine, the centre owns the rules that say which
13843
+ * contacts a mode covers, and neither can reach the other on its own.
13844
+ */
13845
+ alarmArmGuard() {
13846
+ return {
13847
+ openContacts: (mode) => this.alarmOpenContacts(mode),
13848
+ announceRefusal: (input) => this.announceArmRefused(input)
13849
+ };
13850
+ }
13851
+ /**
13852
+ * Which of the contacts this mode covers read OPEN, right now.
13853
+ *
13854
+ * A TARGETED read of at most the handful of sensors the mode names, not a
13855
+ * refresh of the whole watched set: the arm is a person pressing a button and
13856
+ * the answer has to be about this second, but it must not become a fan-out
13857
+ * over every gated device on the hub.
13858
+ *
13859
+ * Three fail-open rules, and all three exist because refusing an arm on
13860
+ * ignorance is worse than arming:
13861
+ *
13862
+ * - a sensor the read could not answer for falls back to the mirrored state
13863
+ * (bounded staleness, the same contract the `deviceState` gate lives with);
13864
+ * - a sensor NEITHER can answer for is not blocking, and says so in a line;
13865
+ * - a mode whose contact rule names no devices blocks nothing, and says so —
13866
+ * an unscoped contact rule covers every contact on the hub, and a refusal
13867
+ * naming devices the operator never associated with the alarm is one they
13868
+ * cannot act on.
13869
+ */
13870
+ async alarmOpenContacts(mode) {
13871
+ const panel = this.alarmPanel;
13872
+ if (panel === null) return [];
13873
+ const candidates = alarmBlockingCandidates(this.rules.list(), panel.deviceId, mode);
13874
+ if (candidates.length === 0) return [];
13875
+ const fresh = await this.readContactStates(candidates);
13876
+ const name = this.deviceNameFromDirectory();
13877
+ const open = [];
13878
+ const unknown = [];
13879
+ for (const deviceId of candidates) {
13880
+ const state = fresh.get(deviceId) ?? this.deviceStates.get(deviceId);
13881
+ if (state === void 0) {
13882
+ unknown.push(deviceId);
13883
+ continue;
13884
+ }
13885
+ if (state === "open") open.push({
13886
+ deviceId,
13887
+ name: name(deviceId)
13888
+ });
13889
+ }
13890
+ for (const deviceId of unknown) this.logger.info("alarm arm check: no contact state — the sensor cannot block this arm", {
13891
+ tags: { deviceId },
13892
+ meta: {
13893
+ panelDeviceId: panel.deviceId,
13894
+ mode
13895
+ }
13896
+ });
13897
+ this.logger.info("alarm arm check", {
13898
+ tags: { deviceId: panel.deviceId },
13899
+ meta: {
13900
+ mode,
13901
+ candidates: candidates.length,
13902
+ open: open.length,
13903
+ unknown: unknown.length,
13904
+ openIds: open.map((c) => c.deviceId).join(",")
13905
+ }
13906
+ });
13907
+ return open;
13908
+ }
13909
+ /** One bounded read of the named contacts. Never throws — an unreadable
13910
+ * sensor is simply absent, exactly as `NcDeviceStateCache` contracts. */
13911
+ async readContactStates(ids) {
13912
+ const read = this.deps.readDeviceStates;
13913
+ if (read === void 0) return /* @__PURE__ */ new Map();
13914
+ try {
13915
+ return await read(ids);
13916
+ } catch (err) {
13917
+ this.logger.warn("alarm arm check: contact read failed — falling back to the mirror", { meta: {
13918
+ devices: ids.length,
13919
+ error: String(err)
13920
+ } });
13921
+ return /* @__PURE__ */ new Map();
13922
+ }
13923
+ }
13924
+ /**
13925
+ * The refusal, as an ordinary notification.
13926
+ *
13927
+ * Same path the arm announcement takes (D126: the alarm speaks through the
13928
+ * ordinary notification path), so it is durable, retried, in history and
13929
+ * worded in the reader's language at send time. It is the operator's ONLY
13930
+ * copy: the thrown error reaches whoever called the cap, and when that caller
13931
+ * is a notification button on a phone, nobody ever sees it.
13932
+ */
13933
+ announceArmRefused(input) {
13934
+ const panel = this.alarmPanel;
13935
+ if (panel === null) return;
13936
+ const text = buildArmRefusedAnnouncement(input.mode, input.blocking.map((c) => c.name), input.retrySeconds);
13937
+ const transition = {
13938
+ kind: "alarm-arm-refused",
13939
+ state: "disarmed",
13940
+ previous: "disarmed",
13941
+ mode: input.mode,
13942
+ at: input.at
13943
+ };
13944
+ const settings = panel.settings();
13945
+ const targetIds = settings.announceArm ? settings.announceTargets : [];
13946
+ this.scheduleAlarmTransition(panel, transition, {
13947
+ text,
13948
+ targetIds
13949
+ });
13950
+ }
13020
13951
  /** Settings + the DERIVED coverage. Read together — see the cap doc. */
13021
13952
  alarmConfig() {
13022
13953
  const panel = this.alarmPanel;
@@ -13053,7 +13984,7 @@ var NotificationCenter = class NotificationCenter {
13053
13984
  const identity = this.deviceDirectory.get(deviceId);
13054
13985
  if (identity === void 0) return void 0;
13055
13986
  if (identity.online === false) return "offline";
13056
- if (identity.detectionActive === false) return "detection-off";
13987
+ if (identity.isCamera && identity.detectionActive === false) return "detection-off";
13057
13988
  }
13058
13989
  /**
13059
13990
  * Everything the panel reports, on every publish.
@@ -13090,13 +14021,13 @@ var NotificationCenter = class NotificationCenter {
13090
14021
  * the combined notification: one row carrying the alarm's words and that
13091
14022
  * rule's pictures, instead of two pushes about one incident.
13092
14023
  */
13093
- scheduleAlarmTransition(panel, transition) {
13094
- const arm = transition.kind === "alarm-armed" && transition.mode !== void 0 ? this.armAnnouncement(panel, transition.mode) : null;
14024
+ scheduleAlarmTransition(panel, transition, prepared) {
14025
+ const arm = prepared ?? this.armAnnouncement(panel, transition);
13095
14026
  const incoming = incomingFromAlarmTransition({
13096
14027
  transition,
13097
14028
  panelDeviceId: panel.deviceId,
13098
14029
  lookup: this.deviceDirectory.lookup(),
13099
- ...arm !== null ? { arm: arm.text } : {}
14030
+ ...arm !== null ? { body: arm.text } : {}
13100
14031
  });
13101
14032
  this.evalChain = this.evalChain.then(async () => {
13102
14033
  try {
@@ -13117,31 +14048,45 @@ var NotificationCenter = class NotificationCenter {
13117
14048
  });
13118
14049
  }
13119
14050
  /**
13120
- * The arm sentence and the targets it goes to, or null when this arm is not
13121
- * announced at all.
14051
+ * The arm/arming sentence and the targets it goes to, or null when the
14052
+ * transition is not one of those two.
14053
+ *
14054
+ * Both kinds compose from the SAME coverage — "what does this mode actually
14055
+ * watch" — because they are the same answer given thirty seconds apart. The
14056
+ * arming one is the useful one: it reaches the operator while the exit delay
14057
+ * is still running, so a mode that protects nothing can still be fixed.
13122
14058
  *
13123
14059
  * Every way of NOT announcing produces a line: an announcement that silently
13124
14060
  * 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) {
14061
+ * is the exact confusion this feature exists to remove. A COVERAGE GAP no
14062
+ * longer suppresses the sentence — it changes it. Suppressing it left the
14063
+ * operator with a title and an empty line (the fallback body key existed in
14064
+ * neither locale) and no way to learn that the mode was gated on by nothing.
14065
+ */
14066
+ armAnnouncement(panel, transition) {
14067
+ const kind = transition.kind;
14068
+ if (kind !== "alarm-armed" && kind !== "alarm-arming") return null;
14069
+ const mode = transition.mode;
14070
+ if (mode === void 0) return null;
13130
14071
  const settings = panel.settings();
13131
14072
  const tags = { deviceId: panel.deviceId };
13132
14073
  const coverage = this.coverageFor(panel).find((c) => c.mode === mode);
13133
14074
  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
- }
14075
+ const name = this.deviceNameFromDirectory();
14076
+ const bypassed = panel.excludedDevices();
14077
+ const text = kind === "alarm-arming" ? buildArmingAnnouncement(coverage, transition.seconds ?? 0, name, bypassed) : buildArmAnnouncement(coverage, name, bypassed);
14078
+ const gap = alarmCoverageGap(coverage);
14079
+ if (gap !== null) this.logger.warn("alarm armed into a mode that watches nothing", {
14080
+ tags,
14081
+ meta: {
14082
+ mode,
14083
+ kind,
14084
+ gap,
14085
+ rules: coverage.ruleCount,
14086
+ devices: coverage.deviceIds.length,
14087
+ skipped: coverage.skippedDevices.length
14088
+ }
14089
+ });
13145
14090
  if (!settings.announceArm) return {
13146
14091
  text,
13147
14092
  targetIds: []
@@ -13685,6 +14630,7 @@ var NotificationCenter = class NotificationCenter {
13685
14630
  }
13686
14631
  this.occupancyWatcher.setWatchedKeys(specs);
13687
14632
  this.occupancyEnabled = specs.length > 0;
14633
+ this.reportOccupancyWatch(specs);
13688
14634
  const audioSpecs = [];
13689
14635
  for (const rule of this.rules.listEnabled("immediate")) {
13690
14636
  const audio = rule.conditions.audio;
@@ -13711,9 +14657,44 @@ var NotificationCenter = class NotificationCenter {
13711
14657
  const devices = rule.conditions.devices;
13712
14658
  if (zones !== void 0 && zones.ids.length > 0 && devices !== void 0) zoneScoped.push(...devices);
13713
14659
  }
14660
+ const panel = this.alarmPanel;
14661
+ if (panel !== null) gated.push(...alarmContactWatchList(this.rules.list(), panel.deviceId, panel.availableModes()));
13714
14662
  this.deviceStates.setWatched(gated);
13715
14663
  this.zoneOwners.setWatched(zoneScoped);
13716
14664
  }
14665
+ /**
14666
+ * Feed every excluded sensor's mirrored state back to the panel.
14667
+ *
14668
+ * The reconcile half of the two-phase lift. The EVENT half
14669
+ * ({@link onSensorEventPersisted}) is the fast one, but it only exists for a
14670
+ * sensor linked to a camera — the seven contacts on this hub are linked to
14671
+ * none, so without this an exclusion would never lift at all and the sensor
14672
+ * would stay out of the alarm until a disarm.
14673
+ */
14674
+ observeAlarmContacts() {
14675
+ const panel = this.alarmPanel;
14676
+ if (panel === null) return;
14677
+ const excluded = panel.excludedDevices();
14678
+ if (excluded.length === 0) return;
14679
+ const now = this.now();
14680
+ for (const deviceId of excluded) panel.observeContact(deviceId, contactReadingOf(this.deviceStates.get(deviceId)), now);
14681
+ }
14682
+ /**
14683
+ * Log the watched occupancy key set, on CHANGE only.
14684
+ *
14685
+ * Change-only because this runs on every rule-reload tick; a line per tick
14686
+ * would be noise, and no line at all is how a rule that watches an
14687
+ * impossible scope stays invisible for a day.
14688
+ */
14689
+ reportOccupancyWatch(specs) {
14690
+ const rendered = specs.map((s) => `${s.zoneId ?? "@frame"}|${s.className ?? "@all"}|t${String(s.threshold)}@${String(s.sustainSeconds)}s`).toSorted().join(",");
14691
+ if (rendered === this.lastOccupancyWatchReport) return;
14692
+ this.lastOccupancyWatchReport = rendered;
14693
+ this.logger.info("occupancy watched keys", { meta: {
14694
+ keys: specs.length,
14695
+ specs: rendered.length > 0 ? rendered : "(none)"
14696
+ } });
14697
+ }
13717
14698
  /** Boot reseed of confirmed occupancy edge-state (durability, constraint 4) —
13718
14699
  * hydrate the watcher from the store, then prune orphaned durable rows to the
13719
14700
  * active watched set. Runs AFTER {@link refreshOccupancyWatch} so hydrate
@@ -13776,6 +14757,7 @@ var NotificationCenter = class NotificationCenter {
13776
14757
  await this.deviceStates.refresh();
13777
14758
  await this.deviceDirectory.refresh();
13778
14759
  await this.zoneOwners.refresh();
14760
+ this.observeAlarmContacts();
13779
14761
  await this.occupancyStore.pruneExcept(this.activeOccupancyKeys());
13780
14762
  }
13781
14763
  /**
@@ -13801,26 +14783,63 @@ var NotificationCenter = class NotificationCenter {
13801
14783
  activeOccupancyKeys() {
13802
14784
  return new Set(this.occupancyWatcher.snapshotState().map((r) => r.key));
13803
14785
  }
14786
+ /**
14787
+ * Write-through the rows the watcher SEEDED on this observation — the initial
14788
+ * level of a key that had neither a durable row nor RAM state.
14789
+ *
14790
+ * Same ledger, same best-effort contract as an edge: a failed write leaves
14791
+ * exactly today's behaviour (a cold re-seed at the next boot), never a
14792
+ * corrupt level. Drained, so a key is written once and not per frame.
14793
+ */
14794
+ persistSeededOccupancy(now) {
14795
+ const rows = this.occupancyWatcher.takeSeededRows();
14796
+ for (const row of rows) {
14797
+ this.logger.info("occupancy baseline seeded", {
14798
+ tags: { deviceId: row.deviceId },
14799
+ meta: {
14800
+ key: row.key,
14801
+ zoneId: row.zoneId ?? "@frame",
14802
+ className: row.className ?? "@all",
14803
+ threshold: row.threshold,
14804
+ count: row.confirmedCount,
14805
+ occupied: row.occupied
14806
+ }
14807
+ });
14808
+ this.persistOccupancyRow(row, now);
14809
+ }
14810
+ }
13804
14811
  /** Write-through the committed confirmed level for one edge (rare — only on a
13805
14812
  * boolean flip). Best-effort: a failed persist is logged, never thrown into
13806
14813
  * the frame path (the boot reseed + next edge recover). */
13807
- async persistOccupancyEdge(edge, now) {
14814
+ persistOccupancyEdge(edge, now) {
14815
+ return this.persistOccupancyRow({
14816
+ key: occupancyKey(edge.deviceId, edge.zoneId, edge.className, edge.threshold),
14817
+ deviceId: edge.deviceId,
14818
+ ...edge.zoneId !== void 0 ? { zoneId: edge.zoneId } : {},
14819
+ ...edge.className !== void 0 ? { className: edge.className } : {},
14820
+ threshold: edge.threshold,
14821
+ confirmedCount: edge.count,
14822
+ occupied: edge.occupied,
14823
+ lastChangeAt: edge.timestamp,
14824
+ updatedAt: now
14825
+ }, now);
14826
+ }
14827
+ /** The ONE durable write for a confirmed occupancy level, whether it came from
14828
+ * a committed edge or a cold seed. Best-effort by contract — see the two
14829
+ * callers. */
14830
+ async persistOccupancyRow(row, now) {
13808
14831
  try {
13809
14832
  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,
14833
+ ...row,
13818
14834
  updatedAt: now
13819
14835
  });
13820
14836
  } catch (err) {
13821
14837
  this.logger.debug("occupancy persist failed", {
13822
- tags: { deviceId: edge.deviceId },
13823
- meta: { error: String(err) }
14838
+ tags: { deviceId: row.deviceId },
14839
+ meta: {
14840
+ key: row.key,
14841
+ error: String(err)
14842
+ }
13824
14843
  });
13825
14844
  }
13826
14845
  }
@@ -35043,6 +36062,138 @@ var BindingCache = class {
35043
36062
  }
35044
36063
  };
35045
36064
  //#endregion
36065
+ //#region src/pipeline-analytics/runtime/detection-coverage-cache.ts
36066
+ /**
36067
+ * DetectionCoverageCache — fleet-seeded, event-invalidated mirror of WHICH
36068
+ * devices carry a WRAPPED `detection-pipeline` binding. This is the alarm's
36069
+ * coverage authority ("Away armed, 12 cameras" is false if three of them are
36070
+ * offline or no longer wrapped), read by the notification centre's device
36071
+ * directory (`listDeviceIdentities`).
36072
+ *
36073
+ * WHY a mirror, not a per-tick sweep. The directory refreshes on the NC's 30 s
36074
+ * rule-reload tick. Answering "who still has detection?" with a fleet-wide
36075
+ * `getAllBindings` on EVERY tick cost 903 ms / 373 KB / ~505 `getBindings:
36076
+ * default wrappers skipped` log lines on the live hub (524 devices), forever —
36077
+ * ~3% of hub-main's main thread plus a permanent log-spam baseline. Same defect
36078
+ * and fix as `SystemMirror.refreshBinding` (639073e8): ONE fleet read to warm,
36079
+ * then per-device updates driven by `DeviceBindingsChanged` events. A wrapper
36080
+ * toggle emits that event carrying the device's id and `capName`
36081
+ * (`detection-pipeline`), so a single `getBindings(deviceId)` — 21 ms / 613 B —
36082
+ * keeps the mirror current without ever touching the fleet again.
36083
+ *
36084
+ * ── Destructive-direction discipline (D49) ──────────────────────────────────
36085
+ *
36086
+ * `detectionActive: false` reports a camera as UNCOVERED / switched-off (D62) —
36087
+ * the destructive answer, and the one an operator reads as "my alarm is not
36088
+ * watching that door". A transient `getBindings` blip must never flip a
36089
+ * known-covered camera to uncovered:
36090
+ * • a failed refresh KEEPS the previous value (a read that throws changes
36091
+ * nothing), and
36092
+ * • a single read that would flip a known `true → false` is confirmed by a
36093
+ * concordant SECOND read before it lands — an empty binding list is not
36094
+ * proof the wrapper is gone.
36095
+ * A COLD mirror (seed not yet landed, or a seed that failed) answers `undefined`
36096
+ * for every id — and unknown never reports a camera as excluded from coverage.
36097
+ */
36098
+ var DetectionCoverageCache = class {
36099
+ api;
36100
+ logger;
36101
+ /** deviceId → "has a WRAPPED detection-pipeline binding". Absent = unknown. */
36102
+ state = /* @__PURE__ */ new Map();
36103
+ warm = false;
36104
+ seeding = null;
36105
+ constructor(deps) {
36106
+ this.api = deps.api;
36107
+ this.logger = deps.logger;
36108
+ }
36109
+ /** Has the ONE fleet seed ever landed? A cold mirror knows nothing. */
36110
+ isWarm() {
36111
+ return this.warm;
36112
+ }
36113
+ /** Do we have an answer for this id at all? (`false` ⇒ absent ⇒ unknown.) */
36114
+ has(deviceId) {
36115
+ return this.state.has(deviceId);
36116
+ }
36117
+ /** The coverage answer, or `undefined` when this id is unknown. No I/O. */
36118
+ get(deviceId) {
36119
+ return this.state.get(deviceId);
36120
+ }
36121
+ /**
36122
+ * Seed the whole fleet ONCE with a single `getAllBindings`. Idempotent: a
36123
+ * warm cache is a no-op, and concurrent callers coalesce onto one read. A
36124
+ * failed seed leaves the mirror COLD so the next tick retries — during that
36125
+ * window every id answers unknown, which never reports a camera as excluded.
36126
+ */
36127
+ async ensureWarm() {
36128
+ if (this.warm) return;
36129
+ if (this.seeding) return this.seeding;
36130
+ this.seeding = this.seed();
36131
+ try {
36132
+ await this.seeding;
36133
+ } finally {
36134
+ this.seeding = null;
36135
+ }
36136
+ }
36137
+ async seed() {
36138
+ try {
36139
+ const rows = await this.api.deviceManager.getAllBindings.query({});
36140
+ for (const row of rows) {
36141
+ if (this.state.has(row.deviceId)) continue;
36142
+ this.state.set(row.deviceId, hasWrappedDetection(row.entries));
36143
+ }
36144
+ this.warm = true;
36145
+ this.logger.info("detection-coverage mirror warm", { meta: { devices: this.state.size } });
36146
+ } catch (err) {
36147
+ this.logger.warn("detection-coverage seed failed — coverage unknown until it lands", { meta: { error: String(err) } });
36148
+ }
36149
+ }
36150
+ /**
36151
+ * A `DeviceBindingsChanged` for the detection cap re-reads THAT ONE device
36152
+ * (never the fleet). Fire-and-forget from the synchronous event handler.
36153
+ */
36154
+ onBindingsChanged(event) {
36155
+ if (event.capName !== "detection-pipeline") return Promise.resolve();
36156
+ return this.refreshDevice(event.deviceId);
36157
+ }
36158
+ /** Drop a device the fleet no longer has (`device.unregistered`). */
36159
+ forget(deviceId) {
36160
+ this.state.delete(deviceId);
36161
+ }
36162
+ /** Return to cold — used on addon shutdown. */
36163
+ clearAll() {
36164
+ this.state.clear();
36165
+ this.warm = false;
36166
+ this.seeding = null;
36167
+ }
36168
+ async refreshDevice(deviceId) {
36169
+ try {
36170
+ const active = await this.readWrappedDetection(deviceId);
36171
+ const prev = this.state.get(deviceId);
36172
+ if (!active && prev === true) {
36173
+ if (await this.readWrappedDetection(deviceId)) {
36174
+ this.state.set(deviceId, true);
36175
+ return;
36176
+ }
36177
+ }
36178
+ this.state.set(deviceId, active);
36179
+ } catch (err) {
36180
+ this.logger.debug("detection-coverage refresh failed — keeping the previous value", {
36181
+ tags: { deviceId },
36182
+ meta: { error: String(err) }
36183
+ });
36184
+ }
36185
+ }
36186
+ async readWrappedDetection(deviceId) {
36187
+ return hasWrappedDetection((await this.api.deviceManager.getBindings.query({ deviceId })).entries);
36188
+ }
36189
+ };
36190
+ /** The one authority: a WRAPPED `detection-pipeline` binding is present. A
36191
+ * native binding of the same cap is NOT coverage (D62: absence of the WRAPPED
36192
+ * binding is how a deactivated wrapper is represented). */
36193
+ function hasWrappedDetection(entries) {
36194
+ return entries.some((e) => e.capName === "detection-pipeline" && e.kind === "wrapped");
36195
+ }
36196
+ //#endregion
35046
36197
  //#region src/pipeline-analytics/runtime/capture-scheduler.ts
35047
36198
  /**
35048
36199
  * Kind-derived default priorities (0 = highest). Explicit `priority` wins.
@@ -39907,7 +41058,13 @@ var ZoneAnalyticsProvider = class {
39907
41058
  meta: {
39908
41059
  totalObjects: total,
39909
41060
  byClass: snapshot.frame.byClass,
39910
- zones: snapshot.zones.length
41061
+ zones: snapshot.zones.length,
41062
+ zoneCounts: snapshot.zones.map((z) => ({
41063
+ zoneId: z.zoneId,
41064
+ zoneName: z.zoneName,
41065
+ totalObjects: z.totalObjects,
41066
+ byClass: z.byClass
41067
+ }))
39911
41068
  }
39912
41069
  });
39913
41070
  this.ctx.emitOccupancyChanged?.({
@@ -40419,6 +41576,12 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
40419
41576
  * path). Null on non-post-processing nodes or when disabled. */
40420
41577
  embeddingDispatcher = null;
40421
41578
  bindingCache = null;
41579
+ /** Alarm-coverage mirror the NC device directory reads — WHICH devices carry
41580
+ * a wrapped `detection-pipeline` binding. Fleet-seeded ONCE, then per-device
41581
+ * updates off `DeviceBindingsChanged`, so the 30 s rule-reload tick stops
41582
+ * sweeping `getAllBindings` over the whole fleet. See
41583
+ * `runtime/detection-coverage-cache.ts`. */
41584
+ detectionCoverage = null;
40422
41585
  /** Per-device throttle for the inactive-binding frame-drop warn (5 min). */
40423
41586
  inactiveBindingDropWarnAt = /* @__PURE__ */ new Map();
40424
41587
  zoneAnalytics = null;
@@ -41209,9 +42372,12 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
41209
42372
  settings: () => panel.settings(),
41210
42373
  applySettings: (patch) => panel.applySettings(patch),
41211
42374
  availableModes: () => NC_ALARM_MODES,
41212
- noteRuleTrigger: (source) => panel.noteRuleTrigger(source)
42375
+ noteRuleTrigger: (source) => panel.noteRuleTrigger(source),
42376
+ excludedDevices: () => panel.excludedDevices(),
42377
+ observeContact: (deviceId, reading, now) => panel.observeContact(deviceId, reading, now)
41213
42378
  });
41214
42379
  panel.setPublishHook((report) => center.onAlarmPublish(report));
42380
+ panel.setArmGuard(center.alarmArmGuard());
41215
42381
  center.onAlarmPublish({
41216
42382
  state: panel.currentState(),
41217
42383
  transition: null
@@ -41599,6 +42765,10 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
41599
42765
  api,
41600
42766
  logger: logger.child("BindingCache")
41601
42767
  });
42768
+ this.detectionCoverage = new DetectionCoverageCache({
42769
+ api,
42770
+ logger: logger.child("DetectionCoverage")
42771
+ });
41602
42772
  const zoneAnalytics = new ZoneAnalyticsProvider({
41603
42773
  logger: logger.child("ZoneAnalytics"),
41604
42774
  onOccupancySample: (deviceId, count, at, zoneId) => this.lts?.note("occupancy", deviceId, count, at, zoneId ?? ""),
@@ -41680,17 +42850,15 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
41680
42850
  },
41681
42851
  listDeviceIdentities: async () => {
41682
42852
  const all = await api.deviceManager.listAll.query({ projection: "slim" });
41683
- 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) => {
41684
- this.ctx.logger.debug("bindings read failed — alarm coverage cannot report skips", { meta: { error: String(err) } });
41685
- return /* @__PURE__ */ new Map();
41686
- });
42853
+ const coverage = this.detectionCoverage;
42854
+ if (coverage) await coverage.ensureWarm();
41687
42855
  return all.map((d) => ({
41688
42856
  deviceId: d.id,
41689
42857
  type: d.type,
41690
42858
  name: d.name,
41691
42859
  isCamera: d.isCamera,
41692
42860
  online: d.online && !d.disabled,
41693
- ...detection.has(d.id) ? { detectionActive: detection.get(d.id) === true } : {}
42861
+ ...coverage?.has(d.id) ? { detectionActive: coverage.get(d.id) === true } : {}
41694
42862
  }));
41695
42863
  },
41696
42864
  readDeviceZoneIds: async (ids) => {
@@ -42398,6 +43566,7 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
42398
43566
  * `runtime/wire-subscriptions.ts`. */
42399
43567
  handleBindingsChangedEvent(data) {
42400
43568
  this.bindingCache?.onBindingsChanged(data);
43569
+ this.detectionCoverage?.onBindingsChanged(data);
42401
43570
  if (data.capName === "pipeline-analytics" && data.reason === "wrapper-deactivated") {
42402
43571
  this.trackStore?.clearDevice(data.deviceId);
42403
43572
  this.residents.closeDevice(data.deviceId);
@@ -42423,6 +43592,7 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
42423
43592
  this.audioMarkers?.forgetDevice(deviceId);
42424
43593
  this.settingsCaches.clearDevice(deviceId);
42425
43594
  this.bindingCache?.invalidate(deviceId);
43595
+ this.detectionCoverage?.forget(deviceId);
42426
43596
  this.zoneAnalytics?.forgetDevice(deviceId);
42427
43597
  this.audioMetrics?.forgetDevice(deviceId);
42428
43598
  this.releaseProxy(deviceId);
@@ -42628,6 +43798,7 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
42628
43798
  this.trackStore?.clearAll();
42629
43799
  this.stationaryRegistry = null;
42630
43800
  this.bindingCache?.clearAll();
43801
+ this.detectionCoverage?.clearAll();
42631
43802
  await this.eventMediaDataPlane?.dispose();
42632
43803
  this.eventMediaDataPlane = null;
42633
43804
  this.eventMediaBaseUrl = null;