@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.
@@ -1,4 +1,4 @@
1
- import { $ as RECORDING_EXPORT_MAX_READ_BYTES, $t as vectorDimFromBase64, At as isKnownTemplateVar, B as NcRuleTargetSchema, Bt as pickClusterStepModels, C as MAX_BIRTH_DECISION_RECORDS, Cn as unknown, Ct as evaluateSensorEdge, D as NC_ALARM_SYSTEM_EVENT_KINDS, E as MediaFileKindEnum, Et as failureContributionCapability, F as NcConditionDescriptorSchema, Ft as kebabToCamel, G as NcSnoozeSuppressedSchema, Gt as resolveLocationMode, H as NcScheduleSchema, Ht as plateGalleryCapability, I as NcConditionsSchema, It as mayWriteToLocation, J as NcTemplatePreviewInputSchema, Jt as storageOccupancyCapability, K as NcSystemEventKindSchema, L as NcRuleInputSchema, Lt as notificationRulesCapability, M as NC_TEMPLATE_VARS, Mt as isScheduleActive, N as NcAlarmConfigSchema, Nt as isSourceCap, O as NC_CONDITION_CATALOG, Ot as isDetectionMacroClass, P as NcAlarmSettingsPatchSchema, Pt as isSystemDelivery, Qt as templateVarsFor, R as NcRulePatchSchema, Rt as occupancyScope, S as MAX_ARCHIVED_DEBUG_NOTES, Sn as string, St as encodeVectorBase64, T as MOTION_CLOSE_AFTER_MS, Tt as faceGalleryCapability, U as NcSnoozeInputSchema, Ut as readDeviceStateFrom, V as NcRuleUpdateInputSchema, Vt as pipelineAnalyticsCapability, W as NcSnoozeSchema, Wt as readTimelapseGeneratedAt, X as NcTemplateVarDescriptorSchema, Xt as systemEventFamilyProblems, Y as NcTemplatePreviewSchema, Yt as subKindsOf, Z as OpsLogEntrySchema, Zt as systemEventFilterApplies, _ as FIRST_LEVEL_MACRO_CLASSES, _n as literal, _t as buildEventKindDescriptor, a as BirthDecisionRecordSchema, an as DeviceType, at as SCENE_DIVERGED, b as LabelAttributionSchema, bn as partialRecord, bt as describeSystemEventFamilyProblem, c as CoverStateSchema, cn as defineCustomActions, ct as TimelapseRuleInputSchema, dn as nodePin, dt as TrackSourceSchema, en as zoneAnalyticsCapability, et as RECORDING_SOURCE_CAMSTACK_ADDON, f as DETECTION_MACRO_CLASSES, fn as sleep$1, ft as addonWidgetsSourceCapability, g as EVENT_OWNER_TYPES, gn as discriminatedUnion, gt as audioModeOf, h as EVENT_KIND_BY_CAP, hn as boolean, ht as audioMetricsCapability, i as BaseDevice, in as CamProfileSchema, it as SCENE_DEFAULT_UNCOVERED_POLICY, j as NC_TAXONOMY, jt as isMediaOwnerType, k as NC_DEFAULT_SNOOZE_MINUTES, kt as isEventOwnerType, l as DEFAULT_EVENT_COLOR, ln as hydrateSchema, lt as TimelapseRulePatchSchema, m as DeclaredDevices, mn as array, mt as assertTimelapseCadences, n as AlarmArmModeSchema, nn as canonicalHash, o as CLUSTER_MODEL_SCOPED_STEPS, on as createEvent, ot as SceneMonitorSchema, pn as _enum, pt as alarmPanelCapability, q as NcTaxonomySchema, qt as sceneMonitorCapability, r as ArchivedDebugNoteSchema, rn as BaseAddon, rt as SCENE_DEFAULT_ANCHOR_THRESHOLD, s as COCO_TO_MACRO, sn as customAction, st as TIMELAPSE_DENSE_FLOOR_SEC, t as AUDIO_MACRO_LABELS, tn as errMsg$1, tt as RetrainStatusSchema, un as isDeviceScopedCap, ut as TimelapseRuleSchema, v as FULL_IMAGE_BBOX, vn as number, vt as cosineSimilarity$1, wn as EventCategory, wt as evictionPolicyOfLocation, x as LockStateSchema, xn as record, y as FailureCounters, yn as object, yt as deriveRecordingMode, z as NcRuleSchema } from "../dist-DkERzZfY.mjs";
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
- for (const id of scope) ids.add(id);
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$2 = "alarm-panel";
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$2) return true;
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
- if ((rule.conditions.deviceState?.states ?? []).some((s) => s.trim().toLowerCase().startsWith(ARMED_WORD_PREFIX))) return true;
9972
- 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));
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:<deviceId>:<key>`, because a
11039
- * vacuum has a brush AND a filter AND a mop pad, and one subject for all
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 measured = input.item.level !== null && Number.isFinite(input.item.level);
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: `consumable:${input.deviceId}:${input.item.key}`,
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
- ...measured ? { percentage: String(Math.round(input.item.level)) } : {}
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 two families and nothing else:
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
- /** The last reading per consumable SUBJECT, for the reminder sweep. */
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
- const subject = `consumable:${deviceId}:${item.key}`;
11791
- const previous = this.deps.rows.get(subject);
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: settings.consumableLowPercent,
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.lastConsumable.delete(subject);
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 consumable = this.lastConsumable.get(subject);
11897
- if (consumable !== void 0) return (facts) => incomingFromConsumableBand({
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
- tags,
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
- tags,
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: [...NC_TEMPLATE_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
- /** The camera scope in one token — `@all` is a real answer, not a missing one. */
28127
- function renderAudioScope(devices) {
28128
- return devices.length === 0 ? "@all" : devices.join("+");
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: [...NC_TEMPLATE_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) this.logger.info("audio rule watching camera", {
31968
- tags: { deviceId },
31969
- meta
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 “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.",
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