@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
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { $ as
|
|
1
|
+
import { $ as NcTemplateVarDescriptorSchema, $t as sensorRaisedOf, A as NC_DEFAULT_SNOOZE_MINUTES, An as unknown, At as failureContributionCapability, B as NcRuleInputSchema, Bt as mayWriteToLocation, C as MAX_BIRTH_DECISION_RECORDS, Cn as discriminatedUnion, Ct as deriveRecordingMode, D as NC_ALARM_SYSTEM_EVENT_KINDS, Dn as partialRecord, Dt as evaluateSensorEdge, E as MediaFileKindEnum, En as object, Et as encodeVectorBase64, F as NC_TEMPLATE_VARS, Ft as isMediaOwnerType, G as NcScheduleSchema, Gt as pipelineAnalyticsCapability, H as NcRuleSchema, Ht as occupancyScope, I as NcAlarmConfigSchema, It as isScheduleActive, J as NcSnoozeSuppressedSchema, Jt as readTimelapseGeneratedAt, K as NcSnoozeInputSchema, Kt as plateGalleryCapability, L as NcAlarmSettingsPatchSchema, Lt as isSourceCap, Mt as isDetectionMacroClass, N as NC_TANK_SYSTEM_EVENT_KINDS, Nt as isEventOwnerType, O as NC_CONDITION_CATALOG, On as record, Ot as evictionPolicyOfLocation, P as NC_TAXONOMY, Pt as isKnownTemplateVar, Q as NcTemplatePreviewSchema, Qt as sceneMonitorCapability, R as NcConditionDescriptorSchema, Rt as isSystemDelivery, S as MAX_ARCHIVED_DEBUG_NOTES, Sn as boolean, St as cosineSimilarity$1, T as MOTION_CLOSE_AFTER_MS, Tn as number, U as NcRuleTargetSchema, V as NcRulePatchSchema, Vt as notificationRulesCapability, W as NcRuleUpdateInputSchema, Wt as pickClusterStepModels, X as NcTaxonomySchema, Y as NcSystemEventKindSchema, Yt as resolveLocationMode, Z as NcTemplatePreviewInputSchema, Zt as ruleBelongsToAlarm$1, _ as FIRST_LEVEL_MACRO_CLASSES, _n as isDeviceScopedCap, _t as assertTimelapseCadences, a as BirthDecisionRecordSchema, an as templateVarsFor, b as LabelAttributionSchema, bn as _enum, bt as buildEventKindDescriptor, c as CoverStateSchema, cn as errMsg$1, ct as SCENE_DIVERGED, dn as CamProfileSchema, dt as TimelapseRuleInputSchema, en as servedTemplateVars, et as OpsLogEntrySchema, f as DETECTION_MACRO_CLASSES, fn as DeviceType, ft as TimelapseRulePatchSchema, g as EVENT_OWNER_TYPES, gn as hydrateSchema, gt as alarmPanelCapability, h as EVENT_KIND_BY_CAP, hn as defineCustomActions, ht as addonWidgetsSourceCapability, i as BaseDevice, in as systemEventFilterApplies, it as RetrainStatusSchema, j as NC_SENSOR_TRANSITION_KINDS, jn as EventCategory, k as NC_CONSUMABLE_SYSTEM_EVENT_KINDS, kn as string, kt as faceGalleryCapability, l as DEFAULT_EVENT_COLOR, ln as canonicalHash, lt as SceneMonitorSchema, m as DeclaredDevices, mn as customAction, mt as TrackSourceSchema, n as AlarmArmModeSchema, nn as subKindsOf, nt as RECORDING_EXPORT_MAX_READ_BYTES, o as CLUSTER_MODEL_SCOPED_STEPS, on as vectorDimFromBase64, ot as SCENE_DEFAULT_ANCHOR_THRESHOLD, pn as createEvent, pt as TimelapseRuleSchema, q as NcSnoozeSchema, qt as readDeviceStateFrom, r as ArchivedDebugNoteSchema, rn as systemEventFamilyProblems, rt as RECORDING_SOURCE_CAMSTACK_ADDON, s as COCO_TO_MACRO, sn as zoneAnalyticsCapability, st as SCENE_DEFAULT_UNCOVERED_POLICY, t as AUDIO_MACRO_LABELS, tn as storageOccupancyCapability, un as BaseAddon, ut as TIMELAPSE_DENSE_FLOOR_SEC, v as FULL_IMAGE_BBOX, vn as nodePin, vt as audioMetricsCapability, wn as literal, wt as describeSystemEventFamilyProblem, x as LockStateSchema, xn as array, xt as consumableLowPercentOf, y as FailureCounters, yn as sleep$1, yt as audioModeOf, z as NcConditionsSchema, zt as kebabToCamel } from "../dist-zp1v7cCp.mjs";
|
|
2
2
|
import { t as __exportAll } from "../embedding-encoder/index.mjs";
|
|
3
3
|
import * as fs from "node:fs";
|
|
4
4
|
import { promises } from "node:fs";
|
|
@@ -281,6 +281,7 @@ function coverageFor(rules, panelDeviceId, mode, skipProbe) {
|
|
|
281
281
|
const ids = /* @__PURE__ */ new Set();
|
|
282
282
|
let ruleCount = 0;
|
|
283
283
|
let allDevices = false;
|
|
284
|
+
let except = null;
|
|
284
285
|
for (const rule of rules) {
|
|
285
286
|
if (!rule.enabled) continue;
|
|
286
287
|
const gate = rule.conditions.deviceState;
|
|
@@ -288,21 +289,35 @@ function coverageFor(rules, panelDeviceId, mode, skipProbe) {
|
|
|
288
289
|
if (!gate.states.includes(wanted)) continue;
|
|
289
290
|
ruleCount += 1;
|
|
290
291
|
const scope = rule.conditions.devices;
|
|
292
|
+
const excluded = rule.conditions.devicesExclude ?? [];
|
|
291
293
|
if (scope === void 0 || scope.length === 0) {
|
|
292
294
|
allDevices = true;
|
|
295
|
+
except = narrowExcept(except, excluded);
|
|
293
296
|
continue;
|
|
294
297
|
}
|
|
295
|
-
for (const id of scope) ids.add(id);
|
|
298
|
+
for (const id of scope) if (!excluded.includes(id)) ids.add(id);
|
|
296
299
|
}
|
|
297
300
|
const deviceIds = [...ids].toSorted((a, b) => a - b);
|
|
301
|
+
const allDevicesExcept = except === null || except === "none" ? [] : [...except].filter((id) => !ids.has(id)).toSorted((a, b) => a - b);
|
|
298
302
|
return {
|
|
299
303
|
mode,
|
|
300
304
|
ruleCount,
|
|
301
305
|
allDevices,
|
|
306
|
+
allDevicesExcept,
|
|
302
307
|
deviceIds,
|
|
303
308
|
skippedDevices: skippedFor(deviceIds, skipProbe)
|
|
304
309
|
};
|
|
305
310
|
}
|
|
311
|
+
/**
|
|
312
|
+
* Fold one UNSCOPED covering rule into the except-set: with no exclusion it
|
|
313
|
+
* covers everything (`'none'`, final); otherwise only what every unscoped rule
|
|
314
|
+
* excludes stays uncovered — the intersection.
|
|
315
|
+
*/
|
|
316
|
+
function narrowExcept(current, excluded) {
|
|
317
|
+
if (current === "none" || excluded.length === 0) return "none";
|
|
318
|
+
if (current === null) return new Set(excluded);
|
|
319
|
+
return new Set(excluded.filter((id) => current.has(id)));
|
|
320
|
+
}
|
|
306
321
|
function skippedFor(deviceIds, skipProbe) {
|
|
307
322
|
if (skipProbe === void 0) return [];
|
|
308
323
|
const out = [];
|
|
@@ -376,6 +391,19 @@ function buildArmAnnouncement(coverage, deviceName, bypassed = []) {
|
|
|
376
391
|
textCount: names.length
|
|
377
392
|
};
|
|
378
393
|
}
|
|
394
|
+
if (coverage.allDevices && coverage.allDevicesExcept.length > 0) {
|
|
395
|
+
const names = coverage.allDevicesExcept.map(deviceName);
|
|
396
|
+
return {
|
|
397
|
+
bodyKey: "alarm.arm.body.allDevicesExcept",
|
|
398
|
+
textParams: {
|
|
399
|
+
rules,
|
|
400
|
+
mode,
|
|
401
|
+
count: `${names.length}`,
|
|
402
|
+
names: names.join(", ")
|
|
403
|
+
},
|
|
404
|
+
textCount: names.length
|
|
405
|
+
};
|
|
406
|
+
}
|
|
379
407
|
if (coverage.allDevices) return {
|
|
380
408
|
bodyKey: "alarm.arm.body.allDevices",
|
|
381
409
|
textParams: {
|
|
@@ -594,7 +622,8 @@ function alarmCoveredDevices(rules, panelDeviceId, mode) {
|
|
|
594
622
|
if (!gate.states.includes(wanted)) continue;
|
|
595
623
|
const scope = rule.conditions.devices;
|
|
596
624
|
if (scope === void 0 || scope.length === 0) continue;
|
|
597
|
-
|
|
625
|
+
const excluded = rule.conditions.devicesExclude ?? [];
|
|
626
|
+
for (const id of scope) if (!excluded.includes(id)) ids.add(id);
|
|
598
627
|
}
|
|
599
628
|
return [...ids].toSorted((a, b) => a - b);
|
|
600
629
|
}
|
|
@@ -6627,6 +6656,8 @@ var SceneEngine = class {
|
|
|
6627
6656
|
var DEFAULT_LOAD_LIMIT = 1e5;
|
|
6628
6657
|
var DurableLedger = class DurableLedger {
|
|
6629
6658
|
mirror = /* @__PURE__ */ new Map();
|
|
6659
|
+
/** Whether ANY load has read the store successfully — see {@link hasAnswered}. */
|
|
6660
|
+
answered = false;
|
|
6630
6661
|
spec;
|
|
6631
6662
|
store;
|
|
6632
6663
|
logger;
|
|
@@ -6682,6 +6713,7 @@ var DurableLedger = class DurableLedger {
|
|
|
6682
6713
|
}
|
|
6683
6714
|
this.mirror.clear();
|
|
6684
6715
|
for (const [key, row] of next) this.mirror.set(key, row);
|
|
6716
|
+
this.answered = true;
|
|
6685
6717
|
if (skipped > 0) this.logger.warn("durable rows skipped as malformed — they gate NOTHING", { meta: {
|
|
6686
6718
|
collection: this.spec.collection,
|
|
6687
6719
|
skipped
|
|
@@ -6696,6 +6728,16 @@ var DurableLedger = class DurableLedger {
|
|
|
6696
6728
|
return [...this.mirror.values()];
|
|
6697
6729
|
}
|
|
6698
6730
|
}
|
|
6731
|
+
/**
|
|
6732
|
+
* Whether the mirror is an ANSWER from the store: true once any load has
|
|
6733
|
+
* succeeded, and never false again (a later failed load keeps that answer).
|
|
6734
|
+
* Before it, an empty mirror is "not read yet", never "the store is empty" —
|
|
6735
|
+
* {@link load} swallows its failure, so a caller that must tell the two apart
|
|
6736
|
+
* asks here (D642 review I1).
|
|
6737
|
+
*/
|
|
6738
|
+
hasAnswered() {
|
|
6739
|
+
return this.answered;
|
|
6740
|
+
}
|
|
6699
6741
|
/** Every mirrored row, insertion-ordered. */
|
|
6700
6742
|
snapshot() {
|
|
6701
6743
|
return [...this.mirror.values()];
|
|
@@ -7788,10 +7830,10 @@ function replaceConditionReferences(monitor, condition, reference) {
|
|
|
7788
7830
|
//#endregion
|
|
7789
7831
|
//#region src/notification-center/snooze-exempt.ts
|
|
7790
7832
|
/** The cap whose presence in a sequence makes the rule the alarm's trigger. */
|
|
7791
|
-
var ALARM_PANEL_CAP$
|
|
7833
|
+
var ALARM_PANEL_CAP$1 = "alarm-panel";
|
|
7792
7834
|
var ALARM_KINDS = new Set(NC_ALARM_SYSTEM_EVENT_KINDS);
|
|
7793
7835
|
function actuatesAlarmPanel(sequences) {
|
|
7794
|
-
for (const sequence of sequences) for (const step of sequence.actions) if (step.kind === "cap" && step.cap === ALARM_PANEL_CAP$
|
|
7836
|
+
for (const sequence of sequences) for (const step of sequence.actions) if (step.kind === "cap" && step.cap === ALARM_PANEL_CAP$1) return true;
|
|
7795
7837
|
return false;
|
|
7796
7838
|
}
|
|
7797
7839
|
/**
|
|
@@ -8429,6 +8471,78 @@ var NcAlertStore = class {
|
|
|
8429
8471
|
};
|
|
8430
8472
|
}
|
|
8431
8473
|
};
|
|
8474
|
+
//#endregion
|
|
8475
|
+
//#region src/notification-center/alerts/consumable-subject.ts
|
|
8476
|
+
/**
|
|
8477
|
+
* A consumable EPISODE's name, and the thresholds the rule set asks for (D642).
|
|
8478
|
+
*
|
|
8479
|
+
* Before D642 one cluster-wide threshold judged every item, so an item had one
|
|
8480
|
+
* episode: `consumable:<deviceId>:<key>`. The threshold is now the RULE's, and
|
|
8481
|
+
* the ledger keeps one episode per threshold a rule asks for — each with its
|
|
8482
|
+
* own hysteresis, pending raise and reminder — named
|
|
8483
|
+
* `consumable@<threshold>:<deviceId>:<key>`.
|
|
8484
|
+
*
|
|
8485
|
+
* The threshold rides the FAMILY segment on purpose: the device id stays the
|
|
8486
|
+
* second segment (`alertSubjectDeviceId`, D636 — the log tag and the indexed
|
|
8487
|
+
* column read it), and the item key stays last and whole, whatever it holds.
|
|
8488
|
+
*
|
|
8489
|
+
* Pure. No clock, no I/O.
|
|
8490
|
+
*/
|
|
8491
|
+
var FAMILY = "consumable";
|
|
8492
|
+
/** The episode of `key` on `deviceId`, judged at `thresholdPercent`. */
|
|
8493
|
+
function consumableSubject(thresholdPercent, deviceId, key) {
|
|
8494
|
+
return `${FAMILY}@${String(thresholdPercent)}:${String(deviceId)}:${key}`;
|
|
8495
|
+
}
|
|
8496
|
+
/** The pre-D642 subject of the same item — read only to carry it over. */
|
|
8497
|
+
function legacyConsumableSubject(deviceId, key) {
|
|
8498
|
+
return `${FAMILY}:${String(deviceId)}:${key}`;
|
|
8499
|
+
}
|
|
8500
|
+
/** `null` for a subject that is not a consumable's. */
|
|
8501
|
+
function parseConsumableSubject(subject) {
|
|
8502
|
+
const first = subject.indexOf(":");
|
|
8503
|
+
const second = first < 0 ? -1 : subject.indexOf(":", first + 1);
|
|
8504
|
+
if (first < 0 || second < 0) return null;
|
|
8505
|
+
const family = subject.slice(0, first);
|
|
8506
|
+
const deviceId = Number(subject.slice(first + 1, second));
|
|
8507
|
+
const key = subject.slice(second + 1);
|
|
8508
|
+
if (!Number.isInteger(deviceId) || key.length === 0) return null;
|
|
8509
|
+
if (family === FAMILY) return {
|
|
8510
|
+
thresholdPercent: 15,
|
|
8511
|
+
deviceId,
|
|
8512
|
+
key,
|
|
8513
|
+
legacy: true
|
|
8514
|
+
};
|
|
8515
|
+
if (!family.startsWith(`${FAMILY}@`)) return null;
|
|
8516
|
+
const thresholdPercent = Number(family.slice(11));
|
|
8517
|
+
if (!Number.isInteger(thresholdPercent)) return null;
|
|
8518
|
+
return {
|
|
8519
|
+
thresholdPercent,
|
|
8520
|
+
deviceId,
|
|
8521
|
+
key,
|
|
8522
|
+
legacy: false
|
|
8523
|
+
};
|
|
8524
|
+
}
|
|
8525
|
+
/**
|
|
8526
|
+
* Every consumable threshold the rule set asks for, ascending.
|
|
8527
|
+
*
|
|
8528
|
+
* A rule asks when it is a system-event rule whose kinds include a consumable
|
|
8529
|
+
* kind — enabled or not: a disabled rule's episode stays PENDING (no rule
|
|
8530
|
+
* matched it) and is re-offered the moment the rule is switched back on
|
|
8531
|
+
* (D636), which is what that rule would have said had it been on. With no such
|
|
8532
|
+
* rule at all the default is still judged, so an episode exists for the first
|
|
8533
|
+
* rule an operator writes to hear (D636's "offered once per rule change").
|
|
8534
|
+
*/
|
|
8535
|
+
function consumableThresholdCensus(rules) {
|
|
8536
|
+
const thresholds = /* @__PURE__ */ new Set();
|
|
8537
|
+
for (const rule of rules) {
|
|
8538
|
+
const condition = rule.conditions.systemEvent;
|
|
8539
|
+
if (rule.delivery !== "system-event" || condition === void 0) continue;
|
|
8540
|
+
if (!condition.kinds.some((kind) => NC_CONSUMABLE_SYSTEM_EVENT_KINDS.includes(kind))) continue;
|
|
8541
|
+
thresholds.add(consumableLowPercentOf(condition));
|
|
8542
|
+
}
|
|
8543
|
+
if (thresholds.size === 0) thresholds.add(15);
|
|
8544
|
+
return [...thresholds].toSorted((a, b) => a - b);
|
|
8545
|
+
}
|
|
8432
8546
|
function deriveConsumableBand(input) {
|
|
8433
8547
|
const { level, status } = input.reading;
|
|
8434
8548
|
const measured = level !== null && Number.isFinite(level);
|
|
@@ -9556,6 +9670,7 @@ function isLinkedSetSubject(subject) {
|
|
|
9556
9670
|
function subjectFromSensorEvent(ev, markerTrackId, linked) {
|
|
9557
9671
|
const eventType = readSensorEventType(ev.value);
|
|
9558
9672
|
const sensorEdge = sensorEdgeOf(ev.kind, ev.value);
|
|
9673
|
+
const sensorRaised = sensorRaisedOf(ev.kind, ev.value);
|
|
9559
9674
|
return {
|
|
9560
9675
|
kind: "device-event",
|
|
9561
9676
|
recordId: String(ev.id),
|
|
@@ -9569,6 +9684,7 @@ function subjectFromSensorEvent(ev, markerTrackId, linked) {
|
|
|
9569
9684
|
sensorKind: ev.kind,
|
|
9570
9685
|
...eventType !== void 0 ? { eventType } : {},
|
|
9571
9686
|
...sensorEdge !== void 0 ? { sensorEdge } : {},
|
|
9687
|
+
...sensorRaised !== void 0 ? { sensorRaised } : {},
|
|
9572
9688
|
...linked !== void 0 ? {
|
|
9573
9689
|
linkedSensorId: ev.sourceDeviceId,
|
|
9574
9690
|
linkedCameraIds: [...linked.linkedCameraIds],
|
|
@@ -9796,6 +9912,7 @@ function presentConditionIds(c) {
|
|
|
9796
9912
|
const ids = [];
|
|
9797
9913
|
if (c.systemEvent !== void 0) ids.push("systemEvent");
|
|
9798
9914
|
if (c.devices !== void 0 && c.devices.length > 0) ids.push("devices");
|
|
9915
|
+
if (c.devicesExclude !== void 0 && c.devicesExclude.length > 0) ids.push("devicesExclude");
|
|
9799
9916
|
if (c.source !== void 0 && c.source !== "any") ids.push("source");
|
|
9800
9917
|
if (c.classes !== void 0 && c.classes.length > 0) ids.push("classes");
|
|
9801
9918
|
if (c.classesExclude !== void 0 && c.classesExclude.length > 0) ids.push("classesExclude");
|
|
@@ -9812,6 +9929,7 @@ function presentConditionIds(c) {
|
|
|
9812
9929
|
if (c.plates !== void 0) ids.push("plates");
|
|
9813
9930
|
if (c.sensorKinds !== void 0 && c.sensorKinds.length > 0) ids.push("sensorKinds");
|
|
9814
9931
|
if (c.eventTypeTokens !== void 0 && c.eventTypeTokens.length > 0) ids.push("eventTypeTokens");
|
|
9932
|
+
if (c.sensorTransition !== void 0) ids.push("sensorTransition");
|
|
9815
9933
|
if (c.packagePhase !== void 0 && c.packagePhase !== "both") ids.push("packagePhase");
|
|
9816
9934
|
if (c.customZones !== void 0 && c.customZones.length > 0) ids.push("customZones");
|
|
9817
9935
|
if (c.occupancy !== void 0) ids.push("occupancy");
|
|
@@ -9906,12 +10024,23 @@ function zonesVisitedBy(subject) {
|
|
|
9906
10024
|
* before the subject exists (the linked-camera capture gate). Coarse in one
|
|
9907
10025
|
* direction only: `true` may still fail later; `false` never would have fired.
|
|
9908
10026
|
*/
|
|
9909
|
-
function deviceEventRuleMayFireOnKind(rule, sensorKind) {
|
|
10027
|
+
function deviceEventRuleMayFireOnKind(rule, sensorKind, sensorRaised) {
|
|
9910
10028
|
const c = rule.conditions;
|
|
9911
10029
|
if (c.occupancy !== void 0) return false;
|
|
10030
|
+
if (sensorTransitionRefuses(c.sensorTransition, sensorRaised)) return false;
|
|
9912
10031
|
if (c.sensorKinds === void 0 || c.sensorKinds.length === 0) return true;
|
|
9913
10032
|
return toLowerSet(c.sensorKinds).has(sensorKind.trim().toLowerCase());
|
|
9914
10033
|
}
|
|
10034
|
+
/**
|
|
10035
|
+
* Does a rule's `sensorTransition` refuse this edge? Only a KNOWN edge can be
|
|
10036
|
+
* refused: `sensorRaised === undefined` (a kind with no raise, an unreadable
|
|
10037
|
+
* slice) always passes — a flood rule never goes silent on a bad read (D640).
|
|
10038
|
+
* The ONE predicate {@link evaluateRule} and the capture gate both ask.
|
|
10039
|
+
*/
|
|
10040
|
+
function sensorTransitionRefuses(transition, sensorRaised) {
|
|
10041
|
+
if (transition === void 0 || sensorRaised === void 0) return false;
|
|
10042
|
+
return sensorRaised !== (transition === "raised");
|
|
10043
|
+
}
|
|
9915
10044
|
function toLowerSet(values) {
|
|
9916
10045
|
return new Set(values.map((v) => v.trim().toLowerCase()));
|
|
9917
10046
|
}
|
|
@@ -9948,9 +10077,6 @@ function matchesIdentityScope(subject, scope) {
|
|
|
9948
10077
|
function ruleAwaitsRecognition(rule) {
|
|
9949
10078
|
return rule.conditions.identities !== void 0 || rule.conditions.plates !== void 0;
|
|
9950
10079
|
}
|
|
9951
|
-
var ARMED_WORD_PREFIX = "armed_";
|
|
9952
|
-
var ALARM_PANEL_CAP$1 = "alarm-panel";
|
|
9953
|
-
var ALARM_PANEL_TRIGGER_METHOD = "trigger";
|
|
9954
10080
|
/**
|
|
9955
10081
|
* A rule "belongs to the alarm" when its `deviceState` gate names ANY device's
|
|
9956
10082
|
* `armed_*` word — CamStack's own panel or any other, e.g. an HA-mirrored
|
|
@@ -9968,8 +10094,10 @@ var ALARM_PANEL_TRIGGER_METHOD = "trigger";
|
|
|
9968
10094
|
* catalog on save, so a new key would be dropped by the first edit.
|
|
9969
10095
|
*/
|
|
9970
10096
|
function ruleBelongsToAlarm(rule) {
|
|
9971
|
-
|
|
9972
|
-
|
|
10097
|
+
return ruleBelongsToAlarm$1({
|
|
10098
|
+
conditions: rule.conditions,
|
|
10099
|
+
...rule.actions?.onTrigger !== void 0 ? { onTrigger: rule.actions.onTrigger } : {}
|
|
10100
|
+
});
|
|
9973
10101
|
}
|
|
9974
10102
|
/**
|
|
9975
10103
|
* The edge an alarm rule refuses: a close, or a halt/fault (a cover stopped
|
|
@@ -10078,6 +10206,9 @@ function systemEventExclusion(condition, subject) {
|
|
|
10078
10206
|
if (wantedPackages !== void 0 && applies("packageNames")) {
|
|
10079
10207
|
if (!subjectPackages(subject).some((name) => wantedPackages.includes(name))) return "packageNames";
|
|
10080
10208
|
}
|
|
10209
|
+
if (applies("consumableLowPercent") && subject.consumableLowPercent !== void 0) {
|
|
10210
|
+
if (subject.consumableLowPercent !== consumableLowPercentOf(condition)) return "consumableLowPercent";
|
|
10211
|
+
}
|
|
10081
10212
|
}
|
|
10082
10213
|
function subjectNodes(subject) {
|
|
10083
10214
|
const ids = [...subject.nodeIds ?? []];
|
|
@@ -10145,6 +10276,7 @@ function evaluateRule(rule, subject, deviceState, zoneOwners, sceneStates) {
|
|
|
10145
10276
|
if (!sceneConditionSatisfied(c.scene, observation)) return fail("scene");
|
|
10146
10277
|
}
|
|
10147
10278
|
if (c.devices !== void 0 && c.devices.length > 0 && !matchesDeviceScope(c.devices, subject)) return fail("devices");
|
|
10279
|
+
if (c.devicesExclude !== void 0 && deviceScopeExcludes(c.devicesExclude, subject)) return fail("devicesExclude");
|
|
10148
10280
|
if (c.source !== void 0 && c.source !== "any") {
|
|
10149
10281
|
if ((subject.source ?? "pipeline") !== c.source) return fail("source");
|
|
10150
10282
|
}
|
|
@@ -10221,6 +10353,7 @@ function evaluateRule(rule, subject, deviceState, zoneOwners, sceneStates) {
|
|
|
10221
10353
|
if (c.eventTypeTokens !== void 0 && c.eventTypeTokens.length > 0) {
|
|
10222
10354
|
if (subject.eventType === void 0 || !toLowerSet(c.eventTypeTokens).has(subject.eventType.trim().toLowerCase())) return fail("eventTypeTokens");
|
|
10223
10355
|
}
|
|
10356
|
+
if (sensorTransitionRefuses(c.sensorTransition, subject.sensorRaised)) return fail("sensorTransition");
|
|
10224
10357
|
if (c.packagePhase !== void 0 && c.packagePhase !== "both") {
|
|
10225
10358
|
if (subject.packagePhase !== c.packagePhase) return fail("packagePhase");
|
|
10226
10359
|
}
|
|
@@ -10326,6 +10459,27 @@ function matchesDeviceScope(scope, subject) {
|
|
|
10326
10459
|
if (scope.includes(subject.deviceId)) return true;
|
|
10327
10460
|
return subject.sourceDeviceId !== void 0 && scope.includes(subject.sourceDeviceId);
|
|
10328
10461
|
}
|
|
10462
|
+
/**
|
|
10463
|
+
* Does a rule's `devicesExclude` (D641) exclude this subject?
|
|
10464
|
+
*
|
|
10465
|
+
* The exact complement of {@link matchesDeviceScope}, row by row. A per-camera
|
|
10466
|
+
* row is excluded by its camera OR by the sensor that fired, the two ids an
|
|
10467
|
+
* include matches on. A linked set stands for the rows it replaced (D630 §6):
|
|
10468
|
+
* it is excluded when its SENSOR is, or when EVERY camera that took a row is —
|
|
10469
|
+
* one excluded row camera leaves the others firing, as their own rows would
|
|
10470
|
+
* have. A camera that only LINKS the sensor excludes nothing, for the reason it
|
|
10471
|
+
* includes nothing: ADR 0632 widens the media, never which rules fire.
|
|
10472
|
+
*/
|
|
10473
|
+
function deviceScopeExcludes(excluded, subject) {
|
|
10474
|
+
if (excluded.length === 0) return false;
|
|
10475
|
+
if (subject.linkedSensorId !== void 0) {
|
|
10476
|
+
if (excluded.includes(subject.linkedSensorId)) return true;
|
|
10477
|
+
const rows = subject.rowCameraIds ?? [];
|
|
10478
|
+
return rows.length > 0 && rows.every((cameraId) => excluded.includes(cameraId));
|
|
10479
|
+
}
|
|
10480
|
+
if (excluded.includes(subject.deviceId)) return true;
|
|
10481
|
+
return subject.sourceDeviceId !== void 0 && excluded.includes(subject.sourceDeviceId);
|
|
10482
|
+
}
|
|
10329
10483
|
function cooldownKey(rule, subject) {
|
|
10330
10484
|
const first = subject.classNames[0];
|
|
10331
10485
|
const classKey = keysPerClass(rule, subject) && first !== void 0 ? `:c:${first}` : "";
|
|
@@ -10934,6 +11088,7 @@ function systemSubject(input) {
|
|
|
10934
11088
|
...input.packageNames !== void 0 ? { packageNames: input.packageNames } : {},
|
|
10935
11089
|
...input.textCount !== void 0 ? { textCount: input.textCount } : {},
|
|
10936
11090
|
...input.alarmSource !== void 0 ? { alarmSource: input.alarmSource } : {},
|
|
11091
|
+
...input.consumableLowPercent !== void 0 ? { consumableLowPercent: input.consumableLowPercent } : {},
|
|
10937
11092
|
data
|
|
10938
11093
|
}
|
|
10939
11094
|
},
|
|
@@ -11035,9 +11190,11 @@ function incomingFromBatteryBand(input) {
|
|
|
11035
11190
|
* these to be treated as the battery notifications are — with two differences
|
|
11036
11191
|
* that come from the subject and not from taste:
|
|
11037
11192
|
*
|
|
11038
|
-
* - **the subject carries the ITEM.** `consumable
|
|
11039
|
-
* vacuum has a brush AND a filter AND a mop pad, and one subject
|
|
11040
|
-
* three would let the brush's alert cancel the filter's.
|
|
11193
|
+
* - **the subject carries the ITEM.** `consumable@<threshold>:<deviceId>:<key>`,
|
|
11194
|
+
* because a vacuum has a brush AND a filter AND a mop pad, and one subject
|
|
11195
|
+
* for all three would let the brush's alert cancel the filter's. It carries
|
|
11196
|
+
* the THRESHOLD too (D642): each rule's threshold is its own episode, and
|
|
11197
|
+
* the engine pairs a rule with the episode judged at its own number.
|
|
11041
11198
|
* - **the body variant follows what is KNOWN.** An item with no percentage
|
|
11042
11199
|
* gets a finished sentence rather than a template with a stand-in number in
|
|
11043
11200
|
* it, for the same reason a binary battery does.
|
|
@@ -11046,17 +11203,46 @@ function incomingFromBatteryBand(input) {
|
|
|
11046
11203
|
*/
|
|
11047
11204
|
function incomingFromConsumableBand(input) {
|
|
11048
11205
|
const kind = input.band === "low" ? "device-consumable-low" : "device-consumable-normal";
|
|
11049
|
-
const
|
|
11206
|
+
const level = input.item.level;
|
|
11207
|
+
const percentage = level !== null && Number.isFinite(level) ? Math.round(level) : null;
|
|
11208
|
+
const measured = percentage !== null;
|
|
11050
11209
|
return systemSubject({
|
|
11051
11210
|
facts: input.facts,
|
|
11052
11211
|
kind,
|
|
11053
|
-
subject:
|
|
11212
|
+
subject: consumableSubject(input.thresholdPercent, input.deviceId, input.item.key),
|
|
11054
11213
|
titleKey: `system.${kind}.title`,
|
|
11055
11214
|
bodyKey: measured ? `system.${kind}.body` : `system.${kind}.body.unmeasured`,
|
|
11056
11215
|
textParams: {
|
|
11057
11216
|
...deviceTextParams(input.deviceId, input.identity),
|
|
11058
11217
|
item: input.item.label,
|
|
11059
|
-
...
|
|
11218
|
+
...percentage !== null ? { percentage: String(percentage) } : {}
|
|
11219
|
+
},
|
|
11220
|
+
deviceId: input.deviceId,
|
|
11221
|
+
consumableLowPercent: input.thresholdPercent,
|
|
11222
|
+
...input.identity !== void 0 ? { deviceType: input.identity.type } : {}
|
|
11223
|
+
});
|
|
11224
|
+
}
|
|
11225
|
+
/**
|
|
11226
|
+
* A vacuum TANK band, as a system-event subject (D642).
|
|
11227
|
+
*
|
|
11228
|
+
* Built like {@link incomingFromConsumableBand}: `tank:<deviceId>:<tank>` is
|
|
11229
|
+
* one subject per tank (the dust bin's alert must not cancel the clean water's),
|
|
11230
|
+
* and the recovery keeps it. The BODY VARIANT names the tank in the reader's
|
|
11231
|
+
* language — the params are frozen before the language is known, so the tank's
|
|
11232
|
+
* name cannot be a param; `tank` carries the stable token for a custom
|
|
11233
|
+
* template.
|
|
11234
|
+
*/
|
|
11235
|
+
function incomingFromTankBand(input) {
|
|
11236
|
+
const kind = input.band === "low" ? "device-tank-low" : input.band === "full" ? "device-tank-full" : "device-tank-normal";
|
|
11237
|
+
return systemSubject({
|
|
11238
|
+
facts: input.facts,
|
|
11239
|
+
kind,
|
|
11240
|
+
subject: `tank:${String(input.deviceId)}:${input.tank}`,
|
|
11241
|
+
titleKey: `system.${kind}.title`,
|
|
11242
|
+
bodyKey: `system.${kind}.body.${input.tank}`,
|
|
11243
|
+
textParams: {
|
|
11244
|
+
...deviceTextParams(input.deviceId, input.identity),
|
|
11245
|
+
tank: input.tank
|
|
11060
11246
|
},
|
|
11061
11247
|
deviceId: input.deviceId,
|
|
11062
11248
|
...input.identity !== void 0 ? { deviceType: input.identity.type } : {}
|
|
@@ -11644,6 +11830,91 @@ function deliveryForKind(kind) {
|
|
|
11644
11830
|
return kind;
|
|
11645
11831
|
}
|
|
11646
11832
|
//#endregion
|
|
11833
|
+
//#region src/notification-center/alerts/tank-band.ts
|
|
11834
|
+
/**
|
|
11835
|
+
* A vacuum TANK reduced to the only thing worth storing: its band (D642).
|
|
11836
|
+
*
|
|
11837
|
+
* The `vacuum-control` slice carries four tanks — clean water, dirty water,
|
|
11838
|
+
* detergent, dust bin — each `null` when the hardware has none, else
|
|
11839
|
+
* `{ level, status }`. The operator asked for them to notify like consumables:
|
|
11840
|
+
* clean water / detergent running low, dirty water / dust bin full.
|
|
11841
|
+
*
|
|
11842
|
+
* ## The verdict is the provider's
|
|
11843
|
+
*
|
|
11844
|
+
* A tank's `status` is the device's own float switch (`ok` | `low` | `full`),
|
|
11845
|
+
* and it is the ONLY thing judged. The hub has no threshold for a tank level:
|
|
11846
|
+
* the rule for thresholds is that they belong to whoever generates the event,
|
|
11847
|
+
* and a tank that says `low` has already decided. A tank that reports only a
|
|
11848
|
+
* `level` is therefore not judged at all (`null`) rather than judged against a
|
|
11849
|
+
* number the hub would have to invent.
|
|
11850
|
+
*
|
|
11851
|
+
* ## `null` is the third answer, and it is not `normal`
|
|
11852
|
+
*
|
|
11853
|
+
* An unknown status says nothing — it neither opens nor closes an episode, for
|
|
11854
|
+
* the reason `battery-band.ts` gives: a reading nobody took is not evidence
|
|
11855
|
+
* that anything changed.
|
|
11856
|
+
*
|
|
11857
|
+
* Pure. No clock, no I/O.
|
|
11858
|
+
*/
|
|
11859
|
+
/** The four tanks `vacuum-control` names, by their slice field. */
|
|
11860
|
+
var NC_TANK_KEYS = [
|
|
11861
|
+
"cleanWater",
|
|
11862
|
+
"dirtyWater",
|
|
11863
|
+
"detergent",
|
|
11864
|
+
"dustBin"
|
|
11865
|
+
];
|
|
11866
|
+
/**
|
|
11867
|
+
* How long a tank change must HOLD before it counts (D642 review M2).
|
|
11868
|
+
*
|
|
11869
|
+
* A float switch sloshing at the edge of `low` / `full` flips its status back
|
|
11870
|
+
* and forth while the robot moves; each flip is a durable transition, and
|
|
11871
|
+
* without a hold each one is a raise and a recovery on the operator's phone.
|
|
11872
|
+
* One minute is two reload ticks: long enough to outlast a slosh, short enough
|
|
11873
|
+
* that a genuinely full dust bin is said within a minute and a half.
|
|
11874
|
+
*/
|
|
11875
|
+
var NC_TANK_HOLD_MS = 6e4;
|
|
11876
|
+
function deriveTankBand(reading) {
|
|
11877
|
+
switch (reading.status) {
|
|
11878
|
+
case "low": return "low";
|
|
11879
|
+
case "full": return "full";
|
|
11880
|
+
case "ok": return "normal";
|
|
11881
|
+
case null: return null;
|
|
11882
|
+
}
|
|
11883
|
+
}
|
|
11884
|
+
function isRecord$4(value) {
|
|
11885
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
11886
|
+
}
|
|
11887
|
+
function tankStatusOf(value) {
|
|
11888
|
+
return value === "ok" || value === "low" || value === "full" ? value : null;
|
|
11889
|
+
}
|
|
11890
|
+
/**
|
|
11891
|
+
* A `device.state-changed` payload carrying a `vacuum-control` slice, reduced
|
|
11892
|
+
* to its tanks. `null` for any other cap. A tank the hardware does not have
|
|
11893
|
+
* (`null`) is skipped; a malformed field reads as unknown, never as a
|
|
11894
|
+
* fabricated verdict (D393: absent is not zero).
|
|
11895
|
+
*/
|
|
11896
|
+
function vacuumTanksOf(data) {
|
|
11897
|
+
if (data["capName"] !== "vacuum-control") return null;
|
|
11898
|
+
const deviceId = data["deviceId"];
|
|
11899
|
+
const slice = data["slice"];
|
|
11900
|
+
if (typeof deviceId !== "number" || !isRecord$4(slice)) return null;
|
|
11901
|
+
const tanks = [];
|
|
11902
|
+
for (const key of NC_TANK_KEYS) {
|
|
11903
|
+
const raw = slice[key];
|
|
11904
|
+
if (!isRecord$4(raw)) continue;
|
|
11905
|
+
const level = raw["level"];
|
|
11906
|
+
tanks.push({
|
|
11907
|
+
key,
|
|
11908
|
+
level: typeof level === "number" && Number.isFinite(level) ? level : null,
|
|
11909
|
+
status: tankStatusOf(raw["status"])
|
|
11910
|
+
});
|
|
11911
|
+
}
|
|
11912
|
+
return tanks.length === 0 ? null : {
|
|
11913
|
+
deviceId,
|
|
11914
|
+
tanks
|
|
11915
|
+
};
|
|
11916
|
+
}
|
|
11917
|
+
//#endregion
|
|
11647
11918
|
//#region src/notification-center/alerts/battery-band.ts
|
|
11648
11919
|
/**
|
|
11649
11920
|
* The stand-in a provider writes for a binary NORMAL. Anything below it, on a
|
|
@@ -11674,10 +11945,14 @@ function deriveBatteryBand(input) {
|
|
|
11674
11945
|
* `NcDeviceAlerts` — the one place a SUSTAINED device fault becomes a
|
|
11675
11946
|
* notification, and keeps becoming one until it stops being true.
|
|
11676
11947
|
*
|
|
11677
|
-
* It owns
|
|
11948
|
+
* It owns these families and nothing else:
|
|
11678
11949
|
*
|
|
11679
11950
|
* - **battery band** — `battery:<deviceId>`, fed by the `battery` runtime-state
|
|
11680
11951
|
* slice that already arrives on `device.state-changed`;
|
|
11952
|
+
* - **consumable band** — `consumable@<threshold>:<deviceId>:<key>`, one
|
|
11953
|
+
* episode per item per threshold a RULE asks for (D642);
|
|
11954
|
+
* - **tank band** — `tank:<deviceId>:<tank>`, a vacuum's tanks, judged on the
|
|
11955
|
+
* provider's own verdict (D642);
|
|
11681
11956
|
* - **the reminder tick** — the operator's "how often do you say it again".
|
|
11682
11957
|
*
|
|
11683
11958
|
* It does NOT own device liveness. `device-offline` already has a producer, a
|
|
@@ -11717,6 +11992,10 @@ var PENDING_CADENCE_MAX_MS = 36e5;
|
|
|
11717
11992
|
function pendingCadenceMs(settings) {
|
|
11718
11993
|
return settings.renotifyMinutes > 0 ? Math.min(settings.renotifyMinutes * 6e4, PENDING_CADENCE_MAX_MS) : PENDING_CADENCE_MAX_MS;
|
|
11719
11994
|
}
|
|
11995
|
+
/** The RAM key of one item's last reading — every threshold's episode reads it. */
|
|
11996
|
+
function itemKeyOf(deviceId, key) {
|
|
11997
|
+
return `${String(deviceId)}:${key}`;
|
|
11998
|
+
}
|
|
11720
11999
|
var NcDeviceAlerts = class {
|
|
11721
12000
|
deps;
|
|
11722
12001
|
now;
|
|
@@ -11728,8 +12007,28 @@ var NcDeviceAlerts = class {
|
|
|
11728
12007
|
* durable.
|
|
11729
12008
|
*/
|
|
11730
12009
|
lastReading = /* @__PURE__ */ new Map();
|
|
11731
|
-
/**
|
|
12010
|
+
/**
|
|
12011
|
+
* The last reading per consumable ITEM (`<deviceId>:<key>`), for the
|
|
12012
|
+
* reminder sweep and for judging a threshold a rule gains later (D642).
|
|
12013
|
+
*/
|
|
11732
12014
|
lastConsumable = /* @__PURE__ */ new Map();
|
|
12015
|
+
/** The last reading per tank SUBJECT, for the reminder sweep. */
|
|
12016
|
+
lastTank = /* @__PURE__ */ new Map();
|
|
12017
|
+
/**
|
|
12018
|
+
* Tank changes on HOLD (review M2): a band that would move the episode,
|
|
12019
|
+
* first seen at `since`. It counts once it has held for `NC_TANK_HOLD_MS`,
|
|
12020
|
+
* confirmed by a later slice write or by the reload tick — no timer of its
|
|
12021
|
+
* own, so nothing outlives the device. Bounded by the device's tanks (four),
|
|
12022
|
+
* and dropped with the device by {@link forget}.
|
|
12023
|
+
*/
|
|
12024
|
+
tankHold = /* @__PURE__ */ new Map();
|
|
12025
|
+
/**
|
|
12026
|
+
* Thresholds some episode row carries that the LAST census did not ask for.
|
|
12027
|
+
* A threshold's rows are retired only when two consecutive censuses agree it
|
|
12028
|
+
* is gone (D49: a read that would destroy work needs a second read to
|
|
12029
|
+
* agree). RAM only: a restart costs one more tick of patience.
|
|
12030
|
+
*/
|
|
12031
|
+
absentThresholds = /* @__PURE__ */ new Set();
|
|
11733
12032
|
/**
|
|
11734
12033
|
* Subject → WHEN its PENDING raise was last offered, and to which
|
|
11735
12034
|
* {@link NcDeviceAlertsDeps.rulesVersion}. RAM only, on purpose: after a
|
|
@@ -11787,31 +12086,137 @@ var NcDeviceAlerts = class {
|
|
|
11787
12086
|
* observed.
|
|
11788
12087
|
*/
|
|
11789
12088
|
observeConsumable(deviceId, item) {
|
|
11790
|
-
|
|
11791
|
-
|
|
12089
|
+
this.lastConsumable.set(itemKeyOf(deviceId, item.key), {
|
|
12090
|
+
deviceId,
|
|
12091
|
+
item
|
|
12092
|
+
});
|
|
12093
|
+
const thresholds = this.deps.consumableThresholds();
|
|
12094
|
+
if (thresholds === null) {
|
|
12095
|
+
this.logger()?.debug("consumable reading kept — the rules have not been read yet", {
|
|
12096
|
+
tags: { deviceId },
|
|
12097
|
+
meta: { item: item.key }
|
|
12098
|
+
});
|
|
12099
|
+
return;
|
|
12100
|
+
}
|
|
11792
12101
|
const settings = this.deps.settings();
|
|
12102
|
+
for (const thresholdPercent of thresholds) this.judgeConsumable(deviceId, item, thresholdPercent, settings);
|
|
12103
|
+
}
|
|
12104
|
+
/**
|
|
12105
|
+
* ONE vacuum tank, from a device's `vacuum-control` slice (D642).
|
|
12106
|
+
*
|
|
12107
|
+
* The provider's verdict is the band (`tank-band.ts`); an unknown status
|
|
12108
|
+
* leaves everything untouched, an open episode included.
|
|
12109
|
+
*/
|
|
12110
|
+
observeTank(deviceId, tank) {
|
|
12111
|
+
const band = deriveTankBand(tank);
|
|
12112
|
+
if (band === null) return;
|
|
12113
|
+
const subject = `tank:${String(deviceId)}:${tank.key}`;
|
|
12114
|
+
this.lastTank.set(subject, {
|
|
12115
|
+
deviceId,
|
|
12116
|
+
tank
|
|
12117
|
+
});
|
|
12118
|
+
if (!this.tankChangeHeld(subject, deviceId, band)) return;
|
|
12119
|
+
this.apply(subject, band !== "normal", this.deps.settings(), (facts, alerting) => incomingFromTankBand({
|
|
12120
|
+
facts,
|
|
12121
|
+
deviceId,
|
|
12122
|
+
band: alerting ? band : "normal",
|
|
12123
|
+
tank: tank.key,
|
|
12124
|
+
identity: this.deps.lookup(deviceId)
|
|
12125
|
+
}));
|
|
12126
|
+
}
|
|
12127
|
+
/**
|
|
12128
|
+
* Has this tank band held long enough to move its episode? A band that
|
|
12129
|
+
* would not move it (a repeat, or low ⇄ full inside one open episode) passes
|
|
12130
|
+
* at once and clears any hold — that is what makes a flapping switch start
|
|
12131
|
+
* its hold over every time it flips back. A first HEALTHY observation passes
|
|
12132
|
+
* too: it seeds silently, so there is nothing to hold.
|
|
12133
|
+
*/
|
|
12134
|
+
tankChangeHeld(subject, deviceId, band) {
|
|
12135
|
+
const row = this.deps.rows.get(subject);
|
|
12136
|
+
const alerting = band !== "normal";
|
|
12137
|
+
if (!(row === void 0 ? alerting : row.alerting !== alerting)) {
|
|
12138
|
+
this.tankHold.delete(subject);
|
|
12139
|
+
return true;
|
|
12140
|
+
}
|
|
12141
|
+
const nowMs = this.now();
|
|
12142
|
+
const held = this.tankHold.get(subject);
|
|
12143
|
+
if (held === void 0 || held.band !== band) {
|
|
12144
|
+
this.tankHold.set(subject, {
|
|
12145
|
+
band,
|
|
12146
|
+
since: nowMs
|
|
12147
|
+
});
|
|
12148
|
+
this.logger()?.debug("tank change on hold — counts once it has held", {
|
|
12149
|
+
tags: { deviceId },
|
|
12150
|
+
meta: {
|
|
12151
|
+
subject,
|
|
12152
|
+
band,
|
|
12153
|
+
holdMs: NC_TANK_HOLD_MS
|
|
12154
|
+
}
|
|
12155
|
+
});
|
|
12156
|
+
return false;
|
|
12157
|
+
}
|
|
12158
|
+
if (nowMs - held.since < 6e4) return false;
|
|
12159
|
+
this.tankHold.delete(subject);
|
|
12160
|
+
return true;
|
|
12161
|
+
}
|
|
12162
|
+
/** The tick's half of the hold: a change that has held with no new slice write. */
|
|
12163
|
+
confirmHeldTanks() {
|
|
12164
|
+
const nowMs = this.now();
|
|
12165
|
+
for (const [subject, held] of [...this.tankHold]) {
|
|
12166
|
+
if (nowMs - held.since < 6e4) continue;
|
|
12167
|
+
const reading = this.lastTank.get(subject);
|
|
12168
|
+
if (reading === void 0) {
|
|
12169
|
+
this.tankHold.delete(subject);
|
|
12170
|
+
continue;
|
|
12171
|
+
}
|
|
12172
|
+
this.observeTank(reading.deviceId, reading.tank);
|
|
12173
|
+
}
|
|
12174
|
+
}
|
|
12175
|
+
/** One item against ONE threshold: its own episode, its own hysteresis. */
|
|
12176
|
+
judgeConsumable(deviceId, item, thresholdPercent, settings) {
|
|
12177
|
+
const subject = consumableSubject(thresholdPercent, deviceId, item.key);
|
|
12178
|
+
const previous = this.deps.rows.get(subject) ?? this.adoptLegacy(subject, deviceId, item.key);
|
|
11793
12179
|
const band = deriveConsumableBand({
|
|
11794
12180
|
reading: {
|
|
11795
12181
|
level: item.level,
|
|
11796
12182
|
status: item.status
|
|
11797
12183
|
},
|
|
11798
|
-
thresholdPercent
|
|
12184
|
+
thresholdPercent,
|
|
11799
12185
|
previous: previous?.alerting === true ? "low" : previous === void 0 ? void 0 : "normal"
|
|
11800
12186
|
});
|
|
11801
12187
|
if (band === null) return;
|
|
11802
|
-
this.lastConsumable.set(subject, {
|
|
11803
|
-
deviceId,
|
|
11804
|
-
item
|
|
11805
|
-
});
|
|
11806
12188
|
this.apply(subject, band === "low", settings, (facts, alerting) => incomingFromConsumableBand({
|
|
11807
12189
|
facts,
|
|
11808
12190
|
deviceId,
|
|
11809
12191
|
band: alerting ? "low" : "normal",
|
|
11810
12192
|
item,
|
|
12193
|
+
thresholdPercent,
|
|
11811
12194
|
identity: this.deps.lookup(deviceId)
|
|
11812
12195
|
}));
|
|
11813
12196
|
}
|
|
11814
12197
|
/**
|
|
12198
|
+
* A pre-D642 row of this item (`consumable:<id>:<key>`, judged at the
|
|
12199
|
+
* cluster threshold, which shipped as the rule default) is carried over to
|
|
12200
|
+
* the default-threshold episode rather than re-announced: the live vacuum
|
|
12201
|
+
* had two items low and reminding when D642 shipped. Any other threshold
|
|
12202
|
+
* starts fresh; an unadopted legacy row retires with the census.
|
|
12203
|
+
*/
|
|
12204
|
+
adoptLegacy(subject, deviceId, key) {
|
|
12205
|
+
const parts = parseConsumableSubject(subject);
|
|
12206
|
+
const legacySubject = legacyConsumableSubject(deviceId, key);
|
|
12207
|
+
const legacy = this.deps.rows.get(legacySubject);
|
|
12208
|
+
if (legacy === void 0 || parts === null) return void 0;
|
|
12209
|
+
const legacyParts = parseConsumableSubject(legacySubject);
|
|
12210
|
+
if (legacyParts === null || legacyParts.thresholdPercent !== parts.thresholdPercent) return;
|
|
12211
|
+
const adopted = {
|
|
12212
|
+
...legacy,
|
|
12213
|
+
subject
|
|
12214
|
+
};
|
|
12215
|
+
this.deps.rows.put(adopted);
|
|
12216
|
+
this.dropRow(legacySubject, "carried over to its threshold episode (D642)");
|
|
12217
|
+
return adopted;
|
|
12218
|
+
}
|
|
12219
|
+
/**
|
|
11815
12220
|
* The reminder sweep. Called from an existing periodic tick; it allocates
|
|
11816
12221
|
* nothing on the healthy path.
|
|
11817
12222
|
*
|
|
@@ -11822,6 +12227,9 @@ var NcDeviceAlerts = class {
|
|
|
11822
12227
|
*/
|
|
11823
12228
|
tick() {
|
|
11824
12229
|
const settings = this.deps.settings();
|
|
12230
|
+
const thresholds = this.deps.consumableThresholds();
|
|
12231
|
+
if (thresholds !== null) this.reconcileThresholds(thresholds, settings);
|
|
12232
|
+
this.confirmHeldTanks();
|
|
11825
12233
|
this.retryPending(settings);
|
|
11826
12234
|
if (settings.renotifyMinutes <= 0) return;
|
|
11827
12235
|
for (const row of this.deps.rows.all()) {
|
|
@@ -11854,14 +12262,58 @@ var NcDeviceAlerts = class {
|
|
|
11854
12262
|
const subjects = this.deps.rows.all().map((row) => row.subject).filter((subject) => alertSubjectDeviceId(subject) === deviceId);
|
|
11855
12263
|
for (const subject of subjects) {
|
|
11856
12264
|
this.lastReading.delete(subject);
|
|
11857
|
-
this.
|
|
12265
|
+
this.lastTank.delete(subject);
|
|
11858
12266
|
this.offeredAt.delete(subject);
|
|
11859
12267
|
await this.deps.rows.remove(subject);
|
|
11860
12268
|
}
|
|
11861
12269
|
this.lastReading.delete(`battery:${deviceId}`);
|
|
12270
|
+
for (const [key, reading] of this.lastConsumable) if (reading.deviceId === deviceId) this.lastConsumable.delete(key);
|
|
12271
|
+
for (const subject of this.tankHold.keys()) if (alertSubjectDeviceId(subject) === deviceId) this.tankHold.delete(subject);
|
|
12272
|
+
for (const subject of this.lastTank.keys()) if (alertSubjectDeviceId(subject) === deviceId) this.lastTank.delete(subject);
|
|
11862
12273
|
return subjects.length;
|
|
11863
12274
|
}
|
|
11864
12275
|
/**
|
|
12276
|
+
* Bring the consumable episodes in line with the thresholds the rules ask
|
|
12277
|
+
* for (D642). A threshold with no episode for an item yet is judged from the
|
|
12278
|
+
* item's last reading — a rule that gains a threshold hears about an item
|
|
12279
|
+
* already past it on the next tick, not at the next slice write. A
|
|
12280
|
+
* threshold that no rule asks for any more has its episodes RETIRED, but
|
|
12281
|
+
* only when two consecutive censuses agree it is gone; retiring is silent
|
|
12282
|
+
* (nothing recovered — nobody is listening at that number any more) and
|
|
12283
|
+
* logged per row.
|
|
12284
|
+
*/
|
|
12285
|
+
reconcileThresholds(thresholds, settings) {
|
|
12286
|
+
for (const { deviceId, item } of this.lastConsumable.values()) for (const thresholdPercent of thresholds) {
|
|
12287
|
+
if (this.deps.rows.get(consumableSubject(thresholdPercent, deviceId, item.key))) continue;
|
|
12288
|
+
this.judgeConsumable(deviceId, item, thresholdPercent, settings);
|
|
12289
|
+
}
|
|
12290
|
+
const absent = /* @__PURE__ */ new Set();
|
|
12291
|
+
for (const row of this.deps.rows.all()) {
|
|
12292
|
+
const parts = parseConsumableSubject(row.subject);
|
|
12293
|
+
if (parts === null || thresholds.includes(parts.thresholdPercent)) continue;
|
|
12294
|
+
absent.add(parts.thresholdPercent);
|
|
12295
|
+
if (this.absentThresholds.has(parts.thresholdPercent)) this.dropRow(row.subject, "retired — no rule judges consumables at this threshold (D642)");
|
|
12296
|
+
}
|
|
12297
|
+
this.absentThresholds = absent;
|
|
12298
|
+
}
|
|
12299
|
+
/** Remove one row, mirror and durable, with a line that names why. */
|
|
12300
|
+
dropRow(subject, reason) {
|
|
12301
|
+
this.offeredAt.delete(subject);
|
|
12302
|
+
this.logger()?.info(`device alert ${reason}`, {
|
|
12303
|
+
...tagsOf(subject),
|
|
12304
|
+
meta: { subject }
|
|
12305
|
+
});
|
|
12306
|
+
this.deps.rows.remove(subject).catch((error) => {
|
|
12307
|
+
this.logger()?.warn("device alert row could not be removed", {
|
|
12308
|
+
...tagsOf(subject),
|
|
12309
|
+
meta: {
|
|
12310
|
+
subject,
|
|
12311
|
+
error: error instanceof Error ? error.message : String(error)
|
|
12312
|
+
}
|
|
12313
|
+
});
|
|
12314
|
+
});
|
|
12315
|
+
}
|
|
12316
|
+
/**
|
|
11865
12317
|
* Offer every PENDING raise once more — when the rule set changed since its
|
|
11866
12318
|
* last offer, OR when {@link pendingCadenceMs} has passed since it. The
|
|
11867
12319
|
* cadence is what delivers a raise refused only by a rule's SCHEDULE (or any
|
|
@@ -11893,14 +12345,25 @@ var NcDeviceAlerts = class {
|
|
|
11893
12345
|
}
|
|
11894
12346
|
/** How to re-word a subject's alert from the last reading, or `null`. */
|
|
11895
12347
|
restatementOf(subject) {
|
|
11896
|
-
const
|
|
11897
|
-
|
|
12348
|
+
const parts = parseConsumableSubject(subject);
|
|
12349
|
+
const consumable = parts === null || parts.legacy ? void 0 : this.lastConsumable.get(itemKeyOf(parts.deviceId, parts.key));
|
|
12350
|
+
if (parts !== null && consumable !== void 0) return (facts) => incomingFromConsumableBand({
|
|
11898
12351
|
facts,
|
|
11899
12352
|
deviceId: consumable.deviceId,
|
|
11900
12353
|
band: "low",
|
|
11901
12354
|
item: consumable.item,
|
|
12355
|
+
thresholdPercent: parts.thresholdPercent,
|
|
11902
12356
|
identity: this.deps.lookup(consumable.deviceId)
|
|
11903
12357
|
});
|
|
12358
|
+
const tank = this.lastTank.get(subject);
|
|
12359
|
+
const tankBand = tank === void 0 ? null : deriveTankBand(tank.tank);
|
|
12360
|
+
if (tank !== void 0 && (tankBand === "low" || tankBand === "full")) return (facts) => incomingFromTankBand({
|
|
12361
|
+
facts,
|
|
12362
|
+
deviceId: tank.deviceId,
|
|
12363
|
+
band: tankBand,
|
|
12364
|
+
tank: tank.tank.key,
|
|
12365
|
+
identity: this.deps.lookup(tank.deviceId)
|
|
12366
|
+
});
|
|
11904
12367
|
const battery = this.lastReading.get(subject);
|
|
11905
12368
|
if (battery !== void 0) return (facts) => incomingFromBatteryBand({
|
|
11906
12369
|
facts,
|
|
@@ -12000,11 +12463,9 @@ var NcDeviceAlerts = class {
|
|
|
12000
12463
|
report(subject, verdict, alerting) {
|
|
12001
12464
|
const logger = this.deps.logger;
|
|
12002
12465
|
if (logger === void 0) return;
|
|
12003
|
-
const deviceId = alertSubjectDeviceId(subject);
|
|
12004
|
-
const tags = deviceId !== void 0 ? { deviceId } : {};
|
|
12005
12466
|
if (verdict.kind === "silent" && !alerting) {
|
|
12006
12467
|
logger.debug("device alert cleared without a notification", {
|
|
12007
|
-
|
|
12468
|
+
...tagsOf(subject),
|
|
12008
12469
|
meta: {
|
|
12009
12470
|
subject,
|
|
12010
12471
|
reason: "notifyRecovery is off, or nothing changed"
|
|
@@ -12013,7 +12474,7 @@ var NcDeviceAlerts = class {
|
|
|
12013
12474
|
return;
|
|
12014
12475
|
}
|
|
12015
12476
|
logger.debug("device alert said nothing", {
|
|
12016
|
-
|
|
12477
|
+
...tagsOf(subject),
|
|
12017
12478
|
meta: {
|
|
12018
12479
|
subject,
|
|
12019
12480
|
verdict: verdict.kind
|
|
@@ -12424,6 +12885,21 @@ var en_default = {
|
|
|
12424
12885
|
"system.device-consumable-normal.title": "Consumable replaced",
|
|
12425
12886
|
"system.device-consumable-normal.body": "{{item}} on {{deviceName}} is back to {{percentage}}%",
|
|
12426
12887
|
"system.device-consumable-normal.body.unmeasured": "{{item}} on {{deviceName}} is OK again",
|
|
12888
|
+
"system.device-tank-low.title": "Tank low",
|
|
12889
|
+
"system.device-tank-full.title": "Tank full",
|
|
12890
|
+
"system.device-tank-normal.title": "Tank OK",
|
|
12891
|
+
"system.device-tank-low.body.cleanWater": "The clean water tank on {{deviceName}} is running low",
|
|
12892
|
+
"system.device-tank-full.body.cleanWater": "The clean water tank on {{deviceName}} is full",
|
|
12893
|
+
"system.device-tank-normal.body.cleanWater": "The clean water tank on {{deviceName}} is OK again",
|
|
12894
|
+
"system.device-tank-low.body.dirtyWater": "The dirty water tank on {{deviceName}} is running low",
|
|
12895
|
+
"system.device-tank-full.body.dirtyWater": "The dirty water tank on {{deviceName}} is full",
|
|
12896
|
+
"system.device-tank-normal.body.dirtyWater": "The dirty water tank on {{deviceName}} is OK again",
|
|
12897
|
+
"system.device-tank-low.body.detergent": "The detergent tank on {{deviceName}} is running low",
|
|
12898
|
+
"system.device-tank-full.body.detergent": "The detergent tank on {{deviceName}} is full",
|
|
12899
|
+
"system.device-tank-normal.body.detergent": "The detergent tank on {{deviceName}} is OK again",
|
|
12900
|
+
"system.device-tank-low.body.dustBin": "The dust bin on {{deviceName}} is running low",
|
|
12901
|
+
"system.device-tank-full.body.dustBin": "The dust bin on {{deviceName}} is full",
|
|
12902
|
+
"system.device-tank-normal.body.dustBin": "The dust bin on {{deviceName}} is OK again",
|
|
12427
12903
|
"system.camera-online.title": "Device online",
|
|
12428
12904
|
"system.camera-online.body": "Device {{deviceName}} is online",
|
|
12429
12905
|
"system.camera-offline.title": "Device offline",
|
|
@@ -12453,6 +12929,7 @@ var en_default = {
|
|
|
12453
12929
|
"snooze.scope.anyRule": "A rule",
|
|
12454
12930
|
"snooze.scope.rule": "“{{rule}}”",
|
|
12455
12931
|
"alarm.arm.body.allDevices": "Active: all devices",
|
|
12932
|
+
"alarm.arm.body.allDevicesExcept": "Active: all devices except {{count}}: {{names}}",
|
|
12456
12933
|
"alarm.arm.body.noDevices": "Active: no device restriction",
|
|
12457
12934
|
"alarm.arm.body.devices": "Active: {{names}}",
|
|
12458
12935
|
"alarm.arm.body.devicesSkipped": "Active: {{names}}\nExcluded: {{skippedNames}}",
|
|
@@ -12712,6 +13189,21 @@ var it_default = {
|
|
|
12712
13189
|
"system.device-consumable-normal.title": "Consumabile sostituito",
|
|
12713
13190
|
"system.device-consumable-normal.body": "{{item}} su {{deviceName}} è tornato al {{percentage}}%",
|
|
12714
13191
|
"system.device-consumable-normal.body.unmeasured": "{{item}} su {{deviceName}} è di nuovo a posto",
|
|
13192
|
+
"system.device-tank-low.title": "Serbatoio quasi vuoto",
|
|
13193
|
+
"system.device-tank-full.title": "Serbatoio pieno",
|
|
13194
|
+
"system.device-tank-normal.title": "Serbatoio a posto",
|
|
13195
|
+
"system.device-tank-low.body.cleanWater": "Il serbatoio dell'acqua pulita di {{deviceName}} è quasi vuoto",
|
|
13196
|
+
"system.device-tank-full.body.cleanWater": "Il serbatoio dell'acqua pulita di {{deviceName}} è pieno",
|
|
13197
|
+
"system.device-tank-normal.body.cleanWater": "Il serbatoio dell'acqua pulita di {{deviceName}} è di nuovo a posto",
|
|
13198
|
+
"system.device-tank-low.body.dirtyWater": "Il serbatoio dell'acqua sporca di {{deviceName}} è quasi vuoto",
|
|
13199
|
+
"system.device-tank-full.body.dirtyWater": "Il serbatoio dell'acqua sporca di {{deviceName}} è pieno",
|
|
13200
|
+
"system.device-tank-normal.body.dirtyWater": "Il serbatoio dell'acqua sporca di {{deviceName}} è di nuovo a posto",
|
|
13201
|
+
"system.device-tank-low.body.detergent": "Il serbatoio del detergente di {{deviceName}} è quasi vuoto",
|
|
13202
|
+
"system.device-tank-full.body.detergent": "Il serbatoio del detergente di {{deviceName}} è pieno",
|
|
13203
|
+
"system.device-tank-normal.body.detergent": "Il serbatoio del detergente di {{deviceName}} è di nuovo a posto",
|
|
13204
|
+
"system.device-tank-low.body.dustBin": "Il contenitore della polvere di {{deviceName}} è quasi vuoto",
|
|
13205
|
+
"system.device-tank-full.body.dustBin": "Il contenitore della polvere di {{deviceName}} è pieno",
|
|
13206
|
+
"system.device-tank-normal.body.dustBin": "Il contenitore della polvere di {{deviceName}} è di nuovo a posto",
|
|
12715
13207
|
"system.camera-online.title": "Dispositivo online",
|
|
12716
13208
|
"system.camera-online.body": "Il dispositivo {{deviceName}} è online",
|
|
12717
13209
|
"system.camera-offline.title": "Dispositivo offline",
|
|
@@ -12741,6 +13233,7 @@ var it_default = {
|
|
|
12741
13233
|
"snooze.scope.anyRule": "Una regola",
|
|
12742
13234
|
"snooze.scope.rule": "“{{rule}}”",
|
|
12743
13235
|
"alarm.arm.body.allDevices": "Attivi: tutti i dispositivi",
|
|
13236
|
+
"alarm.arm.body.allDevicesExcept": "Attivi: tutti i dispositivi tranne {{count}}: {{names}}",
|
|
12744
13237
|
"alarm.arm.body.noDevices": "Attivi: nessuna restrizione",
|
|
12745
13238
|
"alarm.arm.body.devices": "Attivi: {{names}}",
|
|
12746
13239
|
"alarm.arm.body.devicesSkipped": "Attivi: {{names}}\nEsclusi: {{skippedNames}}",
|
|
@@ -13255,7 +13748,7 @@ function assertPatchKeepsFamily(existing, patch) {
|
|
|
13255
13748
|
* the offending kind or filter). Then rethrow. A refused save is dropped work,
|
|
13256
13749
|
* and dropped work is never silent. Any other error passes through unlogged.
|
|
13257
13750
|
*/
|
|
13258
|
-
function logRefusal(check, ctx) {
|
|
13751
|
+
function logRefusal$2(check, ctx) {
|
|
13259
13752
|
try {
|
|
13260
13753
|
check();
|
|
13261
13754
|
} catch (err) {
|
|
@@ -13271,11 +13764,115 @@ function logRefusal(check, ctx) {
|
|
|
13271
13764
|
}
|
|
13272
13765
|
/** {@link assertRuleFamily}, logged. The create path of both routes. */
|
|
13273
13766
|
function guardRuleFamily(rule, ctx) {
|
|
13274
|
-
logRefusal(() => assertRuleFamily(rule), ctx);
|
|
13767
|
+
logRefusal$2(() => assertRuleFamily(rule), ctx);
|
|
13275
13768
|
}
|
|
13276
13769
|
/** {@link assertPatchKeepsFamily}, logged. The update path of both routes. */
|
|
13277
13770
|
function guardPatchFamily(existing, patch, ctx) {
|
|
13278
|
-
logRefusal(() => assertPatchKeepsFamily(existing, patch), ctx);
|
|
13771
|
+
logRefusal$2(() => assertPatchKeepsFamily(existing, patch), ctx);
|
|
13772
|
+
}
|
|
13773
|
+
//#endregion
|
|
13774
|
+
//#region src/notification-center/device-scope-guard.ts
|
|
13775
|
+
/**
|
|
13776
|
+
* The deliveries whose evaluation reads the device scope — the `devices`
|
|
13777
|
+
* descriptor's `appliesTo`, restated as the one delivery that lacks it.
|
|
13778
|
+
*/
|
|
13779
|
+
var NO_DEVICE_SCOPE_DELIVERY = "system-event";
|
|
13780
|
+
var NcDevicesExcludeRefusal = class extends Error {
|
|
13781
|
+
delivery;
|
|
13782
|
+
devicesExclude;
|
|
13783
|
+
constructor(delivery, devicesExclude) {
|
|
13784
|
+
super(`rule refused: devicesExclude does not apply to a '${delivery}' rule — a system rule excludes devices with systemEvent.excludeDeviceIds`);
|
|
13785
|
+
this.name = "NcDevicesExcludeRefusal";
|
|
13786
|
+
this.delivery = delivery;
|
|
13787
|
+
this.devicesExclude = devicesExclude;
|
|
13788
|
+
}
|
|
13789
|
+
};
|
|
13790
|
+
function assertDevicesExcludeDelivery(rule) {
|
|
13791
|
+
const excluded = rule.conditions.devicesExclude;
|
|
13792
|
+
if (excluded === void 0 || rule.delivery !== NO_DEVICE_SCOPE_DELIVERY) return;
|
|
13793
|
+
throw new NcDevicesExcludeRefusal(rule.delivery, excluded);
|
|
13794
|
+
}
|
|
13795
|
+
/** Run the check; a refusal is dropped work, so it is logged before it is rethrown. */
|
|
13796
|
+
function logRefusal$1(check, ctx) {
|
|
13797
|
+
try {
|
|
13798
|
+
check();
|
|
13799
|
+
} catch (err) {
|
|
13800
|
+
if (err instanceof NcDevicesExcludeRefusal) warnRefusal$1(ctx.logger, err, ctx);
|
|
13801
|
+
throw err;
|
|
13802
|
+
}
|
|
13803
|
+
}
|
|
13804
|
+
function warnRefusal$1(logger, err, ctx) {
|
|
13805
|
+
logger.warn("nc rule refused: devicesExclude on a system rule", { meta: {
|
|
13806
|
+
...ctx.ruleId !== void 0 ? { ruleId: ctx.ruleId } : {},
|
|
13807
|
+
by: ctx.by,
|
|
13808
|
+
delivery: err.delivery,
|
|
13809
|
+
devicesExclude: [...err.devicesExclude]
|
|
13810
|
+
} });
|
|
13811
|
+
}
|
|
13812
|
+
/** The create path of both routes. */
|
|
13813
|
+
function guardRuleDevicesExclude(rule, ctx) {
|
|
13814
|
+
logRefusal$1(() => assertDevicesExcludeDelivery(rule), ctx);
|
|
13815
|
+
}
|
|
13816
|
+
/**
|
|
13817
|
+
* The update path of both routes: the MERGED rule is judged, and only when the
|
|
13818
|
+
* patch changes `delivery` or `conditions`.
|
|
13819
|
+
*/
|
|
13820
|
+
function guardPatchDevicesExclude(existing, patch, ctx) {
|
|
13821
|
+
if (existing === null) return;
|
|
13822
|
+
const merged = mergedFamilySubject(existing, patch);
|
|
13823
|
+
if (merged === null) return;
|
|
13824
|
+
logRefusal$1(() => assertDevicesExcludeDelivery(merged), ctx);
|
|
13825
|
+
}
|
|
13826
|
+
//#endregion
|
|
13827
|
+
//#region src/notification-center/sensor-transition-guard.ts
|
|
13828
|
+
/** The one delivery whose subject carries a sensor edge. */
|
|
13829
|
+
var SENSOR_TRANSITION_DELIVERY = "device-event";
|
|
13830
|
+
var NcSensorTransitionRefusal = class extends Error {
|
|
13831
|
+
delivery;
|
|
13832
|
+
sensorTransition;
|
|
13833
|
+
constructor(delivery, sensorTransition) {
|
|
13834
|
+
super(`rule refused: sensorTransition '${sensorTransition}' applies only to a device-event rule, not '${delivery}'`);
|
|
13835
|
+
this.name = "NcSensorTransitionRefusal";
|
|
13836
|
+
this.delivery = delivery;
|
|
13837
|
+
this.sensorTransition = sensorTransition;
|
|
13838
|
+
}
|
|
13839
|
+
};
|
|
13840
|
+
function assertSensorTransitionDelivery(rule) {
|
|
13841
|
+
const transition = rule.conditions.sensorTransition;
|
|
13842
|
+
if (transition === void 0 || rule.delivery === SENSOR_TRANSITION_DELIVERY) return;
|
|
13843
|
+
throw new NcSensorTransitionRefusal(rule.delivery, transition);
|
|
13844
|
+
}
|
|
13845
|
+
/** Run the check; a refusal is dropped work, so it is logged before it is rethrown. */
|
|
13846
|
+
function logRefusal(check, ctx) {
|
|
13847
|
+
try {
|
|
13848
|
+
check();
|
|
13849
|
+
} catch (err) {
|
|
13850
|
+
if (err instanceof NcSensorTransitionRefusal) warnRefusal(ctx.logger, err, ctx);
|
|
13851
|
+
throw err;
|
|
13852
|
+
}
|
|
13853
|
+
}
|
|
13854
|
+
function warnRefusal(logger, err, ctx) {
|
|
13855
|
+
logger.warn("nc rule refused: sensorTransition off a device-event rule", { meta: {
|
|
13856
|
+
...ctx.ruleId !== void 0 ? { ruleId: ctx.ruleId } : {},
|
|
13857
|
+
by: ctx.by,
|
|
13858
|
+
delivery: err.delivery,
|
|
13859
|
+
sensorTransition: err.sensorTransition
|
|
13860
|
+
} });
|
|
13861
|
+
}
|
|
13862
|
+
/** The create path of both routes. */
|
|
13863
|
+
function guardRuleSensorTransition(rule, ctx) {
|
|
13864
|
+
logRefusal(() => assertSensorTransitionDelivery(rule), ctx);
|
|
13865
|
+
}
|
|
13866
|
+
/**
|
|
13867
|
+
* The update path of both routes: the MERGED rule is judged, and only when the
|
|
13868
|
+
* patch changes `delivery` or `conditions` — an unchanged full-form save
|
|
13869
|
+
* (a rename, a retarget) is not re-judged.
|
|
13870
|
+
*/
|
|
13871
|
+
function guardPatchSensorTransition(existing, patch, ctx) {
|
|
13872
|
+
if (existing === null) return;
|
|
13873
|
+
const merged = mergedFamilySubject(existing, patch);
|
|
13874
|
+
if (merged === null) return;
|
|
13875
|
+
logRefusal(() => assertSensorTransitionDelivery(merged), ctx);
|
|
13279
13876
|
}
|
|
13280
13877
|
//#endregion
|
|
13281
13878
|
//#region src/notification-center/occupancy-watcher.ts
|
|
@@ -13990,6 +14587,10 @@ function systemEventKeys(kind) {
|
|
|
13990
14587
|
titleKey: `system.${kind}.title`,
|
|
13991
14588
|
bodyKey: `system.${kind}.body.ignored`
|
|
13992
14589
|
};
|
|
14590
|
+
if (NC_TANK_SYSTEM_EVENT_KINDS.includes(kind)) return {
|
|
14591
|
+
titleKey: `system.${kind}.title`,
|
|
14592
|
+
bodyKey: `system.${kind}.body.cleanWater`
|
|
14593
|
+
};
|
|
13993
14594
|
return {
|
|
13994
14595
|
titleKey: `system.${kind}.title`,
|
|
13995
14596
|
bodyKey: `system.${kind}.body`
|
|
@@ -14046,6 +14647,19 @@ function labelFor(conditions) {
|
|
|
14046
14647
|
return conditions.identities?.[0] ?? conditions.labelEquals?.[0] ?? conditions.plates?.values?.[0] ?? void 0;
|
|
14047
14648
|
}
|
|
14048
14649
|
/**
|
|
14650
|
+
* The edge a planned sensor event carries (D640): RAISED by default — the
|
|
14651
|
+
* alert, which is what an operator pressing Test on a flood rule expects to
|
|
14652
|
+
* see — and cleared only for a rule that asks for the clear. Only for a kind
|
|
14653
|
+
* that HAS a raise: a doorbell press carries none in production, and a test
|
|
14654
|
+
* subject claiming one would be a subject no real record could be.
|
|
14655
|
+
*/
|
|
14656
|
+
function sensorEdgeFor(trigger, c) {
|
|
14657
|
+
const kind = c.sensorKinds?.[0];
|
|
14658
|
+
if (trigger !== "device-event" || kind === void 0) return {};
|
|
14659
|
+
if (!NC_SENSOR_TRANSITION_KINDS.includes(kind.trim().toLowerCase())) return {};
|
|
14660
|
+
return { sensorRaised: c.sensorTransition !== "cleared" };
|
|
14661
|
+
}
|
|
14662
|
+
/**
|
|
14049
14663
|
* Plan the injection that fires this rule.
|
|
14050
14664
|
*
|
|
14051
14665
|
* Every condition the engine reads off the SUBJECT is satisfied here; the two
|
|
@@ -14067,6 +14681,10 @@ function planRuleTest(input) {
|
|
|
14067
14681
|
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."
|
|
14068
14682
|
});
|
|
14069
14683
|
const deviceId = scopedDeviceId ?? NC_SYSTEM_DEVICE_ID;
|
|
14684
|
+
if (trigger !== "system-event" && (c.devicesExclude ?? []).includes(deviceId)) notes.push({
|
|
14685
|
+
conditionId: "devicesExclude",
|
|
14686
|
+
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."
|
|
14687
|
+
});
|
|
14070
14688
|
const scheduledAt = scheduleActiveAt(rule.schedule, now);
|
|
14071
14689
|
if (scheduledAt === null) notes.push({
|
|
14072
14690
|
conditionId: "schedule",
|
|
@@ -14116,6 +14734,7 @@ function planRuleTest(input) {
|
|
|
14116
14734
|
} } : {},
|
|
14117
14735
|
...c.sensorKinds?.[0] !== void 0 ? { sensorKind: c.sensorKinds[0] } : {},
|
|
14118
14736
|
...c.eventTypeTokens?.[0] !== void 0 ? { eventType: c.eventTypeTokens[0] } : {},
|
|
14737
|
+
...sensorEdgeFor(trigger, c),
|
|
14119
14738
|
...trigger === "package-event" ? { packagePhase: c.packagePhase === "picked-up" ? "picked-up" : "delivered" } : {},
|
|
14120
14739
|
...c.source !== void 0 && c.source !== "any" ? { source: c.source } : {},
|
|
14121
14740
|
...bbox !== void 0 ? { bbox } : {},
|
|
@@ -14634,6 +15253,10 @@ var NcTestEventInputSchema = object({
|
|
|
14634
15253
|
/** DEVICE-EVENT: the raw device event-type token, when the source is an
|
|
14635
15254
|
* event-emitter slice. */
|
|
14636
15255
|
eventType: string().optional(),
|
|
15256
|
+
/** DEVICE-EVENT: which edge a boolean sensor moved on — true raised, false
|
|
15257
|
+
* cleared (D640). Absent = the slice could not say, which production
|
|
15258
|
+
* answers for a doorbell press or an unreadable read. */
|
|
15259
|
+
sensorRaised: boolean().optional(),
|
|
14637
15260
|
/** PACKAGE-EVENT: which lifecycle phase. */
|
|
14638
15261
|
packagePhase: _enum(["delivered", "picked-up"]).optional(),
|
|
14639
15262
|
/** Detection provenance. Absent = `pipeline` (what a record with no stamped
|
|
@@ -14852,6 +15475,7 @@ function buildSyntheticEvent(input, recordId, now) {
|
|
|
14852
15475
|
...input.labelConfidence !== void 0 ? { labelConfidence: input.labelConfidence } : {},
|
|
14853
15476
|
...input.sensorKind !== void 0 ? { sensorKind: input.sensorKind } : {},
|
|
14854
15477
|
...input.eventType !== void 0 ? { eventType: input.eventType } : {},
|
|
15478
|
+
...input.sensorRaised !== void 0 ? { sensorRaised: input.sensorRaised } : {},
|
|
14855
15479
|
...input.packagePhase !== void 0 ? { packagePhase: input.packagePhase } : {},
|
|
14856
15480
|
...input.bbox !== void 0 ? { bbox: input.bbox } : {},
|
|
14857
15481
|
...input.crossing !== void 0 ? { crossing: input.crossing } : {},
|
|
@@ -15967,7 +16591,7 @@ function makeNcActionHandlers(deps) {
|
|
|
15967
16591
|
catalog: [...NC_CONDITION_CATALOG],
|
|
15968
16592
|
taxonomy: NC_TAXONOMY
|
|
15969
16593
|
}),
|
|
15970
|
-
"nc.getTemplateCatalog": async () => ({ vars:
|
|
16594
|
+
"nc.getTemplateCatalog": async () => ({ vars: servedTemplateVars() }),
|
|
15971
16595
|
"nc.previewTemplate": async (input) => {
|
|
15972
16596
|
const texts = deps.previewTexts;
|
|
15973
16597
|
if (texts === void 0) throw new Error("nc.previewTemplate: text catalog not wired");
|
|
@@ -15982,6 +16606,14 @@ function makeNcActionHandlers(deps) {
|
|
|
15982
16606
|
logger: deps.logger,
|
|
15983
16607
|
by: c.userId
|
|
15984
16608
|
});
|
|
16609
|
+
guardRuleSensorTransition(parsed, {
|
|
16610
|
+
logger: deps.logger,
|
|
16611
|
+
by: c.userId
|
|
16612
|
+
});
|
|
16613
|
+
guardRuleDevicesExclude(parsed, {
|
|
16614
|
+
logger: deps.logger,
|
|
16615
|
+
by: c.userId
|
|
16616
|
+
});
|
|
15985
16617
|
await assertTargetsOwned(parsed.targets.map((t) => t.targetId), c);
|
|
15986
16618
|
const rule = await deps.ruleStore.create({
|
|
15987
16619
|
...parsed,
|
|
@@ -16005,6 +16637,16 @@ function makeNcActionHandlers(deps) {
|
|
|
16005
16637
|
by: c.userId,
|
|
16006
16638
|
ruleId: input.ruleId
|
|
16007
16639
|
});
|
|
16640
|
+
guardPatchSensorTransition(existing, patch, {
|
|
16641
|
+
logger: deps.logger,
|
|
16642
|
+
by: c.userId,
|
|
16643
|
+
ruleId: input.ruleId
|
|
16644
|
+
});
|
|
16645
|
+
guardPatchDevicesExclude(existing, patch, {
|
|
16646
|
+
logger: deps.logger,
|
|
16647
|
+
by: c.userId,
|
|
16648
|
+
ruleId: input.ruleId
|
|
16649
|
+
});
|
|
16008
16650
|
if (patch.targets !== void 0) await assertTargetsOwned(patch.targets.map((t) => t.targetId), c);
|
|
16009
16651
|
const { ownerUserId: _owner, disabledTargetIds: _optOut, ...safe } = patch;
|
|
16010
16652
|
const rule = await deps.ruleStore.update(input.ruleId, safe, clear);
|
|
@@ -19148,6 +19790,19 @@ function pruneRuleConditions(conditions, isDead, notes) {
|
|
|
19148
19790
|
notes.removed("conditions.devices", deviceSubjects(verdict.dead));
|
|
19149
19791
|
}
|
|
19150
19792
|
}
|
|
19793
|
+
const excluded = conditions.devicesExclude;
|
|
19794
|
+
if (excluded !== void 0) {
|
|
19795
|
+
const verdict = judgeList(excluded, isDead);
|
|
19796
|
+
if (!verdict.untouched) {
|
|
19797
|
+
const { devicesExclude: _dead, ...rest } = next;
|
|
19798
|
+
next = verdict.last ? rest : {
|
|
19799
|
+
...rest,
|
|
19800
|
+
devicesExclude: [...verdict.next]
|
|
19801
|
+
};
|
|
19802
|
+
changed = true;
|
|
19803
|
+
notes.removed("conditions.devicesExclude", deviceSubjects(verdict.dead));
|
|
19804
|
+
}
|
|
19805
|
+
}
|
|
19151
19806
|
const systemEvent = conditions.systemEvent;
|
|
19152
19807
|
if (systemEvent?.deviceIds !== void 0) {
|
|
19153
19808
|
const verdict = judgeList(systemEvent.deviceIds, isDead);
|
|
@@ -19328,6 +19983,7 @@ function referencedDeviceIds(snapshot) {
|
|
|
19328
19983
|
for (const rule of snapshot.rules) {
|
|
19329
19984
|
const c = rule.conditions;
|
|
19330
19985
|
add(c.devices);
|
|
19986
|
+
add(c.devicesExclude);
|
|
19331
19987
|
add(c.systemEvent?.deviceIds);
|
|
19332
19988
|
if (c.deviceState !== void 0) ids.add(c.deviceState.deviceId);
|
|
19333
19989
|
if (c.scene?.deviceId !== void 0) ids.add(c.scene.deviceId);
|
|
@@ -22181,6 +22837,50 @@ var NcOutbox = class {
|
|
|
22181
22837
|
return [];
|
|
22182
22838
|
}
|
|
22183
22839
|
}
|
|
22840
|
+
/**
|
|
22841
|
+
* The rows `getRuleLastRuns` reduces — ONE statement: fire time inside
|
|
22842
|
+
* `[sinceMs, ∞)`, newest-first on `idx_nc_outbox_created`, projected to the
|
|
22843
|
+
* four columns the reduction reads (never the `payload` JSON), at most
|
|
22844
|
+
* `limit` rows. `sinceMs: null` = no lower bound (the operator keeps the
|
|
22845
|
+
* outbox forever); `limit` still bounds the read.
|
|
22846
|
+
*
|
|
22847
|
+
* Unlike {@link queryHistory} this THROWS on a store error: its caller
|
|
22848
|
+
* answers "which rules did not run", and an empty page from a failed read
|
|
22849
|
+
* would answer "none of them did" (D393).
|
|
22850
|
+
*/
|
|
22851
|
+
async queryLastRunRows(sinceMs, limit) {
|
|
22852
|
+
const rows = await this.store.query.query({
|
|
22853
|
+
collection: NC_OUTBOX_COLLECTION,
|
|
22854
|
+
filter: {
|
|
22855
|
+
...sinceMs !== null ? { whereBetween: { createdAt: [sinceMs, Number.MAX_SAFE_INTEGER] } } : {},
|
|
22856
|
+
orderBy: {
|
|
22857
|
+
field: "createdAt",
|
|
22858
|
+
direction: "desc"
|
|
22859
|
+
},
|
|
22860
|
+
limit
|
|
22861
|
+
},
|
|
22862
|
+
columns: [...NC_LAST_RUN_COLUMNS]
|
|
22863
|
+
});
|
|
22864
|
+
const out = [];
|
|
22865
|
+
let malformed = 0;
|
|
22866
|
+
let oldestReadAt = null;
|
|
22867
|
+
for (const row of rows) {
|
|
22868
|
+
const at = Number(row.data["createdAt"]);
|
|
22869
|
+
if (Number.isFinite(at) && (oldestReadAt === null || at < oldestReadAt)) oldestReadAt = at;
|
|
22870
|
+
const parsed = lastRunRow(row.data);
|
|
22871
|
+
if (parsed === null) malformed += 1;
|
|
22872
|
+
else out.push(parsed);
|
|
22873
|
+
}
|
|
22874
|
+
if (malformed > 0) this.logger.warn("last-run read skipped malformed outbox rows", { meta: {
|
|
22875
|
+
malformed,
|
|
22876
|
+
read: rows.length
|
|
22877
|
+
} });
|
|
22878
|
+
return {
|
|
22879
|
+
rows: out,
|
|
22880
|
+
truncated: rows.length >= limit,
|
|
22881
|
+
oldestReadAt
|
|
22882
|
+
};
|
|
22883
|
+
}
|
|
22184
22884
|
async getWatermark() {
|
|
22185
22885
|
try {
|
|
22186
22886
|
const row = await this.store.get.query({
|
|
@@ -22468,6 +23168,27 @@ function rowToEntry$1(id, data) {
|
|
|
22468
23168
|
payload
|
|
22469
23169
|
};
|
|
22470
23170
|
}
|
|
23171
|
+
/** The columns {@link NcOutbox.queryLastRunRows} projects. */
|
|
23172
|
+
var NC_LAST_RUN_COLUMNS = [
|
|
23173
|
+
"ruleId",
|
|
23174
|
+
"createdAt",
|
|
23175
|
+
"status",
|
|
23176
|
+
"deviceId"
|
|
23177
|
+
];
|
|
23178
|
+
/** A projected row back to a {@link NcLastRunRow} — a guard, never a cast. */
|
|
23179
|
+
function lastRunRow(data) {
|
|
23180
|
+
const ruleId = data["ruleId"];
|
|
23181
|
+
const createdAt = Number(data["createdAt"]);
|
|
23182
|
+
const deviceId = Number(data["deviceId"]);
|
|
23183
|
+
const status = data["status"];
|
|
23184
|
+
if (typeof ruleId !== "string" || !Number.isFinite(createdAt) || !Number.isFinite(deviceId) || status !== "pending" && status !== "sent" && status !== "dead") return null;
|
|
23185
|
+
return {
|
|
23186
|
+
ruleId,
|
|
23187
|
+
createdAt,
|
|
23188
|
+
status,
|
|
23189
|
+
deviceId
|
|
23190
|
+
};
|
|
23191
|
+
}
|
|
22471
23192
|
//#endregion
|
|
22472
23193
|
//#region src/notification-center/rule-actions.ts
|
|
22473
23194
|
var NcRuleActionRunner = class {
|
|
@@ -23159,6 +23880,58 @@ var NcRuleAiStore = class {
|
|
|
23159
23880
|
}
|
|
23160
23881
|
};
|
|
23161
23882
|
//#endregion
|
|
23883
|
+
//#region src/notification-center/rule-last-runs.ts
|
|
23884
|
+
/**
|
|
23885
|
+
* Row bound of the one read. Projected rows are four scalars (~100 B), so the
|
|
23886
|
+
* bound costs ~2 MB at worst. The live outbox volume was NOT measured when
|
|
23887
|
+
* this was chosen. It exists so a runaway rule cannot turn an admin page load
|
|
23888
|
+
* into a table walk — and when it bites, `horizonMs` says so.
|
|
23889
|
+
*/
|
|
23890
|
+
var NC_RULE_LAST_RUNS_SCAN_LIMIT = 2e4;
|
|
23891
|
+
/** Least successful first: a fire that reached some targets and not others
|
|
23892
|
+
* did not "deliver". */
|
|
23893
|
+
var STATUS_SEVERITY = {
|
|
23894
|
+
sent: 0,
|
|
23895
|
+
pending: 1,
|
|
23896
|
+
dead: 2
|
|
23897
|
+
};
|
|
23898
|
+
function worse(a, b) {
|
|
23899
|
+
return STATUS_SEVERITY[b] > STATUS_SEVERITY[a] ? b : a;
|
|
23900
|
+
}
|
|
23901
|
+
/**
|
|
23902
|
+
* Reduce newest-first rows to one run per rule, in one pass.
|
|
23903
|
+
*
|
|
23904
|
+
* Rows sharing the newest `createdAt` of a rule are one fire to several
|
|
23905
|
+
* targets; they merge into the least successful status. `read.rows` MUST be
|
|
23906
|
+
* ordered newest-first — that is what the outbox read asks the store for.
|
|
23907
|
+
*/
|
|
23908
|
+
function summariseRuleLastRuns(read, scan) {
|
|
23909
|
+
const byRule = /* @__PURE__ */ new Map();
|
|
23910
|
+
for (const row of read.rows) {
|
|
23911
|
+
const seen = byRule.get(row.ruleId);
|
|
23912
|
+
if (seen === void 0) byRule.set(row.ruleId, {
|
|
23913
|
+
ruleId: row.ruleId,
|
|
23914
|
+
lastAt: row.createdAt,
|
|
23915
|
+
status: row.status,
|
|
23916
|
+
deviceId: row.deviceId
|
|
23917
|
+
});
|
|
23918
|
+
else if (seen.lastAt === row.createdAt && worse(seen.status, row.status) !== seen.status) byRule.set(row.ruleId, {
|
|
23919
|
+
...seen,
|
|
23920
|
+
status: row.status,
|
|
23921
|
+
deviceId: row.deviceId
|
|
23922
|
+
});
|
|
23923
|
+
}
|
|
23924
|
+
return {
|
|
23925
|
+
runs: [...byRule.values()],
|
|
23926
|
+
horizonMs: horizonOf(read, scan)
|
|
23927
|
+
};
|
|
23928
|
+
}
|
|
23929
|
+
/** How far back the answer really looked — see the module doc. */
|
|
23930
|
+
function horizonOf(read, scan) {
|
|
23931
|
+
if (read.truncated && read.oldestReadAt !== null) return Math.max(0, scan.now - read.oldestReadAt);
|
|
23932
|
+
return scan.windowMs;
|
|
23933
|
+
}
|
|
23934
|
+
//#endregion
|
|
23162
23935
|
//#region src/notification-center/durable-reread.ts
|
|
23163
23936
|
function isRecord$3(value) {
|
|
23164
23937
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
@@ -23398,7 +24171,7 @@ var NcRuleStore = class {
|
|
|
23398
24171
|
*/
|
|
23399
24172
|
async load() {
|
|
23400
24173
|
await this.ledger.load();
|
|
23401
|
-
this.loaded = true;
|
|
24174
|
+
if (this.ledger.hasAnswered()) this.loaded = true;
|
|
23402
24175
|
this.logger.debug("notification rules loaded", { meta: { rules: this.ledger.size } });
|
|
23403
24176
|
}
|
|
23404
24177
|
/**
|
|
@@ -27828,7 +28601,6 @@ var ARM_ANYWAY_REJECTION_COPY = {
|
|
|
27828
28601
|
*/
|
|
27829
28602
|
var NC_DEVICE_ALERT_FALLBACK = {
|
|
27830
28603
|
batteryLowPercent: 20,
|
|
27831
|
-
consumableLowPercent: 15,
|
|
27832
28604
|
renotifyMinutes: 720,
|
|
27833
28605
|
notifyRecovery: true
|
|
27834
28606
|
};
|
|
@@ -28123,9 +28895,14 @@ function outboxEntryToHistory(texts, entry) {
|
|
|
28123
28895
|
function renderAudioSpec(spec) {
|
|
28124
28896
|
return spec.mode === "label" ? `label:${spec.labels.join("+")}` : `level:${String(spec.dbThreshold)}dBFS|h${String(spec.hitPercent)}|s${String(spec.samplingSeconds)}`;
|
|
28125
28897
|
}
|
|
28126
|
-
/**
|
|
28127
|
-
|
|
28128
|
-
|
|
28898
|
+
/**
|
|
28899
|
+
* The camera scope in one token — `@all` is a real answer, not a missing one,
|
|
28900
|
+
* and `@all-except:<ids>` is a different one (D641): an excluding rule logged
|
|
28901
|
+
* as `@all` would claim the camera it never watches.
|
|
28902
|
+
*/
|
|
28903
|
+
function renderAudioScope(devices, excluded) {
|
|
28904
|
+
if (devices.length === 0) return excluded.length === 0 ? "@all" : `@all-except:${excluded.join("+")}`;
|
|
28905
|
+
return devices.filter((id) => !excluded.includes(id)).join("+");
|
|
28129
28906
|
}
|
|
28130
28907
|
var NotificationCenter = class NotificationCenter {
|
|
28131
28908
|
logger;
|
|
@@ -29078,6 +29855,7 @@ var NotificationCenter = class NotificationCenter {
|
|
|
29078
29855
|
return (await this.consumeEventAndReport(event)).outcomes.some((outcome) => outcome.matched);
|
|
29079
29856
|
},
|
|
29080
29857
|
rulesVersion: () => this.rulesFingerprint(),
|
|
29858
|
+
consumableThresholds: () => this.rules.isLoaded() ? consumableThresholdCensus(this.rules.list()) : null,
|
|
29081
29859
|
settings: () => this.deviceAlertSettings(),
|
|
29082
29860
|
lookup: (deviceId) => this.deviceDirectory.get(deviceId),
|
|
29083
29861
|
rows: this.alertStore.port(),
|
|
@@ -29867,7 +30645,7 @@ var NotificationCenter = class NotificationCenter {
|
|
|
29867
30645
|
* scope the evaluation will match on (`matchesDeviceScope`).
|
|
29868
30646
|
*/
|
|
29869
30647
|
beginLinkedStills(request) {
|
|
29870
|
-
const { sensorId, sensorKind, cameraIds, atMs, rowCameraIds } = request;
|
|
30648
|
+
const { sensorId, sensorKind, cameraIds, atMs, rowCameraIds, sensorRaised } = request;
|
|
29871
30649
|
const shelf = this.linkedStills;
|
|
29872
30650
|
if (shelf === null || !this.evaluationActive || cameraIds.length < 2) return void 0;
|
|
29873
30651
|
if (this.deviceMutes.isMuted(sensorId)) return void 0;
|
|
@@ -29877,7 +30655,13 @@ var NotificationCenter = class NotificationCenter {
|
|
|
29877
30655
|
const involved = [sensorId, ...rowShown];
|
|
29878
30656
|
if (!this.rules.listEnabled("device-event").some((rule) => {
|
|
29879
30657
|
if (rule.media.attach === "none") return false;
|
|
29880
|
-
if (!deviceEventRuleMayFireOnKind(rule, sensorKind)) return false;
|
|
30658
|
+
if (!deviceEventRuleMayFireOnKind(rule, sensorKind, sensorRaised)) return false;
|
|
30659
|
+
const excluded = rule.conditions.devicesExclude;
|
|
30660
|
+
if (excluded !== void 0 && deviceScopeExcludes(excluded, {
|
|
30661
|
+
deviceId: sensorId,
|
|
30662
|
+
linkedSensorId: sensorId,
|
|
30663
|
+
rowCameraIds: rowShown
|
|
30664
|
+
})) return false;
|
|
29881
30665
|
const devices = rule.conditions.devices;
|
|
29882
30666
|
return devices === void 0 || devices.length === 0 || devices.some((id) => involved.includes(id));
|
|
29883
30667
|
})) return void 0;
|
|
@@ -29999,6 +30783,8 @@ var NotificationCenter = class NotificationCenter {
|
|
|
29999
30783
|
if (!(mode === "condition" ? rule.conditions.audio !== void 0 : rule.conditions.audio === void 0 && referencesAudioClass(rule.conditions.classes))) continue;
|
|
30000
30784
|
const devices = rule.conditions.devices;
|
|
30001
30785
|
if (devices !== void 0 && devices.length > 0 && !devices.includes(deviceId)) continue;
|
|
30786
|
+
const excluded = rule.conditions.devicesExclude;
|
|
30787
|
+
if (excluded !== void 0 && deviceScopeExcludes(excluded, { deviceId })) continue;
|
|
30002
30788
|
return shelf.capture(deviceId, atMs, "audio");
|
|
30003
30789
|
}
|
|
30004
30790
|
}
|
|
@@ -30101,6 +30887,8 @@ var NotificationCenter = class NotificationCenter {
|
|
|
30101
30887
|
if (occ === void 0 || !matchesOccupancy(occ, subject)) continue;
|
|
30102
30888
|
const devices = rule.conditions.devices;
|
|
30103
30889
|
if (devices !== void 0 && devices.length > 0 && !devices.includes(edge.deviceId)) continue;
|
|
30890
|
+
const excluded = rule.conditions.devicesExclude;
|
|
30891
|
+
if (excluded !== void 0 && deviceScopeExcludes(excluded, { deviceId: edge.deviceId })) continue;
|
|
30104
30892
|
return shelf.capture(edge.deviceId, edge.timestamp, "occupancy");
|
|
30105
30893
|
}
|
|
30106
30894
|
}
|
|
@@ -30176,6 +30964,11 @@ var NotificationCenter = class NotificationCenter {
|
|
|
30176
30964
|
for (const item of consumables.items) this.deviceAlerts.observeConsumable(consumables.deviceId, item);
|
|
30177
30965
|
return;
|
|
30178
30966
|
}
|
|
30967
|
+
const tanks = event.category === "device.state-changed" ? vacuumTanksOf(event.data) : null;
|
|
30968
|
+
if (tanks !== null) {
|
|
30969
|
+
for (const tank of tanks.tanks) this.deviceAlerts.observeTank(tanks.deviceId, tank);
|
|
30970
|
+
return;
|
|
30971
|
+
}
|
|
30179
30972
|
const lookup = this.deviceDirectory.lookup();
|
|
30180
30973
|
const incoming = incomingFromSystemEvent(event, lookup);
|
|
30181
30974
|
if (incoming === null) {
|
|
@@ -30236,6 +31029,14 @@ var NotificationCenter = class NotificationCenter {
|
|
|
30236
31029
|
logger: this.logger,
|
|
30237
31030
|
by: caller.userId
|
|
30238
31031
|
});
|
|
31032
|
+
guardRuleSensorTransition(rule, {
|
|
31033
|
+
logger: this.logger,
|
|
31034
|
+
by: caller.userId
|
|
31035
|
+
});
|
|
31036
|
+
guardRuleDevicesExclude(rule, {
|
|
31037
|
+
logger: this.logger,
|
|
31038
|
+
by: caller.userId
|
|
31039
|
+
});
|
|
30239
31040
|
await this.validateTargetRefs(rule.targets.map((t) => t.targetId));
|
|
30240
31041
|
const created = await this.rules.create(rule, caller.userId);
|
|
30241
31042
|
this.logger.info("notification rule created", { meta: {
|
|
@@ -30254,6 +31055,16 @@ var NotificationCenter = class NotificationCenter {
|
|
|
30254
31055
|
by: caller.userId,
|
|
30255
31056
|
ruleId
|
|
30256
31057
|
});
|
|
31058
|
+
guardPatchSensorTransition(existing, patch, {
|
|
31059
|
+
logger: this.logger,
|
|
31060
|
+
by: caller.userId,
|
|
31061
|
+
ruleId
|
|
31062
|
+
});
|
|
31063
|
+
guardPatchDevicesExclude(existing, patch, {
|
|
31064
|
+
logger: this.logger,
|
|
31065
|
+
by: caller.userId,
|
|
31066
|
+
ruleId
|
|
31067
|
+
});
|
|
30257
31068
|
const { disabledTargetIds: _optOut, ...safePatch } = patch;
|
|
30258
31069
|
const updated = await this.rules.update(ruleId, safePatch, clear ?? []);
|
|
30259
31070
|
this.logger.info("notification rule updated", { meta: {
|
|
@@ -30276,7 +31087,7 @@ var NotificationCenter = class NotificationCenter {
|
|
|
30276
31087
|
catalog: [...NC_CONDITION_CATALOG],
|
|
30277
31088
|
taxonomy: NC_TAXONOMY
|
|
30278
31089
|
}),
|
|
30279
|
-
getTemplateCatalog: async () => ({ vars:
|
|
31090
|
+
getTemplateCatalog: async () => ({ vars: servedTemplateVars() }),
|
|
30280
31091
|
previewTemplate: async (input) => previewTemplate(this.texts, input),
|
|
30281
31092
|
getHistory: async ({ filter }) => {
|
|
30282
31093
|
const entries = await this.outbox.queryHistory({
|
|
@@ -30295,6 +31106,14 @@ var NotificationCenter = class NotificationCenter {
|
|
|
30295
31106
|
}
|
|
30296
31107
|
return { entries: mapped };
|
|
30297
31108
|
},
|
|
31109
|
+
getRuleLastRuns: async () => {
|
|
31110
|
+
const now = this.now();
|
|
31111
|
+
const windowMs = (await this.deps.pruneWindows()).outboxMs;
|
|
31112
|
+
return summariseRuleLastRuns(await this.outbox.queryLastRunRows(windowMs !== null ? now - windowMs : null, NC_RULE_LAST_RUNS_SCAN_LIMIT), {
|
|
31113
|
+
now,
|
|
31114
|
+
windowMs
|
|
31115
|
+
});
|
|
31116
|
+
},
|
|
30298
31117
|
resolveArtifactUrl: async ({ artifactId }) => ({ url: await this.deps.resolveArtifactUrl?.(artifactId) ?? null }),
|
|
30299
31118
|
listDeviceMutes: async () => ({ mutedDeviceIds: this.deviceMutes.listMuted() }),
|
|
30300
31119
|
setDeviceMuted: async ({ deviceId, muted }) => {
|
|
@@ -30626,6 +31445,7 @@ var NotificationCenter = class NotificationCenter {
|
|
|
30626
31445
|
coverage: this.coverageFor(panel).map((c) => ({
|
|
30627
31446
|
...c,
|
|
30628
31447
|
deviceIds: [...c.deviceIds],
|
|
31448
|
+
allDevicesExcept: [...c.allDevicesExcept],
|
|
30629
31449
|
skippedDevices: c.skippedDevices.map((s) => ({ ...s }))
|
|
30630
31450
|
}))
|
|
30631
31451
|
};
|
|
@@ -31135,6 +31955,9 @@ var NotificationCenter = class NotificationCenter {
|
|
|
31135
31955
|
failed: evaluation.failedCondition,
|
|
31136
31956
|
classes: subject.classNames,
|
|
31137
31957
|
...subject.sensorEdge !== void 0 ? { sensorEdge: subject.sensorEdge } : {},
|
|
31958
|
+
...subject.sensorRaised !== void 0 ? { sensorRaised: subject.sensorRaised } : {},
|
|
31959
|
+
...subject.sourceDeviceId !== void 0 ? { sourceDeviceId: subject.sourceDeviceId } : {},
|
|
31960
|
+
...subject.linkedSensorId !== void 0 ? { linkedSensorId: subject.linkedSensorId } : {},
|
|
31138
31961
|
eventId: subject.recordId,
|
|
31139
31962
|
...subject.trackId !== void 0 ? { trackId: subject.trackId } : {}
|
|
31140
31963
|
}
|
|
@@ -31244,7 +32067,11 @@ var NotificationCenter = class NotificationCenter {
|
|
|
31244
32067
|
eventId: subject.recordId,
|
|
31245
32068
|
...subject.trackId !== void 0 ? { trackId: subject.trackId } : {},
|
|
31246
32069
|
...subject.confidence !== void 0 ? { confidence: subject.confidence } : {},
|
|
31247
|
-
...subject.zones.length > 0 ? { zones: subject.zones } : {}
|
|
32070
|
+
...subject.zones.length > 0 ? { zones: subject.zones } : {},
|
|
32071
|
+
...rule.conditions.sensorTransition !== void 0 ? {
|
|
32072
|
+
sensorTransition: rule.conditions.sensorTransition,
|
|
32073
|
+
sensorRaised: subject.sensorRaised ?? null
|
|
32074
|
+
} : {}
|
|
31248
32075
|
}
|
|
31249
32076
|
});
|
|
31250
32077
|
const unresolvable = unresolvableButtons(rule.actions);
|
|
@@ -31851,7 +32678,8 @@ var NotificationCenter = class NotificationCenter {
|
|
|
31851
32678
|
ruleId: rule.id,
|
|
31852
32679
|
ruleName: rule.name,
|
|
31853
32680
|
spec,
|
|
31854
|
-
devices: rule.conditions.devices ?? []
|
|
32681
|
+
devices: rule.conditions.devices ?? [],
|
|
32682
|
+
excluded: rule.conditions.devicesExclude ?? []
|
|
31855
32683
|
});
|
|
31856
32684
|
}
|
|
31857
32685
|
this.audioWatcher.setWatchedSpecs(audioSpecs);
|
|
@@ -31943,10 +32771,12 @@ var NotificationCenter = class NotificationCenter {
|
|
|
31943
32771
|
* for at all"; the per-camera line answers "why is 617 not notifying" — and
|
|
31944
32772
|
* that question is ALWAYS asked per camera, so the line carries
|
|
31945
32773
|
* `tags.deviceId` and a rule scoped to no camera is reported as watching
|
|
31946
|
-
* every one rather than silently omitted
|
|
32774
|
+
* every one rather than silently omitted — every one EXCEPT its
|
|
32775
|
+
* `devicesExclude` cameras (D641), each of which gets its own NOT-watching
|
|
32776
|
+
* line on that camera.
|
|
31947
32777
|
*/
|
|
31948
32778
|
reportAudioWatch(entries) {
|
|
31949
|
-
const rendered = entries.map((e) => `${e.ruleId}=${renderAudioSpec(e.spec)}@${renderAudioScope(e.devices)}`).toSorted().join(",");
|
|
32779
|
+
const rendered = entries.map((e) => `${e.ruleId}=${renderAudioSpec(e.spec)}@${renderAudioScope(e.devices, e.excluded)}`).toSorted().join(",");
|
|
31950
32780
|
if (rendered === this.lastAudioWatchReport) return;
|
|
31951
32781
|
this.lastAudioWatchReport = rendered;
|
|
31952
32782
|
this.logger.info("audio watched specs", { meta: {
|
|
@@ -31960,14 +32790,28 @@ var NotificationCenter = class NotificationCenter {
|
|
|
31960
32790
|
mode: entry.spec.mode,
|
|
31961
32791
|
spec: renderAudioSpec(entry.spec)
|
|
31962
32792
|
};
|
|
32793
|
+
for (const deviceId of entry.excluded) this.logger.info("audio rule NOT watching camera", {
|
|
32794
|
+
tags: { deviceId },
|
|
32795
|
+
meta: {
|
|
32796
|
+
...meta,
|
|
32797
|
+
reason: "devicesExclude"
|
|
32798
|
+
}
|
|
32799
|
+
});
|
|
31963
32800
|
if (entry.devices.length === 0) {
|
|
31964
|
-
this.logger.info("audio rule watching every camera", { meta });
|
|
32801
|
+
if (entry.excluded.length === 0) this.logger.info("audio rule watching every camera", { meta });
|
|
32802
|
+
else this.logger.info("audio rule watching every camera except the excluded", { meta: {
|
|
32803
|
+
...meta,
|
|
32804
|
+
excluded: [...entry.excluded]
|
|
32805
|
+
} });
|
|
31965
32806
|
continue;
|
|
31966
32807
|
}
|
|
31967
|
-
for (const deviceId of entry.devices)
|
|
31968
|
-
|
|
31969
|
-
|
|
31970
|
-
|
|
32808
|
+
for (const deviceId of entry.devices) {
|
|
32809
|
+
if (entry.excluded.includes(deviceId)) continue;
|
|
32810
|
+
this.logger.info("audio rule watching camera", {
|
|
32811
|
+
tags: { deviceId },
|
|
32812
|
+
meta
|
|
32813
|
+
});
|
|
32814
|
+
}
|
|
31971
32815
|
}
|
|
31972
32816
|
}
|
|
31973
32817
|
/** Boot reseed of confirmed occupancy edge-state (durability, constraint 4) —
|
|
@@ -32046,7 +32890,6 @@ var NotificationCenter = class NotificationCenter {
|
|
|
32046
32890
|
deviceAlertSettings() {
|
|
32047
32891
|
return this.deps.readDeviceAlertSettings?.() ?? {
|
|
32048
32892
|
batteryLowPercent: NC_DEVICE_ALERT_FALLBACK.batteryLowPercent,
|
|
32049
|
-
consumableLowPercent: NC_DEVICE_ALERT_FALLBACK.consumableLowPercent,
|
|
32050
32893
|
renotifyMinutes: NC_DEVICE_ALERT_FALLBACK.renotifyMinutes,
|
|
32051
32894
|
notifyRecovery: NC_DEVICE_ALERT_FALLBACK.notifyRecovery
|
|
32052
32895
|
};
|
|
@@ -55614,19 +56457,6 @@ var DeviceAlertSettingsSchema = object({
|
|
|
55614
56457
|
*/
|
|
55615
56458
|
batteryLowPercent: number().int().min(1).max(99).default(20),
|
|
55616
56459
|
/**
|
|
55617
|
-
* What counts as a nearly-exhausted CONSUMABLE — a filter, a brush, a mop pad.
|
|
55618
|
-
*
|
|
55619
|
-
* Its own number and not the battery's, because the two are different chores
|
|
55620
|
-
* on different clocks: a battery at 20 % is hours from flat, a brush at 20 %
|
|
55621
|
-
* is weeks from worn, and an operator who wants to be told early about one
|
|
55622
|
-
* does not necessarily want to be told early about the other.
|
|
55623
|
-
*
|
|
55624
|
-
* Applies to the MEASURED side only. An item whose provider states a
|
|
55625
|
-
* `replace` verdict is low whatever this says — the provider decided, and a
|
|
55626
|
-
* threshold cannot talk it out of it (`alerts/consumable-band.ts`).
|
|
55627
|
-
*/
|
|
55628
|
-
consumableLowPercent: number().int().min(1).max(99).default(15),
|
|
55629
|
-
/**
|
|
55630
56460
|
* How long a still-true alert waits before it says itself again.
|
|
55631
56461
|
*
|
|
55632
56462
|
* **0 = say it once.** That is the behaviour of every other notification this
|
|
@@ -77330,7 +78160,7 @@ function buildGlobalSettingsSchema(cap) {
|
|
|
77330
78160
|
{
|
|
77331
78161
|
id: "device-alerts",
|
|
77332
78162
|
title: "Battery & offline alerts",
|
|
77333
|
-
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
|
|
78163
|
+
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.",
|
|
77334
78164
|
columns: 2,
|
|
77335
78165
|
fields: [
|
|
77336
78166
|
{
|
|
@@ -77344,17 +78174,6 @@ function buildGlobalSettingsSchema(cap) {
|
|
|
77344
78174
|
default: DEVICE_ALERT_DEFAULTS.batteryLowPercent,
|
|
77345
78175
|
unit: "%"
|
|
77346
78176
|
},
|
|
77347
|
-
{
|
|
77348
|
-
type: "number",
|
|
77349
|
-
key: "consumableLowPercent",
|
|
77350
|
-
label: "Low consumable at",
|
|
77351
|
-
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.",
|
|
77352
|
-
min: 1,
|
|
77353
|
-
max: 99,
|
|
77354
|
-
step: 1,
|
|
77355
|
-
default: DEVICE_ALERT_DEFAULTS.consumableLowPercent,
|
|
77356
|
-
unit: "%"
|
|
77357
|
-
},
|
|
77358
78177
|
{
|
|
77359
78178
|
type: "number",
|
|
77360
78179
|
key: "alertRenotifyMinutes",
|
|
@@ -83514,7 +84333,6 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
|
|
|
83514
84333
|
},
|
|
83515
84334
|
readDeviceAlertSettings: () => ({
|
|
83516
84335
|
batteryLowPercent: this.deviceAlertSettingsMirror.batteryLowPercent,
|
|
83517
|
-
consumableLowPercent: this.deviceAlertSettingsMirror.consumableLowPercent,
|
|
83518
84336
|
renotifyMinutes: this.deviceAlertSettingsMirror.alertRenotifyMinutes,
|
|
83519
84337
|
notifyRecovery: this.deviceAlertSettingsMirror.alertNotifyRecovery
|
|
83520
84338
|
}),
|
|
@@ -89355,10 +90173,12 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
|
|
|
89355
90173
|
startLinkedStills(data, timestamp, cameras) {
|
|
89356
90174
|
const sensorKind = EVENT_KIND_BY_CAP[data.capName]?.kind;
|
|
89357
90175
|
if (sensorKind === void 0) return void 0;
|
|
90176
|
+
const sensorRaised = sensorRaisedOf(sensorKind, sensorSliceRecord(data.slice));
|
|
89358
90177
|
try {
|
|
89359
90178
|
return this.notificationCenter?.beginLinkedStills({
|
|
89360
90179
|
sensorId: data.deviceId,
|
|
89361
90180
|
sensorKind,
|
|
90181
|
+
...sensorRaised !== void 0 ? { sensorRaised } : {},
|
|
89362
90182
|
cameraIds: cameras.mediaCameraIds,
|
|
89363
90183
|
rowCameraIds: cameras.rowCameraIds,
|
|
89364
90184
|
atMs: timestamp
|