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