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