@camstack/addon-post-analysis 1.2.281 → 1.2.283
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-BtqlSCQT.js → dist-CT5xOY86.js} +394 -8
- package/dist/{dist-DkERzZfY.mjs → dist-zp1v7cCp.mjs} +353 -9
- package/dist/embedding-encoder/index.js +1 -1
- package/dist/embedding-encoder/index.mjs +1 -1
- package/dist/pipeline-analytics/_stub.js +2 -2
- package/dist/pipeline-analytics/{_virtual_mf-localSharedImportMap___mfe_internal__addon_pipeline_analytics_widgets-d-u88SNA.mjs → _virtual_mf-localSharedImportMap___mfe_internal__addon_pipeline_analytics_widgets-9UxXz_Yw.mjs} +2 -2
- package/dist/pipeline-analytics/_virtual_mf___mfe_internal__addon_pipeline_analytics_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-CZkZhroP.mjs +26 -0
- package/dist/pipeline-analytics/{_virtual_mf___mfe_internal__addon_pipeline_analytics_widgets__loadShare___mf_0_camstack_mf_1_ui_mf_2_library__loadShare__.js-BA7N1yDM.mjs → _virtual_mf___mfe_internal__addon_pipeline_analytics_widgets__loadShare___mf_0_camstack_mf_1_ui_mf_2_library__loadShare__.js-DWPDYuJS.mjs} +1 -1
- package/dist/pipeline-analytics/{hostInit-Cq_8hgwY.mjs → hostInit-27I-ZEiW.mjs} +2 -2
- package/dist/pipeline-analytics/index.js +901 -81
- package/dist/pipeline-analytics/index.mjs +901 -81
- package/dist/pipeline-analytics/remoteEntry.js +1 -1
- package/package.json +1 -1
- package/dist/pipeline-analytics/_virtual_mf___mfe_internal__addon_pipeline_analytics_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-OkXjMLHG.mjs +0 -26
|
@@ -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-CT5xOY86.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");
|
|
@@ -287,6 +287,7 @@ function coverageFor(rules, panelDeviceId, mode, skipProbe) {
|
|
|
287
287
|
const ids = /* @__PURE__ */ new Set();
|
|
288
288
|
let ruleCount = 0;
|
|
289
289
|
let allDevices = false;
|
|
290
|
+
let except = null;
|
|
290
291
|
for (const rule of rules) {
|
|
291
292
|
if (!rule.enabled) continue;
|
|
292
293
|
const gate = rule.conditions.deviceState;
|
|
@@ -294,21 +295,35 @@ function coverageFor(rules, panelDeviceId, mode, skipProbe) {
|
|
|
294
295
|
if (!gate.states.includes(wanted)) continue;
|
|
295
296
|
ruleCount += 1;
|
|
296
297
|
const scope = rule.conditions.devices;
|
|
298
|
+
const excluded = rule.conditions.devicesExclude ?? [];
|
|
297
299
|
if (scope === void 0 || scope.length === 0) {
|
|
298
300
|
allDevices = true;
|
|
301
|
+
except = narrowExcept(except, excluded);
|
|
299
302
|
continue;
|
|
300
303
|
}
|
|
301
|
-
for (const id of scope) ids.add(id);
|
|
304
|
+
for (const id of scope) if (!excluded.includes(id)) ids.add(id);
|
|
302
305
|
}
|
|
303
306
|
const deviceIds = [...ids].toSorted((a, b) => a - b);
|
|
307
|
+
const allDevicesExcept = except === null || except === "none" ? [] : [...except].filter((id) => !ids.has(id)).toSorted((a, b) => a - b);
|
|
304
308
|
return {
|
|
305
309
|
mode,
|
|
306
310
|
ruleCount,
|
|
307
311
|
allDevices,
|
|
312
|
+
allDevicesExcept,
|
|
308
313
|
deviceIds,
|
|
309
314
|
skippedDevices: skippedFor(deviceIds, skipProbe)
|
|
310
315
|
};
|
|
311
316
|
}
|
|
317
|
+
/**
|
|
318
|
+
* Fold one UNSCOPED covering rule into the except-set: with no exclusion it
|
|
319
|
+
* covers everything (`'none'`, final); otherwise only what every unscoped rule
|
|
320
|
+
* excludes stays uncovered — the intersection.
|
|
321
|
+
*/
|
|
322
|
+
function narrowExcept(current, excluded) {
|
|
323
|
+
if (current === "none" || excluded.length === 0) return "none";
|
|
324
|
+
if (current === null) return new Set(excluded);
|
|
325
|
+
return new Set(excluded.filter((id) => current.has(id)));
|
|
326
|
+
}
|
|
312
327
|
function skippedFor(deviceIds, skipProbe) {
|
|
313
328
|
if (skipProbe === void 0) return [];
|
|
314
329
|
const out = [];
|
|
@@ -382,6 +397,19 @@ function buildArmAnnouncement(coverage, deviceName, bypassed = []) {
|
|
|
382
397
|
textCount: names.length
|
|
383
398
|
};
|
|
384
399
|
}
|
|
400
|
+
if (coverage.allDevices && coverage.allDevicesExcept.length > 0) {
|
|
401
|
+
const names = coverage.allDevicesExcept.map(deviceName);
|
|
402
|
+
return {
|
|
403
|
+
bodyKey: "alarm.arm.body.allDevicesExcept",
|
|
404
|
+
textParams: {
|
|
405
|
+
rules,
|
|
406
|
+
mode,
|
|
407
|
+
count: `${names.length}`,
|
|
408
|
+
names: names.join(", ")
|
|
409
|
+
},
|
|
410
|
+
textCount: names.length
|
|
411
|
+
};
|
|
412
|
+
}
|
|
385
413
|
if (coverage.allDevices) return {
|
|
386
414
|
bodyKey: "alarm.arm.body.allDevices",
|
|
387
415
|
textParams: {
|
|
@@ -600,7 +628,8 @@ function alarmCoveredDevices(rules, panelDeviceId, mode) {
|
|
|
600
628
|
if (!gate.states.includes(wanted)) continue;
|
|
601
629
|
const scope = rule.conditions.devices;
|
|
602
630
|
if (scope === void 0 || scope.length === 0) continue;
|
|
603
|
-
|
|
631
|
+
const excluded = rule.conditions.devicesExclude ?? [];
|
|
632
|
+
for (const id of scope) if (!excluded.includes(id)) ids.add(id);
|
|
604
633
|
}
|
|
605
634
|
return [...ids].toSorted((a, b) => a - b);
|
|
606
635
|
}
|
|
@@ -6634,6 +6663,8 @@ var SceneEngine = class {
|
|
|
6634
6663
|
var DEFAULT_LOAD_LIMIT = 1e5;
|
|
6635
6664
|
var DurableLedger = class DurableLedger {
|
|
6636
6665
|
mirror = /* @__PURE__ */ new Map();
|
|
6666
|
+
/** Whether ANY load has read the store successfully — see {@link hasAnswered}. */
|
|
6667
|
+
answered = false;
|
|
6637
6668
|
spec;
|
|
6638
6669
|
store;
|
|
6639
6670
|
logger;
|
|
@@ -6689,6 +6720,7 @@ var DurableLedger = class DurableLedger {
|
|
|
6689
6720
|
}
|
|
6690
6721
|
this.mirror.clear();
|
|
6691
6722
|
for (const [key, row] of next) this.mirror.set(key, row);
|
|
6723
|
+
this.answered = true;
|
|
6692
6724
|
if (skipped > 0) this.logger.warn("durable rows skipped as malformed — they gate NOTHING", { meta: {
|
|
6693
6725
|
collection: this.spec.collection,
|
|
6694
6726
|
skipped
|
|
@@ -6703,6 +6735,16 @@ var DurableLedger = class DurableLedger {
|
|
|
6703
6735
|
return [...this.mirror.values()];
|
|
6704
6736
|
}
|
|
6705
6737
|
}
|
|
6738
|
+
/**
|
|
6739
|
+
* Whether the mirror is an ANSWER from the store: true once any load has
|
|
6740
|
+
* succeeded, and never false again (a later failed load keeps that answer).
|
|
6741
|
+
* Before it, an empty mirror is "not read yet", never "the store is empty" —
|
|
6742
|
+
* {@link load} swallows its failure, so a caller that must tell the two apart
|
|
6743
|
+
* asks here (D642 review I1).
|
|
6744
|
+
*/
|
|
6745
|
+
hasAnswered() {
|
|
6746
|
+
return this.answered;
|
|
6747
|
+
}
|
|
6706
6748
|
/** Every mirrored row, insertion-ordered. */
|
|
6707
6749
|
snapshot() {
|
|
6708
6750
|
return [...this.mirror.values()];
|
|
@@ -7795,10 +7837,10 @@ function replaceConditionReferences(monitor, condition, reference) {
|
|
|
7795
7837
|
//#endregion
|
|
7796
7838
|
//#region src/notification-center/snooze-exempt.ts
|
|
7797
7839
|
/** The cap whose presence in a sequence makes the rule the alarm's trigger. */
|
|
7798
|
-
var ALARM_PANEL_CAP$
|
|
7840
|
+
var ALARM_PANEL_CAP$1 = "alarm-panel";
|
|
7799
7841
|
var ALARM_KINDS = new Set(require_dist.NC_ALARM_SYSTEM_EVENT_KINDS);
|
|
7800
7842
|
function actuatesAlarmPanel(sequences) {
|
|
7801
|
-
for (const sequence of sequences) for (const step of sequence.actions) if (step.kind === "cap" && step.cap === ALARM_PANEL_CAP$
|
|
7843
|
+
for (const sequence of sequences) for (const step of sequence.actions) if (step.kind === "cap" && step.cap === ALARM_PANEL_CAP$1) return true;
|
|
7802
7844
|
return false;
|
|
7803
7845
|
}
|
|
7804
7846
|
/**
|
|
@@ -8436,6 +8478,78 @@ var NcAlertStore = class {
|
|
|
8436
8478
|
};
|
|
8437
8479
|
}
|
|
8438
8480
|
};
|
|
8481
|
+
//#endregion
|
|
8482
|
+
//#region src/notification-center/alerts/consumable-subject.ts
|
|
8483
|
+
/**
|
|
8484
|
+
* A consumable EPISODE's name, and the thresholds the rule set asks for (D642).
|
|
8485
|
+
*
|
|
8486
|
+
* Before D642 one cluster-wide threshold judged every item, so an item had one
|
|
8487
|
+
* episode: `consumable:<deviceId>:<key>`. The threshold is now the RULE's, and
|
|
8488
|
+
* the ledger keeps one episode per threshold a rule asks for — each with its
|
|
8489
|
+
* own hysteresis, pending raise and reminder — named
|
|
8490
|
+
* `consumable@<threshold>:<deviceId>:<key>`.
|
|
8491
|
+
*
|
|
8492
|
+
* The threshold rides the FAMILY segment on purpose: the device id stays the
|
|
8493
|
+
* second segment (`alertSubjectDeviceId`, D636 — the log tag and the indexed
|
|
8494
|
+
* column read it), and the item key stays last and whole, whatever it holds.
|
|
8495
|
+
*
|
|
8496
|
+
* Pure. No clock, no I/O.
|
|
8497
|
+
*/
|
|
8498
|
+
var FAMILY = "consumable";
|
|
8499
|
+
/** The episode of `key` on `deviceId`, judged at `thresholdPercent`. */
|
|
8500
|
+
function consumableSubject(thresholdPercent, deviceId, key) {
|
|
8501
|
+
return `${FAMILY}@${String(thresholdPercent)}:${String(deviceId)}:${key}`;
|
|
8502
|
+
}
|
|
8503
|
+
/** The pre-D642 subject of the same item — read only to carry it over. */
|
|
8504
|
+
function legacyConsumableSubject(deviceId, key) {
|
|
8505
|
+
return `${FAMILY}:${String(deviceId)}:${key}`;
|
|
8506
|
+
}
|
|
8507
|
+
/** `null` for a subject that is not a consumable's. */
|
|
8508
|
+
function parseConsumableSubject(subject) {
|
|
8509
|
+
const first = subject.indexOf(":");
|
|
8510
|
+
const second = first < 0 ? -1 : subject.indexOf(":", first + 1);
|
|
8511
|
+
if (first < 0 || second < 0) return null;
|
|
8512
|
+
const family = subject.slice(0, first);
|
|
8513
|
+
const deviceId = Number(subject.slice(first + 1, second));
|
|
8514
|
+
const key = subject.slice(second + 1);
|
|
8515
|
+
if (!Number.isInteger(deviceId) || key.length === 0) return null;
|
|
8516
|
+
if (family === FAMILY) return {
|
|
8517
|
+
thresholdPercent: 15,
|
|
8518
|
+
deviceId,
|
|
8519
|
+
key,
|
|
8520
|
+
legacy: true
|
|
8521
|
+
};
|
|
8522
|
+
if (!family.startsWith(`${FAMILY}@`)) return null;
|
|
8523
|
+
const thresholdPercent = Number(family.slice(11));
|
|
8524
|
+
if (!Number.isInteger(thresholdPercent)) return null;
|
|
8525
|
+
return {
|
|
8526
|
+
thresholdPercent,
|
|
8527
|
+
deviceId,
|
|
8528
|
+
key,
|
|
8529
|
+
legacy: false
|
|
8530
|
+
};
|
|
8531
|
+
}
|
|
8532
|
+
/**
|
|
8533
|
+
* Every consumable threshold the rule set asks for, ascending.
|
|
8534
|
+
*
|
|
8535
|
+
* A rule asks when it is a system-event rule whose kinds include a consumable
|
|
8536
|
+
* kind — enabled or not: a disabled rule's episode stays PENDING (no rule
|
|
8537
|
+
* matched it) and is re-offered the moment the rule is switched back on
|
|
8538
|
+
* (D636), which is what that rule would have said had it been on. With no such
|
|
8539
|
+
* rule at all the default is still judged, so an episode exists for the first
|
|
8540
|
+
* rule an operator writes to hear (D636's "offered once per rule change").
|
|
8541
|
+
*/
|
|
8542
|
+
function consumableThresholdCensus(rules) {
|
|
8543
|
+
const thresholds = /* @__PURE__ */ new Set();
|
|
8544
|
+
for (const rule of rules) {
|
|
8545
|
+
const condition = rule.conditions.systemEvent;
|
|
8546
|
+
if (rule.delivery !== "system-event" || condition === void 0) continue;
|
|
8547
|
+
if (!condition.kinds.some((kind) => require_dist.NC_CONSUMABLE_SYSTEM_EVENT_KINDS.includes(kind))) continue;
|
|
8548
|
+
thresholds.add(require_dist.consumableLowPercentOf(condition));
|
|
8549
|
+
}
|
|
8550
|
+
if (thresholds.size === 0) thresholds.add(15);
|
|
8551
|
+
return [...thresholds].toSorted((a, b) => a - b);
|
|
8552
|
+
}
|
|
8439
8553
|
function deriveConsumableBand(input) {
|
|
8440
8554
|
const { level, status } = input.reading;
|
|
8441
8555
|
const measured = level !== null && Number.isFinite(level);
|
|
@@ -9563,6 +9677,7 @@ function isLinkedSetSubject(subject) {
|
|
|
9563
9677
|
function subjectFromSensorEvent(ev, markerTrackId, linked) {
|
|
9564
9678
|
const eventType = readSensorEventType(ev.value);
|
|
9565
9679
|
const sensorEdge = sensorEdgeOf(ev.kind, ev.value);
|
|
9680
|
+
const sensorRaised = require_dist.sensorRaisedOf(ev.kind, ev.value);
|
|
9566
9681
|
return {
|
|
9567
9682
|
kind: "device-event",
|
|
9568
9683
|
recordId: String(ev.id),
|
|
@@ -9576,6 +9691,7 @@ function subjectFromSensorEvent(ev, markerTrackId, linked) {
|
|
|
9576
9691
|
sensorKind: ev.kind,
|
|
9577
9692
|
...eventType !== void 0 ? { eventType } : {},
|
|
9578
9693
|
...sensorEdge !== void 0 ? { sensorEdge } : {},
|
|
9694
|
+
...sensorRaised !== void 0 ? { sensorRaised } : {},
|
|
9579
9695
|
...linked !== void 0 ? {
|
|
9580
9696
|
linkedSensorId: ev.sourceDeviceId,
|
|
9581
9697
|
linkedCameraIds: [...linked.linkedCameraIds],
|
|
@@ -9803,6 +9919,7 @@ function presentConditionIds(c) {
|
|
|
9803
9919
|
const ids = [];
|
|
9804
9920
|
if (c.systemEvent !== void 0) ids.push("systemEvent");
|
|
9805
9921
|
if (c.devices !== void 0 && c.devices.length > 0) ids.push("devices");
|
|
9922
|
+
if (c.devicesExclude !== void 0 && c.devicesExclude.length > 0) ids.push("devicesExclude");
|
|
9806
9923
|
if (c.source !== void 0 && c.source !== "any") ids.push("source");
|
|
9807
9924
|
if (c.classes !== void 0 && c.classes.length > 0) ids.push("classes");
|
|
9808
9925
|
if (c.classesExclude !== void 0 && c.classesExclude.length > 0) ids.push("classesExclude");
|
|
@@ -9819,6 +9936,7 @@ function presentConditionIds(c) {
|
|
|
9819
9936
|
if (c.plates !== void 0) ids.push("plates");
|
|
9820
9937
|
if (c.sensorKinds !== void 0 && c.sensorKinds.length > 0) ids.push("sensorKinds");
|
|
9821
9938
|
if (c.eventTypeTokens !== void 0 && c.eventTypeTokens.length > 0) ids.push("eventTypeTokens");
|
|
9939
|
+
if (c.sensorTransition !== void 0) ids.push("sensorTransition");
|
|
9822
9940
|
if (c.packagePhase !== void 0 && c.packagePhase !== "both") ids.push("packagePhase");
|
|
9823
9941
|
if (c.customZones !== void 0 && c.customZones.length > 0) ids.push("customZones");
|
|
9824
9942
|
if (c.occupancy !== void 0) ids.push("occupancy");
|
|
@@ -9913,12 +10031,23 @@ function zonesVisitedBy(subject) {
|
|
|
9913
10031
|
* before the subject exists (the linked-camera capture gate). Coarse in one
|
|
9914
10032
|
* direction only: `true` may still fail later; `false` never would have fired.
|
|
9915
10033
|
*/
|
|
9916
|
-
function deviceEventRuleMayFireOnKind(rule, sensorKind) {
|
|
10034
|
+
function deviceEventRuleMayFireOnKind(rule, sensorKind, sensorRaised) {
|
|
9917
10035
|
const c = rule.conditions;
|
|
9918
10036
|
if (c.occupancy !== void 0) return false;
|
|
10037
|
+
if (sensorTransitionRefuses(c.sensorTransition, sensorRaised)) return false;
|
|
9919
10038
|
if (c.sensorKinds === void 0 || c.sensorKinds.length === 0) return true;
|
|
9920
10039
|
return toLowerSet(c.sensorKinds).has(sensorKind.trim().toLowerCase());
|
|
9921
10040
|
}
|
|
10041
|
+
/**
|
|
10042
|
+
* Does a rule's `sensorTransition` refuse this edge? Only a KNOWN edge can be
|
|
10043
|
+
* refused: `sensorRaised === undefined` (a kind with no raise, an unreadable
|
|
10044
|
+
* slice) always passes — a flood rule never goes silent on a bad read (D640).
|
|
10045
|
+
* The ONE predicate {@link evaluateRule} and the capture gate both ask.
|
|
10046
|
+
*/
|
|
10047
|
+
function sensorTransitionRefuses(transition, sensorRaised) {
|
|
10048
|
+
if (transition === void 0 || sensorRaised === void 0) return false;
|
|
10049
|
+
return sensorRaised !== (transition === "raised");
|
|
10050
|
+
}
|
|
9922
10051
|
function toLowerSet(values) {
|
|
9923
10052
|
return new Set(values.map((v) => v.trim().toLowerCase()));
|
|
9924
10053
|
}
|
|
@@ -9955,9 +10084,6 @@ function matchesIdentityScope(subject, scope) {
|
|
|
9955
10084
|
function ruleAwaitsRecognition(rule) {
|
|
9956
10085
|
return rule.conditions.identities !== void 0 || rule.conditions.plates !== void 0;
|
|
9957
10086
|
}
|
|
9958
|
-
var ARMED_WORD_PREFIX = "armed_";
|
|
9959
|
-
var ALARM_PANEL_CAP$1 = "alarm-panel";
|
|
9960
|
-
var ALARM_PANEL_TRIGGER_METHOD = "trigger";
|
|
9961
10087
|
/**
|
|
9962
10088
|
* A rule "belongs to the alarm" when its `deviceState` gate names ANY device's
|
|
9963
10089
|
* `armed_*` word — CamStack's own panel or any other, e.g. an HA-mirrored
|
|
@@ -9975,8 +10101,10 @@ var ALARM_PANEL_TRIGGER_METHOD = "trigger";
|
|
|
9975
10101
|
* catalog on save, so a new key would be dropped by the first edit.
|
|
9976
10102
|
*/
|
|
9977
10103
|
function ruleBelongsToAlarm(rule) {
|
|
9978
|
-
|
|
9979
|
-
|
|
10104
|
+
return require_dist.ruleBelongsToAlarm({
|
|
10105
|
+
conditions: rule.conditions,
|
|
10106
|
+
...rule.actions?.onTrigger !== void 0 ? { onTrigger: rule.actions.onTrigger } : {}
|
|
10107
|
+
});
|
|
9980
10108
|
}
|
|
9981
10109
|
/**
|
|
9982
10110
|
* The edge an alarm rule refuses: a close, or a halt/fault (a cover stopped
|
|
@@ -10085,6 +10213,9 @@ function systemEventExclusion(condition, subject) {
|
|
|
10085
10213
|
if (wantedPackages !== void 0 && applies("packageNames")) {
|
|
10086
10214
|
if (!subjectPackages(subject).some((name) => wantedPackages.includes(name))) return "packageNames";
|
|
10087
10215
|
}
|
|
10216
|
+
if (applies("consumableLowPercent") && subject.consumableLowPercent !== void 0) {
|
|
10217
|
+
if (subject.consumableLowPercent !== require_dist.consumableLowPercentOf(condition)) return "consumableLowPercent";
|
|
10218
|
+
}
|
|
10088
10219
|
}
|
|
10089
10220
|
function subjectNodes(subject) {
|
|
10090
10221
|
const ids = [...subject.nodeIds ?? []];
|
|
@@ -10152,6 +10283,7 @@ function evaluateRule(rule, subject, deviceState, zoneOwners, sceneStates) {
|
|
|
10152
10283
|
if (!sceneConditionSatisfied(c.scene, observation)) return fail("scene");
|
|
10153
10284
|
}
|
|
10154
10285
|
if (c.devices !== void 0 && c.devices.length > 0 && !matchesDeviceScope(c.devices, subject)) return fail("devices");
|
|
10286
|
+
if (c.devicesExclude !== void 0 && deviceScopeExcludes(c.devicesExclude, subject)) return fail("devicesExclude");
|
|
10155
10287
|
if (c.source !== void 0 && c.source !== "any") {
|
|
10156
10288
|
if ((subject.source ?? "pipeline") !== c.source) return fail("source");
|
|
10157
10289
|
}
|
|
@@ -10228,6 +10360,7 @@ function evaluateRule(rule, subject, deviceState, zoneOwners, sceneStates) {
|
|
|
10228
10360
|
if (c.eventTypeTokens !== void 0 && c.eventTypeTokens.length > 0) {
|
|
10229
10361
|
if (subject.eventType === void 0 || !toLowerSet(c.eventTypeTokens).has(subject.eventType.trim().toLowerCase())) return fail("eventTypeTokens");
|
|
10230
10362
|
}
|
|
10363
|
+
if (sensorTransitionRefuses(c.sensorTransition, subject.sensorRaised)) return fail("sensorTransition");
|
|
10231
10364
|
if (c.packagePhase !== void 0 && c.packagePhase !== "both") {
|
|
10232
10365
|
if (subject.packagePhase !== c.packagePhase) return fail("packagePhase");
|
|
10233
10366
|
}
|
|
@@ -10333,6 +10466,27 @@ function matchesDeviceScope(scope, subject) {
|
|
|
10333
10466
|
if (scope.includes(subject.deviceId)) return true;
|
|
10334
10467
|
return subject.sourceDeviceId !== void 0 && scope.includes(subject.sourceDeviceId);
|
|
10335
10468
|
}
|
|
10469
|
+
/**
|
|
10470
|
+
* Does a rule's `devicesExclude` (D641) exclude this subject?
|
|
10471
|
+
*
|
|
10472
|
+
* The exact complement of {@link matchesDeviceScope}, row by row. A per-camera
|
|
10473
|
+
* row is excluded by its camera OR by the sensor that fired, the two ids an
|
|
10474
|
+
* include matches on. A linked set stands for the rows it replaced (D630 §6):
|
|
10475
|
+
* it is excluded when its SENSOR is, or when EVERY camera that took a row is —
|
|
10476
|
+
* one excluded row camera leaves the others firing, as their own rows would
|
|
10477
|
+
* have. A camera that only LINKS the sensor excludes nothing, for the reason it
|
|
10478
|
+
* includes nothing: ADR 0632 widens the media, never which rules fire.
|
|
10479
|
+
*/
|
|
10480
|
+
function deviceScopeExcludes(excluded, subject) {
|
|
10481
|
+
if (excluded.length === 0) return false;
|
|
10482
|
+
if (subject.linkedSensorId !== void 0) {
|
|
10483
|
+
if (excluded.includes(subject.linkedSensorId)) return true;
|
|
10484
|
+
const rows = subject.rowCameraIds ?? [];
|
|
10485
|
+
return rows.length > 0 && rows.every((cameraId) => excluded.includes(cameraId));
|
|
10486
|
+
}
|
|
10487
|
+
if (excluded.includes(subject.deviceId)) return true;
|
|
10488
|
+
return subject.sourceDeviceId !== void 0 && excluded.includes(subject.sourceDeviceId);
|
|
10489
|
+
}
|
|
10336
10490
|
function cooldownKey(rule, subject) {
|
|
10337
10491
|
const first = subject.classNames[0];
|
|
10338
10492
|
const classKey = keysPerClass(rule, subject) && first !== void 0 ? `:c:${first}` : "";
|
|
@@ -10941,6 +11095,7 @@ function systemSubject(input) {
|
|
|
10941
11095
|
...input.packageNames !== void 0 ? { packageNames: input.packageNames } : {},
|
|
10942
11096
|
...input.textCount !== void 0 ? { textCount: input.textCount } : {},
|
|
10943
11097
|
...input.alarmSource !== void 0 ? { alarmSource: input.alarmSource } : {},
|
|
11098
|
+
...input.consumableLowPercent !== void 0 ? { consumableLowPercent: input.consumableLowPercent } : {},
|
|
10944
11099
|
data
|
|
10945
11100
|
}
|
|
10946
11101
|
},
|
|
@@ -11042,9 +11197,11 @@ function incomingFromBatteryBand(input) {
|
|
|
11042
11197
|
* these to be treated as the battery notifications are — with two differences
|
|
11043
11198
|
* that come from the subject and not from taste:
|
|
11044
11199
|
*
|
|
11045
|
-
* - **the subject carries the ITEM.** `consumable
|
|
11046
|
-
* vacuum has a brush AND a filter AND a mop pad, and one subject
|
|
11047
|
-
* three would let the brush's alert cancel the filter's.
|
|
11200
|
+
* - **the subject carries the ITEM.** `consumable@<threshold>:<deviceId>:<key>`,
|
|
11201
|
+
* because a vacuum has a brush AND a filter AND a mop pad, and one subject
|
|
11202
|
+
* for all three would let the brush's alert cancel the filter's. It carries
|
|
11203
|
+
* the THRESHOLD too (D642): each rule's threshold is its own episode, and
|
|
11204
|
+
* the engine pairs a rule with the episode judged at its own number.
|
|
11048
11205
|
* - **the body variant follows what is KNOWN.** An item with no percentage
|
|
11049
11206
|
* gets a finished sentence rather than a template with a stand-in number in
|
|
11050
11207
|
* it, for the same reason a binary battery does.
|
|
@@ -11053,17 +11210,46 @@ function incomingFromBatteryBand(input) {
|
|
|
11053
11210
|
*/
|
|
11054
11211
|
function incomingFromConsumableBand(input) {
|
|
11055
11212
|
const kind = input.band === "low" ? "device-consumable-low" : "device-consumable-normal";
|
|
11056
|
-
const
|
|
11213
|
+
const level = input.item.level;
|
|
11214
|
+
const percentage = level !== null && Number.isFinite(level) ? Math.round(level) : null;
|
|
11215
|
+
const measured = percentage !== null;
|
|
11057
11216
|
return systemSubject({
|
|
11058
11217
|
facts: input.facts,
|
|
11059
11218
|
kind,
|
|
11060
|
-
subject:
|
|
11219
|
+
subject: consumableSubject(input.thresholdPercent, input.deviceId, input.item.key),
|
|
11061
11220
|
titleKey: `system.${kind}.title`,
|
|
11062
11221
|
bodyKey: measured ? `system.${kind}.body` : `system.${kind}.body.unmeasured`,
|
|
11063
11222
|
textParams: {
|
|
11064
11223
|
...deviceTextParams(input.deviceId, input.identity),
|
|
11065
11224
|
item: input.item.label,
|
|
11066
|
-
...
|
|
11225
|
+
...percentage !== null ? { percentage: String(percentage) } : {}
|
|
11226
|
+
},
|
|
11227
|
+
deviceId: input.deviceId,
|
|
11228
|
+
consumableLowPercent: input.thresholdPercent,
|
|
11229
|
+
...input.identity !== void 0 ? { deviceType: input.identity.type } : {}
|
|
11230
|
+
});
|
|
11231
|
+
}
|
|
11232
|
+
/**
|
|
11233
|
+
* A vacuum TANK band, as a system-event subject (D642).
|
|
11234
|
+
*
|
|
11235
|
+
* Built like {@link incomingFromConsumableBand}: `tank:<deviceId>:<tank>` is
|
|
11236
|
+
* one subject per tank (the dust bin's alert must not cancel the clean water's),
|
|
11237
|
+
* and the recovery keeps it. The BODY VARIANT names the tank in the reader's
|
|
11238
|
+
* language — the params are frozen before the language is known, so the tank's
|
|
11239
|
+
* name cannot be a param; `tank` carries the stable token for a custom
|
|
11240
|
+
* template.
|
|
11241
|
+
*/
|
|
11242
|
+
function incomingFromTankBand(input) {
|
|
11243
|
+
const kind = input.band === "low" ? "device-tank-low" : input.band === "full" ? "device-tank-full" : "device-tank-normal";
|
|
11244
|
+
return systemSubject({
|
|
11245
|
+
facts: input.facts,
|
|
11246
|
+
kind,
|
|
11247
|
+
subject: `tank:${String(input.deviceId)}:${input.tank}`,
|
|
11248
|
+
titleKey: `system.${kind}.title`,
|
|
11249
|
+
bodyKey: `system.${kind}.body.${input.tank}`,
|
|
11250
|
+
textParams: {
|
|
11251
|
+
...deviceTextParams(input.deviceId, input.identity),
|
|
11252
|
+
tank: input.tank
|
|
11067
11253
|
},
|
|
11068
11254
|
deviceId: input.deviceId,
|
|
11069
11255
|
...input.identity !== void 0 ? { deviceType: input.identity.type } : {}
|
|
@@ -11651,6 +11837,91 @@ function deliveryForKind(kind) {
|
|
|
11651
11837
|
return kind;
|
|
11652
11838
|
}
|
|
11653
11839
|
//#endregion
|
|
11840
|
+
//#region src/notification-center/alerts/tank-band.ts
|
|
11841
|
+
/**
|
|
11842
|
+
* A vacuum TANK reduced to the only thing worth storing: its band (D642).
|
|
11843
|
+
*
|
|
11844
|
+
* The `vacuum-control` slice carries four tanks — clean water, dirty water,
|
|
11845
|
+
* detergent, dust bin — each `null` when the hardware has none, else
|
|
11846
|
+
* `{ level, status }`. The operator asked for them to notify like consumables:
|
|
11847
|
+
* clean water / detergent running low, dirty water / dust bin full.
|
|
11848
|
+
*
|
|
11849
|
+
* ## The verdict is the provider's
|
|
11850
|
+
*
|
|
11851
|
+
* A tank's `status` is the device's own float switch (`ok` | `low` | `full`),
|
|
11852
|
+
* and it is the ONLY thing judged. The hub has no threshold for a tank level:
|
|
11853
|
+
* the rule for thresholds is that they belong to whoever generates the event,
|
|
11854
|
+
* and a tank that says `low` has already decided. A tank that reports only a
|
|
11855
|
+
* `level` is therefore not judged at all (`null`) rather than judged against a
|
|
11856
|
+
* number the hub would have to invent.
|
|
11857
|
+
*
|
|
11858
|
+
* ## `null` is the third answer, and it is not `normal`
|
|
11859
|
+
*
|
|
11860
|
+
* An unknown status says nothing — it neither opens nor closes an episode, for
|
|
11861
|
+
* the reason `battery-band.ts` gives: a reading nobody took is not evidence
|
|
11862
|
+
* that anything changed.
|
|
11863
|
+
*
|
|
11864
|
+
* Pure. No clock, no I/O.
|
|
11865
|
+
*/
|
|
11866
|
+
/** The four tanks `vacuum-control` names, by their slice field. */
|
|
11867
|
+
var NC_TANK_KEYS = [
|
|
11868
|
+
"cleanWater",
|
|
11869
|
+
"dirtyWater",
|
|
11870
|
+
"detergent",
|
|
11871
|
+
"dustBin"
|
|
11872
|
+
];
|
|
11873
|
+
/**
|
|
11874
|
+
* How long a tank change must HOLD before it counts (D642 review M2).
|
|
11875
|
+
*
|
|
11876
|
+
* A float switch sloshing at the edge of `low` / `full` flips its status back
|
|
11877
|
+
* and forth while the robot moves; each flip is a durable transition, and
|
|
11878
|
+
* without a hold each one is a raise and a recovery on the operator's phone.
|
|
11879
|
+
* One minute is two reload ticks: long enough to outlast a slosh, short enough
|
|
11880
|
+
* that a genuinely full dust bin is said within a minute and a half.
|
|
11881
|
+
*/
|
|
11882
|
+
var NC_TANK_HOLD_MS = 6e4;
|
|
11883
|
+
function deriveTankBand(reading) {
|
|
11884
|
+
switch (reading.status) {
|
|
11885
|
+
case "low": return "low";
|
|
11886
|
+
case "full": return "full";
|
|
11887
|
+
case "ok": return "normal";
|
|
11888
|
+
case null: return null;
|
|
11889
|
+
}
|
|
11890
|
+
}
|
|
11891
|
+
function isRecord$4(value) {
|
|
11892
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
11893
|
+
}
|
|
11894
|
+
function tankStatusOf(value) {
|
|
11895
|
+
return value === "ok" || value === "low" || value === "full" ? value : null;
|
|
11896
|
+
}
|
|
11897
|
+
/**
|
|
11898
|
+
* A `device.state-changed` payload carrying a `vacuum-control` slice, reduced
|
|
11899
|
+
* to its tanks. `null` for any other cap. A tank the hardware does not have
|
|
11900
|
+
* (`null`) is skipped; a malformed field reads as unknown, never as a
|
|
11901
|
+
* fabricated verdict (D393: absent is not zero).
|
|
11902
|
+
*/
|
|
11903
|
+
function vacuumTanksOf(data) {
|
|
11904
|
+
if (data["capName"] !== "vacuum-control") return null;
|
|
11905
|
+
const deviceId = data["deviceId"];
|
|
11906
|
+
const slice = data["slice"];
|
|
11907
|
+
if (typeof deviceId !== "number" || !isRecord$4(slice)) return null;
|
|
11908
|
+
const tanks = [];
|
|
11909
|
+
for (const key of NC_TANK_KEYS) {
|
|
11910
|
+
const raw = slice[key];
|
|
11911
|
+
if (!isRecord$4(raw)) continue;
|
|
11912
|
+
const level = raw["level"];
|
|
11913
|
+
tanks.push({
|
|
11914
|
+
key,
|
|
11915
|
+
level: typeof level === "number" && Number.isFinite(level) ? level : null,
|
|
11916
|
+
status: tankStatusOf(raw["status"])
|
|
11917
|
+
});
|
|
11918
|
+
}
|
|
11919
|
+
return tanks.length === 0 ? null : {
|
|
11920
|
+
deviceId,
|
|
11921
|
+
tanks
|
|
11922
|
+
};
|
|
11923
|
+
}
|
|
11924
|
+
//#endregion
|
|
11654
11925
|
//#region src/notification-center/alerts/battery-band.ts
|
|
11655
11926
|
/**
|
|
11656
11927
|
* The stand-in a provider writes for a binary NORMAL. Anything below it, on a
|
|
@@ -11681,10 +11952,14 @@ function deriveBatteryBand(input) {
|
|
|
11681
11952
|
* `NcDeviceAlerts` — the one place a SUSTAINED device fault becomes a
|
|
11682
11953
|
* notification, and keeps becoming one until it stops being true.
|
|
11683
11954
|
*
|
|
11684
|
-
* It owns
|
|
11955
|
+
* It owns these families and nothing else:
|
|
11685
11956
|
*
|
|
11686
11957
|
* - **battery band** — `battery:<deviceId>`, fed by the `battery` runtime-state
|
|
11687
11958
|
* slice that already arrives on `device.state-changed`;
|
|
11959
|
+
* - **consumable band** — `consumable@<threshold>:<deviceId>:<key>`, one
|
|
11960
|
+
* episode per item per threshold a RULE asks for (D642);
|
|
11961
|
+
* - **tank band** — `tank:<deviceId>:<tank>`, a vacuum's tanks, judged on the
|
|
11962
|
+
* provider's own verdict (D642);
|
|
11688
11963
|
* - **the reminder tick** — the operator's "how often do you say it again".
|
|
11689
11964
|
*
|
|
11690
11965
|
* It does NOT own device liveness. `device-offline` already has a producer, a
|
|
@@ -11724,6 +11999,10 @@ var PENDING_CADENCE_MAX_MS = 36e5;
|
|
|
11724
11999
|
function pendingCadenceMs(settings) {
|
|
11725
12000
|
return settings.renotifyMinutes > 0 ? Math.min(settings.renotifyMinutes * 6e4, PENDING_CADENCE_MAX_MS) : PENDING_CADENCE_MAX_MS;
|
|
11726
12001
|
}
|
|
12002
|
+
/** The RAM key of one item's last reading — every threshold's episode reads it. */
|
|
12003
|
+
function itemKeyOf(deviceId, key) {
|
|
12004
|
+
return `${String(deviceId)}:${key}`;
|
|
12005
|
+
}
|
|
11727
12006
|
var NcDeviceAlerts = class {
|
|
11728
12007
|
deps;
|
|
11729
12008
|
now;
|
|
@@ -11735,8 +12014,28 @@ var NcDeviceAlerts = class {
|
|
|
11735
12014
|
* durable.
|
|
11736
12015
|
*/
|
|
11737
12016
|
lastReading = /* @__PURE__ */ new Map();
|
|
11738
|
-
/**
|
|
12017
|
+
/**
|
|
12018
|
+
* The last reading per consumable ITEM (`<deviceId>:<key>`), for the
|
|
12019
|
+
* reminder sweep and for judging a threshold a rule gains later (D642).
|
|
12020
|
+
*/
|
|
11739
12021
|
lastConsumable = /* @__PURE__ */ new Map();
|
|
12022
|
+
/** The last reading per tank SUBJECT, for the reminder sweep. */
|
|
12023
|
+
lastTank = /* @__PURE__ */ new Map();
|
|
12024
|
+
/**
|
|
12025
|
+
* Tank changes on HOLD (review M2): a band that would move the episode,
|
|
12026
|
+
* first seen at `since`. It counts once it has held for `NC_TANK_HOLD_MS`,
|
|
12027
|
+
* confirmed by a later slice write or by the reload tick — no timer of its
|
|
12028
|
+
* own, so nothing outlives the device. Bounded by the device's tanks (four),
|
|
12029
|
+
* and dropped with the device by {@link forget}.
|
|
12030
|
+
*/
|
|
12031
|
+
tankHold = /* @__PURE__ */ new Map();
|
|
12032
|
+
/**
|
|
12033
|
+
* Thresholds some episode row carries that the LAST census did not ask for.
|
|
12034
|
+
* A threshold's rows are retired only when two consecutive censuses agree it
|
|
12035
|
+
* is gone (D49: a read that would destroy work needs a second read to
|
|
12036
|
+
* agree). RAM only: a restart costs one more tick of patience.
|
|
12037
|
+
*/
|
|
12038
|
+
absentThresholds = /* @__PURE__ */ new Set();
|
|
11740
12039
|
/**
|
|
11741
12040
|
* Subject → WHEN its PENDING raise was last offered, and to which
|
|
11742
12041
|
* {@link NcDeviceAlertsDeps.rulesVersion}. RAM only, on purpose: after a
|
|
@@ -11794,31 +12093,137 @@ var NcDeviceAlerts = class {
|
|
|
11794
12093
|
* observed.
|
|
11795
12094
|
*/
|
|
11796
12095
|
observeConsumable(deviceId, item) {
|
|
11797
|
-
|
|
11798
|
-
|
|
12096
|
+
this.lastConsumable.set(itemKeyOf(deviceId, item.key), {
|
|
12097
|
+
deviceId,
|
|
12098
|
+
item
|
|
12099
|
+
});
|
|
12100
|
+
const thresholds = this.deps.consumableThresholds();
|
|
12101
|
+
if (thresholds === null) {
|
|
12102
|
+
this.logger()?.debug("consumable reading kept — the rules have not been read yet", {
|
|
12103
|
+
tags: { deviceId },
|
|
12104
|
+
meta: { item: item.key }
|
|
12105
|
+
});
|
|
12106
|
+
return;
|
|
12107
|
+
}
|
|
11799
12108
|
const settings = this.deps.settings();
|
|
12109
|
+
for (const thresholdPercent of thresholds) this.judgeConsumable(deviceId, item, thresholdPercent, settings);
|
|
12110
|
+
}
|
|
12111
|
+
/**
|
|
12112
|
+
* ONE vacuum tank, from a device's `vacuum-control` slice (D642).
|
|
12113
|
+
*
|
|
12114
|
+
* The provider's verdict is the band (`tank-band.ts`); an unknown status
|
|
12115
|
+
* leaves everything untouched, an open episode included.
|
|
12116
|
+
*/
|
|
12117
|
+
observeTank(deviceId, tank) {
|
|
12118
|
+
const band = deriveTankBand(tank);
|
|
12119
|
+
if (band === null) return;
|
|
12120
|
+
const subject = `tank:${String(deviceId)}:${tank.key}`;
|
|
12121
|
+
this.lastTank.set(subject, {
|
|
12122
|
+
deviceId,
|
|
12123
|
+
tank
|
|
12124
|
+
});
|
|
12125
|
+
if (!this.tankChangeHeld(subject, deviceId, band)) return;
|
|
12126
|
+
this.apply(subject, band !== "normal", this.deps.settings(), (facts, alerting) => incomingFromTankBand({
|
|
12127
|
+
facts,
|
|
12128
|
+
deviceId,
|
|
12129
|
+
band: alerting ? band : "normal",
|
|
12130
|
+
tank: tank.key,
|
|
12131
|
+
identity: this.deps.lookup(deviceId)
|
|
12132
|
+
}));
|
|
12133
|
+
}
|
|
12134
|
+
/**
|
|
12135
|
+
* Has this tank band held long enough to move its episode? A band that
|
|
12136
|
+
* would not move it (a repeat, or low ⇄ full inside one open episode) passes
|
|
12137
|
+
* at once and clears any hold — that is what makes a flapping switch start
|
|
12138
|
+
* its hold over every time it flips back. A first HEALTHY observation passes
|
|
12139
|
+
* too: it seeds silently, so there is nothing to hold.
|
|
12140
|
+
*/
|
|
12141
|
+
tankChangeHeld(subject, deviceId, band) {
|
|
12142
|
+
const row = this.deps.rows.get(subject);
|
|
12143
|
+
const alerting = band !== "normal";
|
|
12144
|
+
if (!(row === void 0 ? alerting : row.alerting !== alerting)) {
|
|
12145
|
+
this.tankHold.delete(subject);
|
|
12146
|
+
return true;
|
|
12147
|
+
}
|
|
12148
|
+
const nowMs = this.now();
|
|
12149
|
+
const held = this.tankHold.get(subject);
|
|
12150
|
+
if (held === void 0 || held.band !== band) {
|
|
12151
|
+
this.tankHold.set(subject, {
|
|
12152
|
+
band,
|
|
12153
|
+
since: nowMs
|
|
12154
|
+
});
|
|
12155
|
+
this.logger()?.debug("tank change on hold — counts once it has held", {
|
|
12156
|
+
tags: { deviceId },
|
|
12157
|
+
meta: {
|
|
12158
|
+
subject,
|
|
12159
|
+
band,
|
|
12160
|
+
holdMs: NC_TANK_HOLD_MS
|
|
12161
|
+
}
|
|
12162
|
+
});
|
|
12163
|
+
return false;
|
|
12164
|
+
}
|
|
12165
|
+
if (nowMs - held.since < 6e4) return false;
|
|
12166
|
+
this.tankHold.delete(subject);
|
|
12167
|
+
return true;
|
|
12168
|
+
}
|
|
12169
|
+
/** The tick's half of the hold: a change that has held with no new slice write. */
|
|
12170
|
+
confirmHeldTanks() {
|
|
12171
|
+
const nowMs = this.now();
|
|
12172
|
+
for (const [subject, held] of [...this.tankHold]) {
|
|
12173
|
+
if (nowMs - held.since < 6e4) continue;
|
|
12174
|
+
const reading = this.lastTank.get(subject);
|
|
12175
|
+
if (reading === void 0) {
|
|
12176
|
+
this.tankHold.delete(subject);
|
|
12177
|
+
continue;
|
|
12178
|
+
}
|
|
12179
|
+
this.observeTank(reading.deviceId, reading.tank);
|
|
12180
|
+
}
|
|
12181
|
+
}
|
|
12182
|
+
/** One item against ONE threshold: its own episode, its own hysteresis. */
|
|
12183
|
+
judgeConsumable(deviceId, item, thresholdPercent, settings) {
|
|
12184
|
+
const subject = consumableSubject(thresholdPercent, deviceId, item.key);
|
|
12185
|
+
const previous = this.deps.rows.get(subject) ?? this.adoptLegacy(subject, deviceId, item.key);
|
|
11800
12186
|
const band = deriveConsumableBand({
|
|
11801
12187
|
reading: {
|
|
11802
12188
|
level: item.level,
|
|
11803
12189
|
status: item.status
|
|
11804
12190
|
},
|
|
11805
|
-
thresholdPercent
|
|
12191
|
+
thresholdPercent,
|
|
11806
12192
|
previous: previous?.alerting === true ? "low" : previous === void 0 ? void 0 : "normal"
|
|
11807
12193
|
});
|
|
11808
12194
|
if (band === null) return;
|
|
11809
|
-
this.lastConsumable.set(subject, {
|
|
11810
|
-
deviceId,
|
|
11811
|
-
item
|
|
11812
|
-
});
|
|
11813
12195
|
this.apply(subject, band === "low", settings, (facts, alerting) => incomingFromConsumableBand({
|
|
11814
12196
|
facts,
|
|
11815
12197
|
deviceId,
|
|
11816
12198
|
band: alerting ? "low" : "normal",
|
|
11817
12199
|
item,
|
|
12200
|
+
thresholdPercent,
|
|
11818
12201
|
identity: this.deps.lookup(deviceId)
|
|
11819
12202
|
}));
|
|
11820
12203
|
}
|
|
11821
12204
|
/**
|
|
12205
|
+
* A pre-D642 row of this item (`consumable:<id>:<key>`, judged at the
|
|
12206
|
+
* cluster threshold, which shipped as the rule default) is carried over to
|
|
12207
|
+
* the default-threshold episode rather than re-announced: the live vacuum
|
|
12208
|
+
* had two items low and reminding when D642 shipped. Any other threshold
|
|
12209
|
+
* starts fresh; an unadopted legacy row retires with the census.
|
|
12210
|
+
*/
|
|
12211
|
+
adoptLegacy(subject, deviceId, key) {
|
|
12212
|
+
const parts = parseConsumableSubject(subject);
|
|
12213
|
+
const legacySubject = legacyConsumableSubject(deviceId, key);
|
|
12214
|
+
const legacy = this.deps.rows.get(legacySubject);
|
|
12215
|
+
if (legacy === void 0 || parts === null) return void 0;
|
|
12216
|
+
const legacyParts = parseConsumableSubject(legacySubject);
|
|
12217
|
+
if (legacyParts === null || legacyParts.thresholdPercent !== parts.thresholdPercent) return;
|
|
12218
|
+
const adopted = {
|
|
12219
|
+
...legacy,
|
|
12220
|
+
subject
|
|
12221
|
+
};
|
|
12222
|
+
this.deps.rows.put(adopted);
|
|
12223
|
+
this.dropRow(legacySubject, "carried over to its threshold episode (D642)");
|
|
12224
|
+
return adopted;
|
|
12225
|
+
}
|
|
12226
|
+
/**
|
|
11822
12227
|
* The reminder sweep. Called from an existing periodic tick; it allocates
|
|
11823
12228
|
* nothing on the healthy path.
|
|
11824
12229
|
*
|
|
@@ -11829,6 +12234,9 @@ var NcDeviceAlerts = class {
|
|
|
11829
12234
|
*/
|
|
11830
12235
|
tick() {
|
|
11831
12236
|
const settings = this.deps.settings();
|
|
12237
|
+
const thresholds = this.deps.consumableThresholds();
|
|
12238
|
+
if (thresholds !== null) this.reconcileThresholds(thresholds, settings);
|
|
12239
|
+
this.confirmHeldTanks();
|
|
11832
12240
|
this.retryPending(settings);
|
|
11833
12241
|
if (settings.renotifyMinutes <= 0) return;
|
|
11834
12242
|
for (const row of this.deps.rows.all()) {
|
|
@@ -11861,14 +12269,58 @@ var NcDeviceAlerts = class {
|
|
|
11861
12269
|
const subjects = this.deps.rows.all().map((row) => row.subject).filter((subject) => alertSubjectDeviceId(subject) === deviceId);
|
|
11862
12270
|
for (const subject of subjects) {
|
|
11863
12271
|
this.lastReading.delete(subject);
|
|
11864
|
-
this.
|
|
12272
|
+
this.lastTank.delete(subject);
|
|
11865
12273
|
this.offeredAt.delete(subject);
|
|
11866
12274
|
await this.deps.rows.remove(subject);
|
|
11867
12275
|
}
|
|
11868
12276
|
this.lastReading.delete(`battery:${deviceId}`);
|
|
12277
|
+
for (const [key, reading] of this.lastConsumable) if (reading.deviceId === deviceId) this.lastConsumable.delete(key);
|
|
12278
|
+
for (const subject of this.tankHold.keys()) if (alertSubjectDeviceId(subject) === deviceId) this.tankHold.delete(subject);
|
|
12279
|
+
for (const subject of this.lastTank.keys()) if (alertSubjectDeviceId(subject) === deviceId) this.lastTank.delete(subject);
|
|
11869
12280
|
return subjects.length;
|
|
11870
12281
|
}
|
|
11871
12282
|
/**
|
|
12283
|
+
* Bring the consumable episodes in line with the thresholds the rules ask
|
|
12284
|
+
* for (D642). A threshold with no episode for an item yet is judged from the
|
|
12285
|
+
* item's last reading — a rule that gains a threshold hears about an item
|
|
12286
|
+
* already past it on the next tick, not at the next slice write. A
|
|
12287
|
+
* threshold that no rule asks for any more has its episodes RETIRED, but
|
|
12288
|
+
* only when two consecutive censuses agree it is gone; retiring is silent
|
|
12289
|
+
* (nothing recovered — nobody is listening at that number any more) and
|
|
12290
|
+
* logged per row.
|
|
12291
|
+
*/
|
|
12292
|
+
reconcileThresholds(thresholds, settings) {
|
|
12293
|
+
for (const { deviceId, item } of this.lastConsumable.values()) for (const thresholdPercent of thresholds) {
|
|
12294
|
+
if (this.deps.rows.get(consumableSubject(thresholdPercent, deviceId, item.key))) continue;
|
|
12295
|
+
this.judgeConsumable(deviceId, item, thresholdPercent, settings);
|
|
12296
|
+
}
|
|
12297
|
+
const absent = /* @__PURE__ */ new Set();
|
|
12298
|
+
for (const row of this.deps.rows.all()) {
|
|
12299
|
+
const parts = parseConsumableSubject(row.subject);
|
|
12300
|
+
if (parts === null || thresholds.includes(parts.thresholdPercent)) continue;
|
|
12301
|
+
absent.add(parts.thresholdPercent);
|
|
12302
|
+
if (this.absentThresholds.has(parts.thresholdPercent)) this.dropRow(row.subject, "retired — no rule judges consumables at this threshold (D642)");
|
|
12303
|
+
}
|
|
12304
|
+
this.absentThresholds = absent;
|
|
12305
|
+
}
|
|
12306
|
+
/** Remove one row, mirror and durable, with a line that names why. */
|
|
12307
|
+
dropRow(subject, reason) {
|
|
12308
|
+
this.offeredAt.delete(subject);
|
|
12309
|
+
this.logger()?.info(`device alert ${reason}`, {
|
|
12310
|
+
...tagsOf(subject),
|
|
12311
|
+
meta: { subject }
|
|
12312
|
+
});
|
|
12313
|
+
this.deps.rows.remove(subject).catch((error) => {
|
|
12314
|
+
this.logger()?.warn("device alert row could not be removed", {
|
|
12315
|
+
...tagsOf(subject),
|
|
12316
|
+
meta: {
|
|
12317
|
+
subject,
|
|
12318
|
+
error: error instanceof Error ? error.message : String(error)
|
|
12319
|
+
}
|
|
12320
|
+
});
|
|
12321
|
+
});
|
|
12322
|
+
}
|
|
12323
|
+
/**
|
|
11872
12324
|
* Offer every PENDING raise once more — when the rule set changed since its
|
|
11873
12325
|
* last offer, OR when {@link pendingCadenceMs} has passed since it. The
|
|
11874
12326
|
* cadence is what delivers a raise refused only by a rule's SCHEDULE (or any
|
|
@@ -11900,14 +12352,25 @@ var NcDeviceAlerts = class {
|
|
|
11900
12352
|
}
|
|
11901
12353
|
/** How to re-word a subject's alert from the last reading, or `null`. */
|
|
11902
12354
|
restatementOf(subject) {
|
|
11903
|
-
const
|
|
11904
|
-
|
|
12355
|
+
const parts = parseConsumableSubject(subject);
|
|
12356
|
+
const consumable = parts === null || parts.legacy ? void 0 : this.lastConsumable.get(itemKeyOf(parts.deviceId, parts.key));
|
|
12357
|
+
if (parts !== null && consumable !== void 0) return (facts) => incomingFromConsumableBand({
|
|
11905
12358
|
facts,
|
|
11906
12359
|
deviceId: consumable.deviceId,
|
|
11907
12360
|
band: "low",
|
|
11908
12361
|
item: consumable.item,
|
|
12362
|
+
thresholdPercent: parts.thresholdPercent,
|
|
11909
12363
|
identity: this.deps.lookup(consumable.deviceId)
|
|
11910
12364
|
});
|
|
12365
|
+
const tank = this.lastTank.get(subject);
|
|
12366
|
+
const tankBand = tank === void 0 ? null : deriveTankBand(tank.tank);
|
|
12367
|
+
if (tank !== void 0 && (tankBand === "low" || tankBand === "full")) return (facts) => incomingFromTankBand({
|
|
12368
|
+
facts,
|
|
12369
|
+
deviceId: tank.deviceId,
|
|
12370
|
+
band: tankBand,
|
|
12371
|
+
tank: tank.tank.key,
|
|
12372
|
+
identity: this.deps.lookup(tank.deviceId)
|
|
12373
|
+
});
|
|
11911
12374
|
const battery = this.lastReading.get(subject);
|
|
11912
12375
|
if (battery !== void 0) return (facts) => incomingFromBatteryBand({
|
|
11913
12376
|
facts,
|
|
@@ -12007,11 +12470,9 @@ var NcDeviceAlerts = class {
|
|
|
12007
12470
|
report(subject, verdict, alerting) {
|
|
12008
12471
|
const logger = this.deps.logger;
|
|
12009
12472
|
if (logger === void 0) return;
|
|
12010
|
-
const deviceId = alertSubjectDeviceId(subject);
|
|
12011
|
-
const tags = deviceId !== void 0 ? { deviceId } : {};
|
|
12012
12473
|
if (verdict.kind === "silent" && !alerting) {
|
|
12013
12474
|
logger.debug("device alert cleared without a notification", {
|
|
12014
|
-
|
|
12475
|
+
...tagsOf(subject),
|
|
12015
12476
|
meta: {
|
|
12016
12477
|
subject,
|
|
12017
12478
|
reason: "notifyRecovery is off, or nothing changed"
|
|
@@ -12020,7 +12481,7 @@ var NcDeviceAlerts = class {
|
|
|
12020
12481
|
return;
|
|
12021
12482
|
}
|
|
12022
12483
|
logger.debug("device alert said nothing", {
|
|
12023
|
-
|
|
12484
|
+
...tagsOf(subject),
|
|
12024
12485
|
meta: {
|
|
12025
12486
|
subject,
|
|
12026
12487
|
verdict: verdict.kind
|
|
@@ -12431,6 +12892,21 @@ var en_default = {
|
|
|
12431
12892
|
"system.device-consumable-normal.title": "Consumable replaced",
|
|
12432
12893
|
"system.device-consumable-normal.body": "{{item}} on {{deviceName}} is back to {{percentage}}%",
|
|
12433
12894
|
"system.device-consumable-normal.body.unmeasured": "{{item}} on {{deviceName}} is OK again",
|
|
12895
|
+
"system.device-tank-low.title": "Tank low",
|
|
12896
|
+
"system.device-tank-full.title": "Tank full",
|
|
12897
|
+
"system.device-tank-normal.title": "Tank OK",
|
|
12898
|
+
"system.device-tank-low.body.cleanWater": "The clean water tank on {{deviceName}} is running low",
|
|
12899
|
+
"system.device-tank-full.body.cleanWater": "The clean water tank on {{deviceName}} is full",
|
|
12900
|
+
"system.device-tank-normal.body.cleanWater": "The clean water tank on {{deviceName}} is OK again",
|
|
12901
|
+
"system.device-tank-low.body.dirtyWater": "The dirty water tank on {{deviceName}} is running low",
|
|
12902
|
+
"system.device-tank-full.body.dirtyWater": "The dirty water tank on {{deviceName}} is full",
|
|
12903
|
+
"system.device-tank-normal.body.dirtyWater": "The dirty water tank on {{deviceName}} is OK again",
|
|
12904
|
+
"system.device-tank-low.body.detergent": "The detergent tank on {{deviceName}} is running low",
|
|
12905
|
+
"system.device-tank-full.body.detergent": "The detergent tank on {{deviceName}} is full",
|
|
12906
|
+
"system.device-tank-normal.body.detergent": "The detergent tank on {{deviceName}} is OK again",
|
|
12907
|
+
"system.device-tank-low.body.dustBin": "The dust bin on {{deviceName}} is running low",
|
|
12908
|
+
"system.device-tank-full.body.dustBin": "The dust bin on {{deviceName}} is full",
|
|
12909
|
+
"system.device-tank-normal.body.dustBin": "The dust bin on {{deviceName}} is OK again",
|
|
12434
12910
|
"system.camera-online.title": "Device online",
|
|
12435
12911
|
"system.camera-online.body": "Device {{deviceName}} is online",
|
|
12436
12912
|
"system.camera-offline.title": "Device offline",
|
|
@@ -12460,6 +12936,7 @@ var en_default = {
|
|
|
12460
12936
|
"snooze.scope.anyRule": "A rule",
|
|
12461
12937
|
"snooze.scope.rule": "“{{rule}}”",
|
|
12462
12938
|
"alarm.arm.body.allDevices": "Active: all devices",
|
|
12939
|
+
"alarm.arm.body.allDevicesExcept": "Active: all devices except {{count}}: {{names}}",
|
|
12463
12940
|
"alarm.arm.body.noDevices": "Active: no device restriction",
|
|
12464
12941
|
"alarm.arm.body.devices": "Active: {{names}}",
|
|
12465
12942
|
"alarm.arm.body.devicesSkipped": "Active: {{names}}\nExcluded: {{skippedNames}}",
|
|
@@ -12719,6 +13196,21 @@ var it_default = {
|
|
|
12719
13196
|
"system.device-consumable-normal.title": "Consumabile sostituito",
|
|
12720
13197
|
"system.device-consumable-normal.body": "{{item}} su {{deviceName}} è tornato al {{percentage}}%",
|
|
12721
13198
|
"system.device-consumable-normal.body.unmeasured": "{{item}} su {{deviceName}} è di nuovo a posto",
|
|
13199
|
+
"system.device-tank-low.title": "Serbatoio quasi vuoto",
|
|
13200
|
+
"system.device-tank-full.title": "Serbatoio pieno",
|
|
13201
|
+
"system.device-tank-normal.title": "Serbatoio a posto",
|
|
13202
|
+
"system.device-tank-low.body.cleanWater": "Il serbatoio dell'acqua pulita di {{deviceName}} è quasi vuoto",
|
|
13203
|
+
"system.device-tank-full.body.cleanWater": "Il serbatoio dell'acqua pulita di {{deviceName}} è pieno",
|
|
13204
|
+
"system.device-tank-normal.body.cleanWater": "Il serbatoio dell'acqua pulita di {{deviceName}} è di nuovo a posto",
|
|
13205
|
+
"system.device-tank-low.body.dirtyWater": "Il serbatoio dell'acqua sporca di {{deviceName}} è quasi vuoto",
|
|
13206
|
+
"system.device-tank-full.body.dirtyWater": "Il serbatoio dell'acqua sporca di {{deviceName}} è pieno",
|
|
13207
|
+
"system.device-tank-normal.body.dirtyWater": "Il serbatoio dell'acqua sporca di {{deviceName}} è di nuovo a posto",
|
|
13208
|
+
"system.device-tank-low.body.detergent": "Il serbatoio del detergente di {{deviceName}} è quasi vuoto",
|
|
13209
|
+
"system.device-tank-full.body.detergent": "Il serbatoio del detergente di {{deviceName}} è pieno",
|
|
13210
|
+
"system.device-tank-normal.body.detergent": "Il serbatoio del detergente di {{deviceName}} è di nuovo a posto",
|
|
13211
|
+
"system.device-tank-low.body.dustBin": "Il contenitore della polvere di {{deviceName}} è quasi vuoto",
|
|
13212
|
+
"system.device-tank-full.body.dustBin": "Il contenitore della polvere di {{deviceName}} è pieno",
|
|
13213
|
+
"system.device-tank-normal.body.dustBin": "Il contenitore della polvere di {{deviceName}} è di nuovo a posto",
|
|
12722
13214
|
"system.camera-online.title": "Dispositivo online",
|
|
12723
13215
|
"system.camera-online.body": "Il dispositivo {{deviceName}} è online",
|
|
12724
13216
|
"system.camera-offline.title": "Dispositivo offline",
|
|
@@ -12748,6 +13240,7 @@ var it_default = {
|
|
|
12748
13240
|
"snooze.scope.anyRule": "Una regola",
|
|
12749
13241
|
"snooze.scope.rule": "“{{rule}}”",
|
|
12750
13242
|
"alarm.arm.body.allDevices": "Attivi: tutti i dispositivi",
|
|
13243
|
+
"alarm.arm.body.allDevicesExcept": "Attivi: tutti i dispositivi tranne {{count}}: {{names}}",
|
|
12751
13244
|
"alarm.arm.body.noDevices": "Attivi: nessuna restrizione",
|
|
12752
13245
|
"alarm.arm.body.devices": "Attivi: {{names}}",
|
|
12753
13246
|
"alarm.arm.body.devicesSkipped": "Attivi: {{names}}\nEsclusi: {{skippedNames}}",
|
|
@@ -13262,7 +13755,7 @@ function assertPatchKeepsFamily(existing, patch) {
|
|
|
13262
13755
|
* the offending kind or filter). Then rethrow. A refused save is dropped work,
|
|
13263
13756
|
* and dropped work is never silent. Any other error passes through unlogged.
|
|
13264
13757
|
*/
|
|
13265
|
-
function logRefusal(check, ctx) {
|
|
13758
|
+
function logRefusal$2(check, ctx) {
|
|
13266
13759
|
try {
|
|
13267
13760
|
check();
|
|
13268
13761
|
} catch (err) {
|
|
@@ -13278,11 +13771,115 @@ function logRefusal(check, ctx) {
|
|
|
13278
13771
|
}
|
|
13279
13772
|
/** {@link assertRuleFamily}, logged. The create path of both routes. */
|
|
13280
13773
|
function guardRuleFamily(rule, ctx) {
|
|
13281
|
-
logRefusal(() => assertRuleFamily(rule), ctx);
|
|
13774
|
+
logRefusal$2(() => assertRuleFamily(rule), ctx);
|
|
13282
13775
|
}
|
|
13283
13776
|
/** {@link assertPatchKeepsFamily}, logged. The update path of both routes. */
|
|
13284
13777
|
function guardPatchFamily(existing, patch, ctx) {
|
|
13285
|
-
logRefusal(() => assertPatchKeepsFamily(existing, patch), ctx);
|
|
13778
|
+
logRefusal$2(() => assertPatchKeepsFamily(existing, patch), ctx);
|
|
13779
|
+
}
|
|
13780
|
+
//#endregion
|
|
13781
|
+
//#region src/notification-center/device-scope-guard.ts
|
|
13782
|
+
/**
|
|
13783
|
+
* The deliveries whose evaluation reads the device scope — the `devices`
|
|
13784
|
+
* descriptor's `appliesTo`, restated as the one delivery that lacks it.
|
|
13785
|
+
*/
|
|
13786
|
+
var NO_DEVICE_SCOPE_DELIVERY = "system-event";
|
|
13787
|
+
var NcDevicesExcludeRefusal = class extends Error {
|
|
13788
|
+
delivery;
|
|
13789
|
+
devicesExclude;
|
|
13790
|
+
constructor(delivery, devicesExclude) {
|
|
13791
|
+
super(`rule refused: devicesExclude does not apply to a '${delivery}' rule — a system rule excludes devices with systemEvent.excludeDeviceIds`);
|
|
13792
|
+
this.name = "NcDevicesExcludeRefusal";
|
|
13793
|
+
this.delivery = delivery;
|
|
13794
|
+
this.devicesExclude = devicesExclude;
|
|
13795
|
+
}
|
|
13796
|
+
};
|
|
13797
|
+
function assertDevicesExcludeDelivery(rule) {
|
|
13798
|
+
const excluded = rule.conditions.devicesExclude;
|
|
13799
|
+
if (excluded === void 0 || rule.delivery !== NO_DEVICE_SCOPE_DELIVERY) return;
|
|
13800
|
+
throw new NcDevicesExcludeRefusal(rule.delivery, excluded);
|
|
13801
|
+
}
|
|
13802
|
+
/** Run the check; a refusal is dropped work, so it is logged before it is rethrown. */
|
|
13803
|
+
function logRefusal$1(check, ctx) {
|
|
13804
|
+
try {
|
|
13805
|
+
check();
|
|
13806
|
+
} catch (err) {
|
|
13807
|
+
if (err instanceof NcDevicesExcludeRefusal) warnRefusal$1(ctx.logger, err, ctx);
|
|
13808
|
+
throw err;
|
|
13809
|
+
}
|
|
13810
|
+
}
|
|
13811
|
+
function warnRefusal$1(logger, err, ctx) {
|
|
13812
|
+
logger.warn("nc rule refused: devicesExclude on a system rule", { meta: {
|
|
13813
|
+
...ctx.ruleId !== void 0 ? { ruleId: ctx.ruleId } : {},
|
|
13814
|
+
by: ctx.by,
|
|
13815
|
+
delivery: err.delivery,
|
|
13816
|
+
devicesExclude: [...err.devicesExclude]
|
|
13817
|
+
} });
|
|
13818
|
+
}
|
|
13819
|
+
/** The create path of both routes. */
|
|
13820
|
+
function guardRuleDevicesExclude(rule, ctx) {
|
|
13821
|
+
logRefusal$1(() => assertDevicesExcludeDelivery(rule), ctx);
|
|
13822
|
+
}
|
|
13823
|
+
/**
|
|
13824
|
+
* The update path of both routes: the MERGED rule is judged, and only when the
|
|
13825
|
+
* patch changes `delivery` or `conditions`.
|
|
13826
|
+
*/
|
|
13827
|
+
function guardPatchDevicesExclude(existing, patch, ctx) {
|
|
13828
|
+
if (existing === null) return;
|
|
13829
|
+
const merged = mergedFamilySubject(existing, patch);
|
|
13830
|
+
if (merged === null) return;
|
|
13831
|
+
logRefusal$1(() => assertDevicesExcludeDelivery(merged), ctx);
|
|
13832
|
+
}
|
|
13833
|
+
//#endregion
|
|
13834
|
+
//#region src/notification-center/sensor-transition-guard.ts
|
|
13835
|
+
/** The one delivery whose subject carries a sensor edge. */
|
|
13836
|
+
var SENSOR_TRANSITION_DELIVERY = "device-event";
|
|
13837
|
+
var NcSensorTransitionRefusal = class extends Error {
|
|
13838
|
+
delivery;
|
|
13839
|
+
sensorTransition;
|
|
13840
|
+
constructor(delivery, sensorTransition) {
|
|
13841
|
+
super(`rule refused: sensorTransition '${sensorTransition}' applies only to a device-event rule, not '${delivery}'`);
|
|
13842
|
+
this.name = "NcSensorTransitionRefusal";
|
|
13843
|
+
this.delivery = delivery;
|
|
13844
|
+
this.sensorTransition = sensorTransition;
|
|
13845
|
+
}
|
|
13846
|
+
};
|
|
13847
|
+
function assertSensorTransitionDelivery(rule) {
|
|
13848
|
+
const transition = rule.conditions.sensorTransition;
|
|
13849
|
+
if (transition === void 0 || rule.delivery === SENSOR_TRANSITION_DELIVERY) return;
|
|
13850
|
+
throw new NcSensorTransitionRefusal(rule.delivery, transition);
|
|
13851
|
+
}
|
|
13852
|
+
/** Run the check; a refusal is dropped work, so it is logged before it is rethrown. */
|
|
13853
|
+
function logRefusal(check, ctx) {
|
|
13854
|
+
try {
|
|
13855
|
+
check();
|
|
13856
|
+
} catch (err) {
|
|
13857
|
+
if (err instanceof NcSensorTransitionRefusal) warnRefusal(ctx.logger, err, ctx);
|
|
13858
|
+
throw err;
|
|
13859
|
+
}
|
|
13860
|
+
}
|
|
13861
|
+
function warnRefusal(logger, err, ctx) {
|
|
13862
|
+
logger.warn("nc rule refused: sensorTransition off a device-event rule", { meta: {
|
|
13863
|
+
...ctx.ruleId !== void 0 ? { ruleId: ctx.ruleId } : {},
|
|
13864
|
+
by: ctx.by,
|
|
13865
|
+
delivery: err.delivery,
|
|
13866
|
+
sensorTransition: err.sensorTransition
|
|
13867
|
+
} });
|
|
13868
|
+
}
|
|
13869
|
+
/** The create path of both routes. */
|
|
13870
|
+
function guardRuleSensorTransition(rule, ctx) {
|
|
13871
|
+
logRefusal(() => assertSensorTransitionDelivery(rule), ctx);
|
|
13872
|
+
}
|
|
13873
|
+
/**
|
|
13874
|
+
* The update path of both routes: the MERGED rule is judged, and only when the
|
|
13875
|
+
* patch changes `delivery` or `conditions` — an unchanged full-form save
|
|
13876
|
+
* (a rename, a retarget) is not re-judged.
|
|
13877
|
+
*/
|
|
13878
|
+
function guardPatchSensorTransition(existing, patch, ctx) {
|
|
13879
|
+
if (existing === null) return;
|
|
13880
|
+
const merged = mergedFamilySubject(existing, patch);
|
|
13881
|
+
if (merged === null) return;
|
|
13882
|
+
logRefusal(() => assertSensorTransitionDelivery(merged), ctx);
|
|
13286
13883
|
}
|
|
13287
13884
|
//#endregion
|
|
13288
13885
|
//#region src/notification-center/occupancy-watcher.ts
|
|
@@ -13997,6 +14594,10 @@ function systemEventKeys(kind) {
|
|
|
13997
14594
|
titleKey: `system.${kind}.title`,
|
|
13998
14595
|
bodyKey: `system.${kind}.body.ignored`
|
|
13999
14596
|
};
|
|
14597
|
+
if (require_dist.NC_TANK_SYSTEM_EVENT_KINDS.includes(kind)) return {
|
|
14598
|
+
titleKey: `system.${kind}.title`,
|
|
14599
|
+
bodyKey: `system.${kind}.body.cleanWater`
|
|
14600
|
+
};
|
|
14000
14601
|
return {
|
|
14001
14602
|
titleKey: `system.${kind}.title`,
|
|
14002
14603
|
bodyKey: `system.${kind}.body`
|
|
@@ -14053,6 +14654,19 @@ function labelFor(conditions) {
|
|
|
14053
14654
|
return conditions.identities?.[0] ?? conditions.labelEquals?.[0] ?? conditions.plates?.values?.[0] ?? void 0;
|
|
14054
14655
|
}
|
|
14055
14656
|
/**
|
|
14657
|
+
* The edge a planned sensor event carries (D640): RAISED by default — the
|
|
14658
|
+
* alert, which is what an operator pressing Test on a flood rule expects to
|
|
14659
|
+
* see — and cleared only for a rule that asks for the clear. Only for a kind
|
|
14660
|
+
* that HAS a raise: a doorbell press carries none in production, and a test
|
|
14661
|
+
* subject claiming one would be a subject no real record could be.
|
|
14662
|
+
*/
|
|
14663
|
+
function sensorEdgeFor(trigger, c) {
|
|
14664
|
+
const kind = c.sensorKinds?.[0];
|
|
14665
|
+
if (trigger !== "device-event" || kind === void 0) return {};
|
|
14666
|
+
if (!require_dist.NC_SENSOR_TRANSITION_KINDS.includes(kind.trim().toLowerCase())) return {};
|
|
14667
|
+
return { sensorRaised: c.sensorTransition !== "cleared" };
|
|
14668
|
+
}
|
|
14669
|
+
/**
|
|
14056
14670
|
* Plan the injection that fires this rule.
|
|
14057
14671
|
*
|
|
14058
14672
|
* Every condition the engine reads off the SUBJECT is satisfied here; the two
|
|
@@ -14074,6 +14688,10 @@ function planRuleTest(input) {
|
|
|
14074
14688
|
note: "This rule names no camera and none was supplied, so the event is attributed to no device and carries no picture. Scope the rule to a camera, or pick one, to see the notification it would really send."
|
|
14075
14689
|
});
|
|
14076
14690
|
const deviceId = scopedDeviceId ?? NC_SYSTEM_DEVICE_ID;
|
|
14691
|
+
if (trigger !== "system-event" && (c.devicesExclude ?? []).includes(deviceId)) notes.push({
|
|
14692
|
+
conditionId: "devicesExclude",
|
|
14693
|
+
note: "The camera this test uses is one the rule excludes, so the evaluation will refuse the event — exactly as it refuses every real one there. Pick another camera to see the notification."
|
|
14694
|
+
});
|
|
14077
14695
|
const scheduledAt = scheduleActiveAt(rule.schedule, now);
|
|
14078
14696
|
if (scheduledAt === null) notes.push({
|
|
14079
14697
|
conditionId: "schedule",
|
|
@@ -14123,6 +14741,7 @@ function planRuleTest(input) {
|
|
|
14123
14741
|
} } : {},
|
|
14124
14742
|
...c.sensorKinds?.[0] !== void 0 ? { sensorKind: c.sensorKinds[0] } : {},
|
|
14125
14743
|
...c.eventTypeTokens?.[0] !== void 0 ? { eventType: c.eventTypeTokens[0] } : {},
|
|
14744
|
+
...sensorEdgeFor(trigger, c),
|
|
14126
14745
|
...trigger === "package-event" ? { packagePhase: c.packagePhase === "picked-up" ? "picked-up" : "delivered" } : {},
|
|
14127
14746
|
...c.source !== void 0 && c.source !== "any" ? { source: c.source } : {},
|
|
14128
14747
|
...bbox !== void 0 ? { bbox } : {},
|
|
@@ -14656,6 +15275,10 @@ var NcTestEventInputSchema = require_dist.object({
|
|
|
14656
15275
|
/** DEVICE-EVENT: the raw device event-type token, when the source is an
|
|
14657
15276
|
* event-emitter slice. */
|
|
14658
15277
|
eventType: require_dist.string().optional(),
|
|
15278
|
+
/** DEVICE-EVENT: which edge a boolean sensor moved on — true raised, false
|
|
15279
|
+
* cleared (D640). Absent = the slice could not say, which production
|
|
15280
|
+
* answers for a doorbell press or an unreadable read. */
|
|
15281
|
+
sensorRaised: require_dist.boolean().optional(),
|
|
14659
15282
|
/** PACKAGE-EVENT: which lifecycle phase. */
|
|
14660
15283
|
packagePhase: require_dist._enum(["delivered", "picked-up"]).optional(),
|
|
14661
15284
|
/** Detection provenance. Absent = `pipeline` (what a record with no stamped
|
|
@@ -14874,6 +15497,7 @@ function buildSyntheticEvent(input, recordId, now) {
|
|
|
14874
15497
|
...input.labelConfidence !== void 0 ? { labelConfidence: input.labelConfidence } : {},
|
|
14875
15498
|
...input.sensorKind !== void 0 ? { sensorKind: input.sensorKind } : {},
|
|
14876
15499
|
...input.eventType !== void 0 ? { eventType: input.eventType } : {},
|
|
15500
|
+
...input.sensorRaised !== void 0 ? { sensorRaised: input.sensorRaised } : {},
|
|
14877
15501
|
...input.packagePhase !== void 0 ? { packagePhase: input.packagePhase } : {},
|
|
14878
15502
|
...input.bbox !== void 0 ? { bbox: input.bbox } : {},
|
|
14879
15503
|
...input.crossing !== void 0 ? { crossing: input.crossing } : {},
|
|
@@ -15989,7 +16613,7 @@ function makeNcActionHandlers(deps) {
|
|
|
15989
16613
|
catalog: [...require_dist.NC_CONDITION_CATALOG],
|
|
15990
16614
|
taxonomy: require_dist.NC_TAXONOMY
|
|
15991
16615
|
}),
|
|
15992
|
-
"nc.getTemplateCatalog": async () => ({ vars:
|
|
16616
|
+
"nc.getTemplateCatalog": async () => ({ vars: require_dist.servedTemplateVars() }),
|
|
15993
16617
|
"nc.previewTemplate": async (input) => {
|
|
15994
16618
|
const texts = deps.previewTexts;
|
|
15995
16619
|
if (texts === void 0) throw new Error("nc.previewTemplate: text catalog not wired");
|
|
@@ -16004,6 +16628,14 @@ function makeNcActionHandlers(deps) {
|
|
|
16004
16628
|
logger: deps.logger,
|
|
16005
16629
|
by: c.userId
|
|
16006
16630
|
});
|
|
16631
|
+
guardRuleSensorTransition(parsed, {
|
|
16632
|
+
logger: deps.logger,
|
|
16633
|
+
by: c.userId
|
|
16634
|
+
});
|
|
16635
|
+
guardRuleDevicesExclude(parsed, {
|
|
16636
|
+
logger: deps.logger,
|
|
16637
|
+
by: c.userId
|
|
16638
|
+
});
|
|
16007
16639
|
await assertTargetsOwned(parsed.targets.map((t) => t.targetId), c);
|
|
16008
16640
|
const rule = await deps.ruleStore.create({
|
|
16009
16641
|
...parsed,
|
|
@@ -16027,6 +16659,16 @@ function makeNcActionHandlers(deps) {
|
|
|
16027
16659
|
by: c.userId,
|
|
16028
16660
|
ruleId: input.ruleId
|
|
16029
16661
|
});
|
|
16662
|
+
guardPatchSensorTransition(existing, patch, {
|
|
16663
|
+
logger: deps.logger,
|
|
16664
|
+
by: c.userId,
|
|
16665
|
+
ruleId: input.ruleId
|
|
16666
|
+
});
|
|
16667
|
+
guardPatchDevicesExclude(existing, patch, {
|
|
16668
|
+
logger: deps.logger,
|
|
16669
|
+
by: c.userId,
|
|
16670
|
+
ruleId: input.ruleId
|
|
16671
|
+
});
|
|
16030
16672
|
if (patch.targets !== void 0) await assertTargetsOwned(patch.targets.map((t) => t.targetId), c);
|
|
16031
16673
|
const { ownerUserId: _owner, disabledTargetIds: _optOut, ...safe } = patch;
|
|
16032
16674
|
const rule = await deps.ruleStore.update(input.ruleId, safe, clear);
|
|
@@ -19170,6 +19812,19 @@ function pruneRuleConditions(conditions, isDead, notes) {
|
|
|
19170
19812
|
notes.removed("conditions.devices", deviceSubjects(verdict.dead));
|
|
19171
19813
|
}
|
|
19172
19814
|
}
|
|
19815
|
+
const excluded = conditions.devicesExclude;
|
|
19816
|
+
if (excluded !== void 0) {
|
|
19817
|
+
const verdict = judgeList(excluded, isDead);
|
|
19818
|
+
if (!verdict.untouched) {
|
|
19819
|
+
const { devicesExclude: _dead, ...rest } = next;
|
|
19820
|
+
next = verdict.last ? rest : {
|
|
19821
|
+
...rest,
|
|
19822
|
+
devicesExclude: [...verdict.next]
|
|
19823
|
+
};
|
|
19824
|
+
changed = true;
|
|
19825
|
+
notes.removed("conditions.devicesExclude", deviceSubjects(verdict.dead));
|
|
19826
|
+
}
|
|
19827
|
+
}
|
|
19173
19828
|
const systemEvent = conditions.systemEvent;
|
|
19174
19829
|
if (systemEvent?.deviceIds !== void 0) {
|
|
19175
19830
|
const verdict = judgeList(systemEvent.deviceIds, isDead);
|
|
@@ -19350,6 +20005,7 @@ function referencedDeviceIds(snapshot) {
|
|
|
19350
20005
|
for (const rule of snapshot.rules) {
|
|
19351
20006
|
const c = rule.conditions;
|
|
19352
20007
|
add(c.devices);
|
|
20008
|
+
add(c.devicesExclude);
|
|
19353
20009
|
add(c.systemEvent?.deviceIds);
|
|
19354
20010
|
if (c.deviceState !== void 0) ids.add(c.deviceState.deviceId);
|
|
19355
20011
|
if (c.scene?.deviceId !== void 0) ids.add(c.scene.deviceId);
|
|
@@ -22203,6 +22859,50 @@ var NcOutbox = class {
|
|
|
22203
22859
|
return [];
|
|
22204
22860
|
}
|
|
22205
22861
|
}
|
|
22862
|
+
/**
|
|
22863
|
+
* The rows `getRuleLastRuns` reduces — ONE statement: fire time inside
|
|
22864
|
+
* `[sinceMs, ∞)`, newest-first on `idx_nc_outbox_created`, projected to the
|
|
22865
|
+
* four columns the reduction reads (never the `payload` JSON), at most
|
|
22866
|
+
* `limit` rows. `sinceMs: null` = no lower bound (the operator keeps the
|
|
22867
|
+
* outbox forever); `limit` still bounds the read.
|
|
22868
|
+
*
|
|
22869
|
+
* Unlike {@link queryHistory} this THROWS on a store error: its caller
|
|
22870
|
+
* answers "which rules did not run", and an empty page from a failed read
|
|
22871
|
+
* would answer "none of them did" (D393).
|
|
22872
|
+
*/
|
|
22873
|
+
async queryLastRunRows(sinceMs, limit) {
|
|
22874
|
+
const rows = await this.store.query.query({
|
|
22875
|
+
collection: NC_OUTBOX_COLLECTION,
|
|
22876
|
+
filter: {
|
|
22877
|
+
...sinceMs !== null ? { whereBetween: { createdAt: [sinceMs, Number.MAX_SAFE_INTEGER] } } : {},
|
|
22878
|
+
orderBy: {
|
|
22879
|
+
field: "createdAt",
|
|
22880
|
+
direction: "desc"
|
|
22881
|
+
},
|
|
22882
|
+
limit
|
|
22883
|
+
},
|
|
22884
|
+
columns: [...NC_LAST_RUN_COLUMNS]
|
|
22885
|
+
});
|
|
22886
|
+
const out = [];
|
|
22887
|
+
let malformed = 0;
|
|
22888
|
+
let oldestReadAt = null;
|
|
22889
|
+
for (const row of rows) {
|
|
22890
|
+
const at = Number(row.data["createdAt"]);
|
|
22891
|
+
if (Number.isFinite(at) && (oldestReadAt === null || at < oldestReadAt)) oldestReadAt = at;
|
|
22892
|
+
const parsed = lastRunRow(row.data);
|
|
22893
|
+
if (parsed === null) malformed += 1;
|
|
22894
|
+
else out.push(parsed);
|
|
22895
|
+
}
|
|
22896
|
+
if (malformed > 0) this.logger.warn("last-run read skipped malformed outbox rows", { meta: {
|
|
22897
|
+
malformed,
|
|
22898
|
+
read: rows.length
|
|
22899
|
+
} });
|
|
22900
|
+
return {
|
|
22901
|
+
rows: out,
|
|
22902
|
+
truncated: rows.length >= limit,
|
|
22903
|
+
oldestReadAt
|
|
22904
|
+
};
|
|
22905
|
+
}
|
|
22206
22906
|
async getWatermark() {
|
|
22207
22907
|
try {
|
|
22208
22908
|
const row = await this.store.get.query({
|
|
@@ -22490,6 +23190,27 @@ function rowToEntry$1(id, data) {
|
|
|
22490
23190
|
payload
|
|
22491
23191
|
};
|
|
22492
23192
|
}
|
|
23193
|
+
/** The columns {@link NcOutbox.queryLastRunRows} projects. */
|
|
23194
|
+
var NC_LAST_RUN_COLUMNS = [
|
|
23195
|
+
"ruleId",
|
|
23196
|
+
"createdAt",
|
|
23197
|
+
"status",
|
|
23198
|
+
"deviceId"
|
|
23199
|
+
];
|
|
23200
|
+
/** A projected row back to a {@link NcLastRunRow} — a guard, never a cast. */
|
|
23201
|
+
function lastRunRow(data) {
|
|
23202
|
+
const ruleId = data["ruleId"];
|
|
23203
|
+
const createdAt = Number(data["createdAt"]);
|
|
23204
|
+
const deviceId = Number(data["deviceId"]);
|
|
23205
|
+
const status = data["status"];
|
|
23206
|
+
if (typeof ruleId !== "string" || !Number.isFinite(createdAt) || !Number.isFinite(deviceId) || status !== "pending" && status !== "sent" && status !== "dead") return null;
|
|
23207
|
+
return {
|
|
23208
|
+
ruleId,
|
|
23209
|
+
createdAt,
|
|
23210
|
+
status,
|
|
23211
|
+
deviceId
|
|
23212
|
+
};
|
|
23213
|
+
}
|
|
22493
23214
|
//#endregion
|
|
22494
23215
|
//#region src/notification-center/rule-actions.ts
|
|
22495
23216
|
var NcRuleActionRunner = class {
|
|
@@ -23181,6 +23902,58 @@ var NcRuleAiStore = class {
|
|
|
23181
23902
|
}
|
|
23182
23903
|
};
|
|
23183
23904
|
//#endregion
|
|
23905
|
+
//#region src/notification-center/rule-last-runs.ts
|
|
23906
|
+
/**
|
|
23907
|
+
* Row bound of the one read. Projected rows are four scalars (~100 B), so the
|
|
23908
|
+
* bound costs ~2 MB at worst. The live outbox volume was NOT measured when
|
|
23909
|
+
* this was chosen. It exists so a runaway rule cannot turn an admin page load
|
|
23910
|
+
* into a table walk — and when it bites, `horizonMs` says so.
|
|
23911
|
+
*/
|
|
23912
|
+
var NC_RULE_LAST_RUNS_SCAN_LIMIT = 2e4;
|
|
23913
|
+
/** Least successful first: a fire that reached some targets and not others
|
|
23914
|
+
* did not "deliver". */
|
|
23915
|
+
var STATUS_SEVERITY = {
|
|
23916
|
+
sent: 0,
|
|
23917
|
+
pending: 1,
|
|
23918
|
+
dead: 2
|
|
23919
|
+
};
|
|
23920
|
+
function worse(a, b) {
|
|
23921
|
+
return STATUS_SEVERITY[b] > STATUS_SEVERITY[a] ? b : a;
|
|
23922
|
+
}
|
|
23923
|
+
/**
|
|
23924
|
+
* Reduce newest-first rows to one run per rule, in one pass.
|
|
23925
|
+
*
|
|
23926
|
+
* Rows sharing the newest `createdAt` of a rule are one fire to several
|
|
23927
|
+
* targets; they merge into the least successful status. `read.rows` MUST be
|
|
23928
|
+
* ordered newest-first — that is what the outbox read asks the store for.
|
|
23929
|
+
*/
|
|
23930
|
+
function summariseRuleLastRuns(read, scan) {
|
|
23931
|
+
const byRule = /* @__PURE__ */ new Map();
|
|
23932
|
+
for (const row of read.rows) {
|
|
23933
|
+
const seen = byRule.get(row.ruleId);
|
|
23934
|
+
if (seen === void 0) byRule.set(row.ruleId, {
|
|
23935
|
+
ruleId: row.ruleId,
|
|
23936
|
+
lastAt: row.createdAt,
|
|
23937
|
+
status: row.status,
|
|
23938
|
+
deviceId: row.deviceId
|
|
23939
|
+
});
|
|
23940
|
+
else if (seen.lastAt === row.createdAt && worse(seen.status, row.status) !== seen.status) byRule.set(row.ruleId, {
|
|
23941
|
+
...seen,
|
|
23942
|
+
status: row.status,
|
|
23943
|
+
deviceId: row.deviceId
|
|
23944
|
+
});
|
|
23945
|
+
}
|
|
23946
|
+
return {
|
|
23947
|
+
runs: [...byRule.values()],
|
|
23948
|
+
horizonMs: horizonOf(read, scan)
|
|
23949
|
+
};
|
|
23950
|
+
}
|
|
23951
|
+
/** How far back the answer really looked — see the module doc. */
|
|
23952
|
+
function horizonOf(read, scan) {
|
|
23953
|
+
if (read.truncated && read.oldestReadAt !== null) return Math.max(0, scan.now - read.oldestReadAt);
|
|
23954
|
+
return scan.windowMs;
|
|
23955
|
+
}
|
|
23956
|
+
//#endregion
|
|
23184
23957
|
//#region src/notification-center/durable-reread.ts
|
|
23185
23958
|
function isRecord$3(value) {
|
|
23186
23959
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
@@ -23420,7 +24193,7 @@ var NcRuleStore = class {
|
|
|
23420
24193
|
*/
|
|
23421
24194
|
async load() {
|
|
23422
24195
|
await this.ledger.load();
|
|
23423
|
-
this.loaded = true;
|
|
24196
|
+
if (this.ledger.hasAnswered()) this.loaded = true;
|
|
23424
24197
|
this.logger.debug("notification rules loaded", { meta: { rules: this.ledger.size } });
|
|
23425
24198
|
}
|
|
23426
24199
|
/**
|
|
@@ -27850,7 +28623,6 @@ var ARM_ANYWAY_REJECTION_COPY = {
|
|
|
27850
28623
|
*/
|
|
27851
28624
|
var NC_DEVICE_ALERT_FALLBACK = {
|
|
27852
28625
|
batteryLowPercent: 20,
|
|
27853
|
-
consumableLowPercent: 15,
|
|
27854
28626
|
renotifyMinutes: 720,
|
|
27855
28627
|
notifyRecovery: true
|
|
27856
28628
|
};
|
|
@@ -28145,9 +28917,14 @@ function outboxEntryToHistory(texts, entry) {
|
|
|
28145
28917
|
function renderAudioSpec(spec) {
|
|
28146
28918
|
return spec.mode === "label" ? `label:${spec.labels.join("+")}` : `level:${String(spec.dbThreshold)}dBFS|h${String(spec.hitPercent)}|s${String(spec.samplingSeconds)}`;
|
|
28147
28919
|
}
|
|
28148
|
-
/**
|
|
28149
|
-
|
|
28150
|
-
|
|
28920
|
+
/**
|
|
28921
|
+
* The camera scope in one token — `@all` is a real answer, not a missing one,
|
|
28922
|
+
* and `@all-except:<ids>` is a different one (D641): an excluding rule logged
|
|
28923
|
+
* as `@all` would claim the camera it never watches.
|
|
28924
|
+
*/
|
|
28925
|
+
function renderAudioScope(devices, excluded) {
|
|
28926
|
+
if (devices.length === 0) return excluded.length === 0 ? "@all" : `@all-except:${excluded.join("+")}`;
|
|
28927
|
+
return devices.filter((id) => !excluded.includes(id)).join("+");
|
|
28151
28928
|
}
|
|
28152
28929
|
var NotificationCenter = class NotificationCenter {
|
|
28153
28930
|
logger;
|
|
@@ -29100,6 +29877,7 @@ var NotificationCenter = class NotificationCenter {
|
|
|
29100
29877
|
return (await this.consumeEventAndReport(event)).outcomes.some((outcome) => outcome.matched);
|
|
29101
29878
|
},
|
|
29102
29879
|
rulesVersion: () => this.rulesFingerprint(),
|
|
29880
|
+
consumableThresholds: () => this.rules.isLoaded() ? consumableThresholdCensus(this.rules.list()) : null,
|
|
29103
29881
|
settings: () => this.deviceAlertSettings(),
|
|
29104
29882
|
lookup: (deviceId) => this.deviceDirectory.get(deviceId),
|
|
29105
29883
|
rows: this.alertStore.port(),
|
|
@@ -29889,7 +30667,7 @@ var NotificationCenter = class NotificationCenter {
|
|
|
29889
30667
|
* scope the evaluation will match on (`matchesDeviceScope`).
|
|
29890
30668
|
*/
|
|
29891
30669
|
beginLinkedStills(request) {
|
|
29892
|
-
const { sensorId, sensorKind, cameraIds, atMs, rowCameraIds } = request;
|
|
30670
|
+
const { sensorId, sensorKind, cameraIds, atMs, rowCameraIds, sensorRaised } = request;
|
|
29893
30671
|
const shelf = this.linkedStills;
|
|
29894
30672
|
if (shelf === null || !this.evaluationActive || cameraIds.length < 2) return void 0;
|
|
29895
30673
|
if (this.deviceMutes.isMuted(sensorId)) return void 0;
|
|
@@ -29899,7 +30677,13 @@ var NotificationCenter = class NotificationCenter {
|
|
|
29899
30677
|
const involved = [sensorId, ...rowShown];
|
|
29900
30678
|
if (!this.rules.listEnabled("device-event").some((rule) => {
|
|
29901
30679
|
if (rule.media.attach === "none") return false;
|
|
29902
|
-
if (!deviceEventRuleMayFireOnKind(rule, sensorKind)) return false;
|
|
30680
|
+
if (!deviceEventRuleMayFireOnKind(rule, sensorKind, sensorRaised)) return false;
|
|
30681
|
+
const excluded = rule.conditions.devicesExclude;
|
|
30682
|
+
if (excluded !== void 0 && deviceScopeExcludes(excluded, {
|
|
30683
|
+
deviceId: sensorId,
|
|
30684
|
+
linkedSensorId: sensorId,
|
|
30685
|
+
rowCameraIds: rowShown
|
|
30686
|
+
})) return false;
|
|
29903
30687
|
const devices = rule.conditions.devices;
|
|
29904
30688
|
return devices === void 0 || devices.length === 0 || devices.some((id) => involved.includes(id));
|
|
29905
30689
|
})) return void 0;
|
|
@@ -30021,6 +30805,8 @@ var NotificationCenter = class NotificationCenter {
|
|
|
30021
30805
|
if (!(mode === "condition" ? rule.conditions.audio !== void 0 : rule.conditions.audio === void 0 && referencesAudioClass(rule.conditions.classes))) continue;
|
|
30022
30806
|
const devices = rule.conditions.devices;
|
|
30023
30807
|
if (devices !== void 0 && devices.length > 0 && !devices.includes(deviceId)) continue;
|
|
30808
|
+
const excluded = rule.conditions.devicesExclude;
|
|
30809
|
+
if (excluded !== void 0 && deviceScopeExcludes(excluded, { deviceId })) continue;
|
|
30024
30810
|
return shelf.capture(deviceId, atMs, "audio");
|
|
30025
30811
|
}
|
|
30026
30812
|
}
|
|
@@ -30123,6 +30909,8 @@ var NotificationCenter = class NotificationCenter {
|
|
|
30123
30909
|
if (occ === void 0 || !matchesOccupancy(occ, subject)) continue;
|
|
30124
30910
|
const devices = rule.conditions.devices;
|
|
30125
30911
|
if (devices !== void 0 && devices.length > 0 && !devices.includes(edge.deviceId)) continue;
|
|
30912
|
+
const excluded = rule.conditions.devicesExclude;
|
|
30913
|
+
if (excluded !== void 0 && deviceScopeExcludes(excluded, { deviceId: edge.deviceId })) continue;
|
|
30126
30914
|
return shelf.capture(edge.deviceId, edge.timestamp, "occupancy");
|
|
30127
30915
|
}
|
|
30128
30916
|
}
|
|
@@ -30198,6 +30986,11 @@ var NotificationCenter = class NotificationCenter {
|
|
|
30198
30986
|
for (const item of consumables.items) this.deviceAlerts.observeConsumable(consumables.deviceId, item);
|
|
30199
30987
|
return;
|
|
30200
30988
|
}
|
|
30989
|
+
const tanks = event.category === "device.state-changed" ? vacuumTanksOf(event.data) : null;
|
|
30990
|
+
if (tanks !== null) {
|
|
30991
|
+
for (const tank of tanks.tanks) this.deviceAlerts.observeTank(tanks.deviceId, tank);
|
|
30992
|
+
return;
|
|
30993
|
+
}
|
|
30201
30994
|
const lookup = this.deviceDirectory.lookup();
|
|
30202
30995
|
const incoming = incomingFromSystemEvent(event, lookup);
|
|
30203
30996
|
if (incoming === null) {
|
|
@@ -30258,6 +31051,14 @@ var NotificationCenter = class NotificationCenter {
|
|
|
30258
31051
|
logger: this.logger,
|
|
30259
31052
|
by: caller.userId
|
|
30260
31053
|
});
|
|
31054
|
+
guardRuleSensorTransition(rule, {
|
|
31055
|
+
logger: this.logger,
|
|
31056
|
+
by: caller.userId
|
|
31057
|
+
});
|
|
31058
|
+
guardRuleDevicesExclude(rule, {
|
|
31059
|
+
logger: this.logger,
|
|
31060
|
+
by: caller.userId
|
|
31061
|
+
});
|
|
30261
31062
|
await this.validateTargetRefs(rule.targets.map((t) => t.targetId));
|
|
30262
31063
|
const created = await this.rules.create(rule, caller.userId);
|
|
30263
31064
|
this.logger.info("notification rule created", { meta: {
|
|
@@ -30276,6 +31077,16 @@ var NotificationCenter = class NotificationCenter {
|
|
|
30276
31077
|
by: caller.userId,
|
|
30277
31078
|
ruleId
|
|
30278
31079
|
});
|
|
31080
|
+
guardPatchSensorTransition(existing, patch, {
|
|
31081
|
+
logger: this.logger,
|
|
31082
|
+
by: caller.userId,
|
|
31083
|
+
ruleId
|
|
31084
|
+
});
|
|
31085
|
+
guardPatchDevicesExclude(existing, patch, {
|
|
31086
|
+
logger: this.logger,
|
|
31087
|
+
by: caller.userId,
|
|
31088
|
+
ruleId
|
|
31089
|
+
});
|
|
30279
31090
|
const { disabledTargetIds: _optOut, ...safePatch } = patch;
|
|
30280
31091
|
const updated = await this.rules.update(ruleId, safePatch, clear ?? []);
|
|
30281
31092
|
this.logger.info("notification rule updated", { meta: {
|
|
@@ -30298,7 +31109,7 @@ var NotificationCenter = class NotificationCenter {
|
|
|
30298
31109
|
catalog: [...require_dist.NC_CONDITION_CATALOG],
|
|
30299
31110
|
taxonomy: require_dist.NC_TAXONOMY
|
|
30300
31111
|
}),
|
|
30301
|
-
getTemplateCatalog: async () => ({ vars:
|
|
31112
|
+
getTemplateCatalog: async () => ({ vars: require_dist.servedTemplateVars() }),
|
|
30302
31113
|
previewTemplate: async (input) => previewTemplate(this.texts, input),
|
|
30303
31114
|
getHistory: async ({ filter }) => {
|
|
30304
31115
|
const entries = await this.outbox.queryHistory({
|
|
@@ -30317,6 +31128,14 @@ var NotificationCenter = class NotificationCenter {
|
|
|
30317
31128
|
}
|
|
30318
31129
|
return { entries: mapped };
|
|
30319
31130
|
},
|
|
31131
|
+
getRuleLastRuns: async () => {
|
|
31132
|
+
const now = this.now();
|
|
31133
|
+
const windowMs = (await this.deps.pruneWindows()).outboxMs;
|
|
31134
|
+
return summariseRuleLastRuns(await this.outbox.queryLastRunRows(windowMs !== null ? now - windowMs : null, NC_RULE_LAST_RUNS_SCAN_LIMIT), {
|
|
31135
|
+
now,
|
|
31136
|
+
windowMs
|
|
31137
|
+
});
|
|
31138
|
+
},
|
|
30320
31139
|
resolveArtifactUrl: async ({ artifactId }) => ({ url: await this.deps.resolveArtifactUrl?.(artifactId) ?? null }),
|
|
30321
31140
|
listDeviceMutes: async () => ({ mutedDeviceIds: this.deviceMutes.listMuted() }),
|
|
30322
31141
|
setDeviceMuted: async ({ deviceId, muted }) => {
|
|
@@ -30648,6 +31467,7 @@ var NotificationCenter = class NotificationCenter {
|
|
|
30648
31467
|
coverage: this.coverageFor(panel).map((c) => ({
|
|
30649
31468
|
...c,
|
|
30650
31469
|
deviceIds: [...c.deviceIds],
|
|
31470
|
+
allDevicesExcept: [...c.allDevicesExcept],
|
|
30651
31471
|
skippedDevices: c.skippedDevices.map((s) => ({ ...s }))
|
|
30652
31472
|
}))
|
|
30653
31473
|
};
|
|
@@ -31157,6 +31977,9 @@ var NotificationCenter = class NotificationCenter {
|
|
|
31157
31977
|
failed: evaluation.failedCondition,
|
|
31158
31978
|
classes: subject.classNames,
|
|
31159
31979
|
...subject.sensorEdge !== void 0 ? { sensorEdge: subject.sensorEdge } : {},
|
|
31980
|
+
...subject.sensorRaised !== void 0 ? { sensorRaised: subject.sensorRaised } : {},
|
|
31981
|
+
...subject.sourceDeviceId !== void 0 ? { sourceDeviceId: subject.sourceDeviceId } : {},
|
|
31982
|
+
...subject.linkedSensorId !== void 0 ? { linkedSensorId: subject.linkedSensorId } : {},
|
|
31160
31983
|
eventId: subject.recordId,
|
|
31161
31984
|
...subject.trackId !== void 0 ? { trackId: subject.trackId } : {}
|
|
31162
31985
|
}
|
|
@@ -31266,7 +32089,11 @@ var NotificationCenter = class NotificationCenter {
|
|
|
31266
32089
|
eventId: subject.recordId,
|
|
31267
32090
|
...subject.trackId !== void 0 ? { trackId: subject.trackId } : {},
|
|
31268
32091
|
...subject.confidence !== void 0 ? { confidence: subject.confidence } : {},
|
|
31269
|
-
...subject.zones.length > 0 ? { zones: subject.zones } : {}
|
|
32092
|
+
...subject.zones.length > 0 ? { zones: subject.zones } : {},
|
|
32093
|
+
...rule.conditions.sensorTransition !== void 0 ? {
|
|
32094
|
+
sensorTransition: rule.conditions.sensorTransition,
|
|
32095
|
+
sensorRaised: subject.sensorRaised ?? null
|
|
32096
|
+
} : {}
|
|
31270
32097
|
}
|
|
31271
32098
|
});
|
|
31272
32099
|
const unresolvable = unresolvableButtons(rule.actions);
|
|
@@ -31873,7 +32700,8 @@ var NotificationCenter = class NotificationCenter {
|
|
|
31873
32700
|
ruleId: rule.id,
|
|
31874
32701
|
ruleName: rule.name,
|
|
31875
32702
|
spec,
|
|
31876
|
-
devices: rule.conditions.devices ?? []
|
|
32703
|
+
devices: rule.conditions.devices ?? [],
|
|
32704
|
+
excluded: rule.conditions.devicesExclude ?? []
|
|
31877
32705
|
});
|
|
31878
32706
|
}
|
|
31879
32707
|
this.audioWatcher.setWatchedSpecs(audioSpecs);
|
|
@@ -31965,10 +32793,12 @@ var NotificationCenter = class NotificationCenter {
|
|
|
31965
32793
|
* for at all"; the per-camera line answers "why is 617 not notifying" — and
|
|
31966
32794
|
* that question is ALWAYS asked per camera, so the line carries
|
|
31967
32795
|
* `tags.deviceId` and a rule scoped to no camera is reported as watching
|
|
31968
|
-
* every one rather than silently omitted
|
|
32796
|
+
* every one rather than silently omitted — every one EXCEPT its
|
|
32797
|
+
* `devicesExclude` cameras (D641), each of which gets its own NOT-watching
|
|
32798
|
+
* line on that camera.
|
|
31969
32799
|
*/
|
|
31970
32800
|
reportAudioWatch(entries) {
|
|
31971
|
-
const rendered = entries.map((e) => `${e.ruleId}=${renderAudioSpec(e.spec)}@${renderAudioScope(e.devices)}`).toSorted().join(",");
|
|
32801
|
+
const rendered = entries.map((e) => `${e.ruleId}=${renderAudioSpec(e.spec)}@${renderAudioScope(e.devices, e.excluded)}`).toSorted().join(",");
|
|
31972
32802
|
if (rendered === this.lastAudioWatchReport) return;
|
|
31973
32803
|
this.lastAudioWatchReport = rendered;
|
|
31974
32804
|
this.logger.info("audio watched specs", { meta: {
|
|
@@ -31982,14 +32812,28 @@ var NotificationCenter = class NotificationCenter {
|
|
|
31982
32812
|
mode: entry.spec.mode,
|
|
31983
32813
|
spec: renderAudioSpec(entry.spec)
|
|
31984
32814
|
};
|
|
32815
|
+
for (const deviceId of entry.excluded) this.logger.info("audio rule NOT watching camera", {
|
|
32816
|
+
tags: { deviceId },
|
|
32817
|
+
meta: {
|
|
32818
|
+
...meta,
|
|
32819
|
+
reason: "devicesExclude"
|
|
32820
|
+
}
|
|
32821
|
+
});
|
|
31985
32822
|
if (entry.devices.length === 0) {
|
|
31986
|
-
this.logger.info("audio rule watching every camera", { meta });
|
|
32823
|
+
if (entry.excluded.length === 0) this.logger.info("audio rule watching every camera", { meta });
|
|
32824
|
+
else this.logger.info("audio rule watching every camera except the excluded", { meta: {
|
|
32825
|
+
...meta,
|
|
32826
|
+
excluded: [...entry.excluded]
|
|
32827
|
+
} });
|
|
31987
32828
|
continue;
|
|
31988
32829
|
}
|
|
31989
|
-
for (const deviceId of entry.devices)
|
|
31990
|
-
|
|
31991
|
-
|
|
31992
|
-
|
|
32830
|
+
for (const deviceId of entry.devices) {
|
|
32831
|
+
if (entry.excluded.includes(deviceId)) continue;
|
|
32832
|
+
this.logger.info("audio rule watching camera", {
|
|
32833
|
+
tags: { deviceId },
|
|
32834
|
+
meta
|
|
32835
|
+
});
|
|
32836
|
+
}
|
|
31993
32837
|
}
|
|
31994
32838
|
}
|
|
31995
32839
|
/** Boot reseed of confirmed occupancy edge-state (durability, constraint 4) —
|
|
@@ -32068,7 +32912,6 @@ var NotificationCenter = class NotificationCenter {
|
|
|
32068
32912
|
deviceAlertSettings() {
|
|
32069
32913
|
return this.deps.readDeviceAlertSettings?.() ?? {
|
|
32070
32914
|
batteryLowPercent: NC_DEVICE_ALERT_FALLBACK.batteryLowPercent,
|
|
32071
|
-
consumableLowPercent: NC_DEVICE_ALERT_FALLBACK.consumableLowPercent,
|
|
32072
32915
|
renotifyMinutes: NC_DEVICE_ALERT_FALLBACK.renotifyMinutes,
|
|
32073
32916
|
notifyRecovery: NC_DEVICE_ALERT_FALLBACK.notifyRecovery
|
|
32074
32917
|
};
|
|
@@ -55690,19 +56533,6 @@ var DeviceAlertSettingsSchema = require_dist.object({
|
|
|
55690
56533
|
*/
|
|
55691
56534
|
batteryLowPercent: require_dist.number().int().min(1).max(99).default(20),
|
|
55692
56535
|
/**
|
|
55693
|
-
* What counts as a nearly-exhausted CONSUMABLE — a filter, a brush, a mop pad.
|
|
55694
|
-
*
|
|
55695
|
-
* Its own number and not the battery's, because the two are different chores
|
|
55696
|
-
* on different clocks: a battery at 20 % is hours from flat, a brush at 20 %
|
|
55697
|
-
* is weeks from worn, and an operator who wants to be told early about one
|
|
55698
|
-
* does not necessarily want to be told early about the other.
|
|
55699
|
-
*
|
|
55700
|
-
* Applies to the MEASURED side only. An item whose provider states a
|
|
55701
|
-
* `replace` verdict is low whatever this says — the provider decided, and a
|
|
55702
|
-
* threshold cannot talk it out of it (`alerts/consumable-band.ts`).
|
|
55703
|
-
*/
|
|
55704
|
-
consumableLowPercent: require_dist.number().int().min(1).max(99).default(15),
|
|
55705
|
-
/**
|
|
55706
56536
|
* How long a still-true alert waits before it says itself again.
|
|
55707
56537
|
*
|
|
55708
56538
|
* **0 = say it once.** That is the behaviour of every other notification this
|
|
@@ -77406,7 +78236,7 @@ function buildGlobalSettingsSchema(cap) {
|
|
|
77406
78236
|
{
|
|
77407
78237
|
id: "device-alerts",
|
|
77408
78238
|
title: "Battery & offline alerts",
|
|
77409
|
-
description: "What counts as a low battery, how often a still-true alert is repeated, and whether the return to normal is announced. These govern the
|
|
78239
|
+
description: "What counts as a low battery, how often a still-true alert is repeated, and whether the return to normal is announced. These govern the device-event rules; the rules themselves still choose which devices and which kinds — and each rule sets its own consumable threshold. A device with a binary low-battery indicator (no real percentage) is judged by the indicator, and the threshold below does not apply to it.",
|
|
77410
78240
|
columns: 2,
|
|
77411
78241
|
fields: [
|
|
77412
78242
|
{
|
|
@@ -77420,17 +78250,6 @@ function buildGlobalSettingsSchema(cap) {
|
|
|
77420
78250
|
default: DEVICE_ALERT_DEFAULTS.batteryLowPercent,
|
|
77421
78251
|
unit: "%"
|
|
77422
78252
|
},
|
|
77423
|
-
{
|
|
77424
|
-
type: "number",
|
|
77425
|
-
key: "consumableLowPercent",
|
|
77426
|
-
label: "Low consumable at",
|
|
77427
|
-
description: "At or below this, a measured consumable — a filter, a brush, a mop pad — is nearly exhausted. Its own number rather than the battery’s: a battery at 20% is hours from flat, a brush at 20% is weeks from worn. An item whose device reports “replace” outright is announced whatever this says.",
|
|
77428
|
-
min: 1,
|
|
77429
|
-
max: 99,
|
|
77430
|
-
step: 1,
|
|
77431
|
-
default: DEVICE_ALERT_DEFAULTS.consumableLowPercent,
|
|
77432
|
-
unit: "%"
|
|
77433
|
-
},
|
|
77434
78253
|
{
|
|
77435
78254
|
type: "number",
|
|
77436
78255
|
key: "alertRenotifyMinutes",
|
|
@@ -83590,7 +84409,6 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
|
|
|
83590
84409
|
},
|
|
83591
84410
|
readDeviceAlertSettings: () => ({
|
|
83592
84411
|
batteryLowPercent: this.deviceAlertSettingsMirror.batteryLowPercent,
|
|
83593
|
-
consumableLowPercent: this.deviceAlertSettingsMirror.consumableLowPercent,
|
|
83594
84412
|
renotifyMinutes: this.deviceAlertSettingsMirror.alertRenotifyMinutes,
|
|
83595
84413
|
notifyRecovery: this.deviceAlertSettingsMirror.alertNotifyRecovery
|
|
83596
84414
|
}),
|
|
@@ -89431,10 +90249,12 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
|
|
|
89431
90249
|
startLinkedStills(data, timestamp, cameras) {
|
|
89432
90250
|
const sensorKind = require_dist.EVENT_KIND_BY_CAP[data.capName]?.kind;
|
|
89433
90251
|
if (sensorKind === void 0) return void 0;
|
|
90252
|
+
const sensorRaised = require_dist.sensorRaisedOf(sensorKind, sensorSliceRecord(data.slice));
|
|
89434
90253
|
try {
|
|
89435
90254
|
return this.notificationCenter?.beginLinkedStills({
|
|
89436
90255
|
sensorId: data.deviceId,
|
|
89437
90256
|
sensorKind,
|
|
90257
|
+
...sensorRaised !== void 0 ? { sensorRaised } : {},
|
|
89438
90258
|
cameraIds: cameras.mediaCameraIds,
|
|
89439
90259
|
rowCameraIds: cameras.rowCameraIds,
|
|
89440
90260
|
atMs: timestamp
|