@camstack/addon-post-analysis 1.2.282 → 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 { $t as systemEventFamilyProblems, A as NC_SENSOR_TRANSITION_KINDS, At as isEventOwnerType, B as NcRuleSchema, C as MAX_BIRTH_DECISION_RECORDS, Cn as partialRecord, Ct as encodeVectorBase64, D as NC_ALARM_SYSTEM_EVENT_KINDS, Dn as EventCategory, Dt as failureContributionCapability, E as MediaFileKindEnum, En as unknown, Et as faceGalleryCapability, F as NcAlarmSettingsPatchSchema, Ft as isSystemDelivery, G as NcSnoozeSchema, Gt as readTimelapseGeneratedAt, H as NcRuleUpdateInputSchema, Ht as pipelineAnalyticsCapability, I as NcConditionDescriptorSchema, It as kebabToCamel, J as NcTaxonomySchema, Jt as ruleBelongsToAlarm$1, K as NcSnoozeSuppressedSchema, Kt as resolveLocationMode, L as NcConditionsSchema, Lt as mayWriteToLocation, M as NC_TAXONOMY, Mt as isMediaOwnerType, N as NC_TEMPLATE_VARS, Nt as isScheduleActive, O as NC_CONDITION_CATALOG, P as NcAlarmConfigSchema, Pt as isSourceCap, Q as OpsLogEntrySchema, Qt as subKindsOf, R as NcRuleInputSchema, Rt as notificationRulesCapability, S as MAX_ARCHIVED_DEBUG_NOTES, Sn as object, T as MOTION_CLOSE_AFTER_MS, Tn as string, Tt as evictionPolicyOfLocation, U as NcScheduleSchema, Ut as plateGalleryCapability, V as NcRuleTargetSchema, Vt as pickClusterStepModels, W as NcSnoozeInputSchema, Wt as readDeviceStateFrom, X as NcTemplatePreviewSchema, Xt as sensorRaisedOf, Y as NcTemplatePreviewInputSchema, Yt as sceneMonitorCapability, Z as NcTemplateVarDescriptorSchema, Zt as storageOccupancyCapability, _ as FIRST_LEVEL_MACRO_CLASSES, _n as array, _t as audioModeOf, a as BirthDecisionRecordSchema, an as canonicalHash, at as SCENE_DEFAULT_UNCOVERED_POLICY, b as LabelAttributionSchema, bn as literal, bt as deriveRecordingMode, c as CoverStateSchema, cn as DeviceType, ct as TIMELAPSE_DENSE_FLOOR_SEC, dn as defineCustomActions, dt as TimelapseRuleSchema, en as systemEventFilterApplies, et as RECORDING_EXPORT_MAX_READ_BYTES, f as DETECTION_MACRO_CLASSES, fn as hydrateSchema, ft as TrackSourceSchema, g as EVENT_OWNER_TYPES, gn as _enum, gt as audioMetricsCapability, h as EVENT_KIND_BY_CAP, hn as sleep$1, ht as assertTimelapseCadences, i as BaseDevice, in as errMsg$1, it as SCENE_DEFAULT_ANCHOR_THRESHOLD, jt as isKnownTemplateVar, k as NC_DEFAULT_SNOOZE_MINUTES, kt as isDetectionMacroClass, l as DEFAULT_EVENT_COLOR, ln as createEvent, lt as TimelapseRuleInputSchema, m as DeclaredDevices, mn as nodePin, mt as alarmPanelCapability, n as AlarmArmModeSchema, nn as vectorDimFromBase64, nt as RetrainStatusSchema, o as CLUSTER_MODEL_SCOPED_STEPS, on as BaseAddon, ot as SCENE_DIVERGED, pn as isDeviceScopedCap, pt as addonWidgetsSourceCapability, q as NcSystemEventKindSchema, r as ArchivedDebugNoteSchema, rn as zoneAnalyticsCapability, s as COCO_TO_MACRO, sn as CamProfileSchema, st as SceneMonitorSchema, t as AUDIO_MACRO_LABELS, tn as templateVarsFor, tt as RECORDING_SOURCE_CAMSTACK_ADDON, un as customAction, ut as TimelapseRulePatchSchema, v as FULL_IMAGE_BBOX, vn as boolean, vt as buildEventKindDescriptor, wn as record, wt as evaluateSensorEdge, x as LockStateSchema, xn as number, xt as describeSystemEventFamilyProblem, y as FailureCounters, yn as discriminatedUnion, yt as cosineSimilarity$1, z as NcRulePatchSchema, zt as occupancyScope } from "../dist-C3zTKShG.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";
@@ -6656,6 +6656,8 @@ var SceneEngine = class {
6656
6656
  var DEFAULT_LOAD_LIMIT = 1e5;
6657
6657
  var DurableLedger = class DurableLedger {
6658
6658
  mirror = /* @__PURE__ */ new Map();
6659
+ /** Whether ANY load has read the store successfully — see {@link hasAnswered}. */
6660
+ answered = false;
6659
6661
  spec;
6660
6662
  store;
6661
6663
  logger;
@@ -6711,6 +6713,7 @@ var DurableLedger = class DurableLedger {
6711
6713
  }
6712
6714
  this.mirror.clear();
6713
6715
  for (const [key, row] of next) this.mirror.set(key, row);
6716
+ this.answered = true;
6714
6717
  if (skipped > 0) this.logger.warn("durable rows skipped as malformed — they gate NOTHING", { meta: {
6715
6718
  collection: this.spec.collection,
6716
6719
  skipped
@@ -6725,6 +6728,16 @@ var DurableLedger = class DurableLedger {
6725
6728
  return [...this.mirror.values()];
6726
6729
  }
6727
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
+ }
6728
6741
  /** Every mirrored row, insertion-ordered. */
6729
6742
  snapshot() {
6730
6743
  return [...this.mirror.values()];
@@ -8458,6 +8471,78 @@ var NcAlertStore = class {
8458
8471
  };
8459
8472
  }
8460
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
+ }
8461
8546
  function deriveConsumableBand(input) {
8462
8547
  const { level, status } = input.reading;
8463
8548
  const measured = level !== null && Number.isFinite(level);
@@ -10121,6 +10206,9 @@ function systemEventExclusion(condition, subject) {
10121
10206
  if (wantedPackages !== void 0 && applies("packageNames")) {
10122
10207
  if (!subjectPackages(subject).some((name) => wantedPackages.includes(name))) return "packageNames";
10123
10208
  }
10209
+ if (applies("consumableLowPercent") && subject.consumableLowPercent !== void 0) {
10210
+ if (subject.consumableLowPercent !== consumableLowPercentOf(condition)) return "consumableLowPercent";
10211
+ }
10124
10212
  }
10125
10213
  function subjectNodes(subject) {
10126
10214
  const ids = [...subject.nodeIds ?? []];
@@ -11000,6 +11088,7 @@ function systemSubject(input) {
11000
11088
  ...input.packageNames !== void 0 ? { packageNames: input.packageNames } : {},
11001
11089
  ...input.textCount !== void 0 ? { textCount: input.textCount } : {},
11002
11090
  ...input.alarmSource !== void 0 ? { alarmSource: input.alarmSource } : {},
11091
+ ...input.consumableLowPercent !== void 0 ? { consumableLowPercent: input.consumableLowPercent } : {},
11003
11092
  data
11004
11093
  }
11005
11094
  },
@@ -11101,9 +11190,11 @@ function incomingFromBatteryBand(input) {
11101
11190
  * these to be treated as the battery notifications are — with two differences
11102
11191
  * that come from the subject and not from taste:
11103
11192
  *
11104
- * - **the subject carries the ITEM.** `consumable:<deviceId>:<key>`, because a
11105
- * vacuum has a brush AND a filter AND a mop pad, and one subject for all
11106
- * 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.
11107
11198
  * - **the body variant follows what is KNOWN.** An item with no percentage
11108
11199
  * gets a finished sentence rather than a template with a stand-in number in
11109
11200
  * it, for the same reason a binary battery does.
@@ -11112,17 +11203,46 @@ function incomingFromBatteryBand(input) {
11112
11203
  */
11113
11204
  function incomingFromConsumableBand(input) {
11114
11205
  const kind = input.band === "low" ? "device-consumable-low" : "device-consumable-normal";
11115
- 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;
11116
11209
  return systemSubject({
11117
11210
  facts: input.facts,
11118
11211
  kind,
11119
- subject: `consumable:${input.deviceId}:${input.item.key}`,
11212
+ subject: consumableSubject(input.thresholdPercent, input.deviceId, input.item.key),
11120
11213
  titleKey: `system.${kind}.title`,
11121
11214
  bodyKey: measured ? `system.${kind}.body` : `system.${kind}.body.unmeasured`,
11122
11215
  textParams: {
11123
11216
  ...deviceTextParams(input.deviceId, input.identity),
11124
11217
  item: input.item.label,
11125
- ...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
11126
11246
  },
11127
11247
  deviceId: input.deviceId,
11128
11248
  ...input.identity !== void 0 ? { deviceType: input.identity.type } : {}
@@ -11710,6 +11830,91 @@ function deliveryForKind(kind) {
11710
11830
  return kind;
11711
11831
  }
11712
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
11713
11918
  //#region src/notification-center/alerts/battery-band.ts
11714
11919
  /**
11715
11920
  * The stand-in a provider writes for a binary NORMAL. Anything below it, on a
@@ -11740,10 +11945,14 @@ function deriveBatteryBand(input) {
11740
11945
  * `NcDeviceAlerts` — the one place a SUSTAINED device fault becomes a
11741
11946
  * notification, and keeps becoming one until it stops being true.
11742
11947
  *
11743
- * It owns two families and nothing else:
11948
+ * It owns these families and nothing else:
11744
11949
  *
11745
11950
  * - **battery band** — `battery:<deviceId>`, fed by the `battery` runtime-state
11746
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);
11747
11956
  * - **the reminder tick** — the operator's "how often do you say it again".
11748
11957
  *
11749
11958
  * It does NOT own device liveness. `device-offline` already has a producer, a
@@ -11783,6 +11992,10 @@ var PENDING_CADENCE_MAX_MS = 36e5;
11783
11992
  function pendingCadenceMs(settings) {
11784
11993
  return settings.renotifyMinutes > 0 ? Math.min(settings.renotifyMinutes * 6e4, PENDING_CADENCE_MAX_MS) : PENDING_CADENCE_MAX_MS;
11785
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
+ }
11786
11999
  var NcDeviceAlerts = class {
11787
12000
  deps;
11788
12001
  now;
@@ -11794,8 +12007,28 @@ var NcDeviceAlerts = class {
11794
12007
  * durable.
11795
12008
  */
11796
12009
  lastReading = /* @__PURE__ */ new Map();
11797
- /** 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
+ */
11798
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();
11799
12032
  /**
11800
12033
  * Subject → WHEN its PENDING raise was last offered, and to which
11801
12034
  * {@link NcDeviceAlertsDeps.rulesVersion}. RAM only, on purpose: after a
@@ -11853,31 +12086,137 @@ var NcDeviceAlerts = class {
11853
12086
  * observed.
11854
12087
  */
11855
12088
  observeConsumable(deviceId, item) {
11856
- const subject = `consumable:${deviceId}:${item.key}`;
11857
- 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
+ }
11858
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);
11859
12179
  const band = deriveConsumableBand({
11860
12180
  reading: {
11861
12181
  level: item.level,
11862
12182
  status: item.status
11863
12183
  },
11864
- thresholdPercent: settings.consumableLowPercent,
12184
+ thresholdPercent,
11865
12185
  previous: previous?.alerting === true ? "low" : previous === void 0 ? void 0 : "normal"
11866
12186
  });
11867
12187
  if (band === null) return;
11868
- this.lastConsumable.set(subject, {
11869
- deviceId,
11870
- item
11871
- });
11872
12188
  this.apply(subject, band === "low", settings, (facts, alerting) => incomingFromConsumableBand({
11873
12189
  facts,
11874
12190
  deviceId,
11875
12191
  band: alerting ? "low" : "normal",
11876
12192
  item,
12193
+ thresholdPercent,
11877
12194
  identity: this.deps.lookup(deviceId)
11878
12195
  }));
11879
12196
  }
11880
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
+ /**
11881
12220
  * The reminder sweep. Called from an existing periodic tick; it allocates
11882
12221
  * nothing on the healthy path.
11883
12222
  *
@@ -11888,6 +12227,9 @@ var NcDeviceAlerts = class {
11888
12227
  */
11889
12228
  tick() {
11890
12229
  const settings = this.deps.settings();
12230
+ const thresholds = this.deps.consumableThresholds();
12231
+ if (thresholds !== null) this.reconcileThresholds(thresholds, settings);
12232
+ this.confirmHeldTanks();
11891
12233
  this.retryPending(settings);
11892
12234
  if (settings.renotifyMinutes <= 0) return;
11893
12235
  for (const row of this.deps.rows.all()) {
@@ -11920,14 +12262,58 @@ var NcDeviceAlerts = class {
11920
12262
  const subjects = this.deps.rows.all().map((row) => row.subject).filter((subject) => alertSubjectDeviceId(subject) === deviceId);
11921
12263
  for (const subject of subjects) {
11922
12264
  this.lastReading.delete(subject);
11923
- this.lastConsumable.delete(subject);
12265
+ this.lastTank.delete(subject);
11924
12266
  this.offeredAt.delete(subject);
11925
12267
  await this.deps.rows.remove(subject);
11926
12268
  }
11927
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);
11928
12273
  return subjects.length;
11929
12274
  }
11930
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
+ /**
11931
12317
  * Offer every PENDING raise once more — when the rule set changed since its
11932
12318
  * last offer, OR when {@link pendingCadenceMs} has passed since it. The
11933
12319
  * cadence is what delivers a raise refused only by a rule's SCHEDULE (or any
@@ -11959,14 +12345,25 @@ var NcDeviceAlerts = class {
11959
12345
  }
11960
12346
  /** How to re-word a subject's alert from the last reading, or `null`. */
11961
12347
  restatementOf(subject) {
11962
- const consumable = this.lastConsumable.get(subject);
11963
- 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({
11964
12351
  facts,
11965
12352
  deviceId: consumable.deviceId,
11966
12353
  band: "low",
11967
12354
  item: consumable.item,
12355
+ thresholdPercent: parts.thresholdPercent,
11968
12356
  identity: this.deps.lookup(consumable.deviceId)
11969
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
+ });
11970
12367
  const battery = this.lastReading.get(subject);
11971
12368
  if (battery !== void 0) return (facts) => incomingFromBatteryBand({
11972
12369
  facts,
@@ -12066,11 +12463,9 @@ var NcDeviceAlerts = class {
12066
12463
  report(subject, verdict, alerting) {
12067
12464
  const logger = this.deps.logger;
12068
12465
  if (logger === void 0) return;
12069
- const deviceId = alertSubjectDeviceId(subject);
12070
- const tags = deviceId !== void 0 ? { deviceId } : {};
12071
12466
  if (verdict.kind === "silent" && !alerting) {
12072
12467
  logger.debug("device alert cleared without a notification", {
12073
- tags,
12468
+ ...tagsOf(subject),
12074
12469
  meta: {
12075
12470
  subject,
12076
12471
  reason: "notifyRecovery is off, or nothing changed"
@@ -12079,7 +12474,7 @@ var NcDeviceAlerts = class {
12079
12474
  return;
12080
12475
  }
12081
12476
  logger.debug("device alert said nothing", {
12082
- tags,
12477
+ ...tagsOf(subject),
12083
12478
  meta: {
12084
12479
  subject,
12085
12480
  verdict: verdict.kind
@@ -12490,6 +12885,21 @@ var en_default = {
12490
12885
  "system.device-consumable-normal.title": "Consumable replaced",
12491
12886
  "system.device-consumable-normal.body": "{{item}} on {{deviceName}} is back to {{percentage}}%",
12492
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",
12493
12903
  "system.camera-online.title": "Device online",
12494
12904
  "system.camera-online.body": "Device {{deviceName}} is online",
12495
12905
  "system.camera-offline.title": "Device offline",
@@ -12779,6 +13189,21 @@ var it_default = {
12779
13189
  "system.device-consumable-normal.title": "Consumabile sostituito",
12780
13190
  "system.device-consumable-normal.body": "{{item}} su {{deviceName}} è tornato al {{percentage}}%",
12781
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",
12782
13207
  "system.camera-online.title": "Dispositivo online",
12783
13208
  "system.camera-online.body": "Il dispositivo {{deviceName}} è online",
12784
13209
  "system.camera-offline.title": "Dispositivo offline",
@@ -14162,6 +14587,10 @@ function systemEventKeys(kind) {
14162
14587
  titleKey: `system.${kind}.title`,
14163
14588
  bodyKey: `system.${kind}.body.ignored`
14164
14589
  };
14590
+ if (NC_TANK_SYSTEM_EVENT_KINDS.includes(kind)) return {
14591
+ titleKey: `system.${kind}.title`,
14592
+ bodyKey: `system.${kind}.body.cleanWater`
14593
+ };
14165
14594
  return {
14166
14595
  titleKey: `system.${kind}.title`,
14167
14596
  bodyKey: `system.${kind}.body`
@@ -16162,7 +16591,7 @@ function makeNcActionHandlers(deps) {
16162
16591
  catalog: [...NC_CONDITION_CATALOG],
16163
16592
  taxonomy: NC_TAXONOMY
16164
16593
  }),
16165
- "nc.getTemplateCatalog": async () => ({ vars: [...NC_TEMPLATE_VARS] }),
16594
+ "nc.getTemplateCatalog": async () => ({ vars: servedTemplateVars() }),
16166
16595
  "nc.previewTemplate": async (input) => {
16167
16596
  const texts = deps.previewTexts;
16168
16597
  if (texts === void 0) throw new Error("nc.previewTemplate: text catalog not wired");
@@ -23742,7 +24171,7 @@ var NcRuleStore = class {
23742
24171
  */
23743
24172
  async load() {
23744
24173
  await this.ledger.load();
23745
- this.loaded = true;
24174
+ if (this.ledger.hasAnswered()) this.loaded = true;
23746
24175
  this.logger.debug("notification rules loaded", { meta: { rules: this.ledger.size } });
23747
24176
  }
23748
24177
  /**
@@ -28172,7 +28601,6 @@ var ARM_ANYWAY_REJECTION_COPY = {
28172
28601
  */
28173
28602
  var NC_DEVICE_ALERT_FALLBACK = {
28174
28603
  batteryLowPercent: 20,
28175
- consumableLowPercent: 15,
28176
28604
  renotifyMinutes: 720,
28177
28605
  notifyRecovery: true
28178
28606
  };
@@ -29427,6 +29855,7 @@ var NotificationCenter = class NotificationCenter {
29427
29855
  return (await this.consumeEventAndReport(event)).outcomes.some((outcome) => outcome.matched);
29428
29856
  },
29429
29857
  rulesVersion: () => this.rulesFingerprint(),
29858
+ consumableThresholds: () => this.rules.isLoaded() ? consumableThresholdCensus(this.rules.list()) : null,
29430
29859
  settings: () => this.deviceAlertSettings(),
29431
29860
  lookup: (deviceId) => this.deviceDirectory.get(deviceId),
29432
29861
  rows: this.alertStore.port(),
@@ -30535,6 +30964,11 @@ var NotificationCenter = class NotificationCenter {
30535
30964
  for (const item of consumables.items) this.deviceAlerts.observeConsumable(consumables.deviceId, item);
30536
30965
  return;
30537
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
+ }
30538
30972
  const lookup = this.deviceDirectory.lookup();
30539
30973
  const incoming = incomingFromSystemEvent(event, lookup);
30540
30974
  if (incoming === null) {
@@ -30653,7 +31087,7 @@ var NotificationCenter = class NotificationCenter {
30653
31087
  catalog: [...NC_CONDITION_CATALOG],
30654
31088
  taxonomy: NC_TAXONOMY
30655
31089
  }),
30656
- getTemplateCatalog: async () => ({ vars: [...NC_TEMPLATE_VARS] }),
31090
+ getTemplateCatalog: async () => ({ vars: servedTemplateVars() }),
30657
31091
  previewTemplate: async (input) => previewTemplate(this.texts, input),
30658
31092
  getHistory: async ({ filter }) => {
30659
31093
  const entries = await this.outbox.queryHistory({
@@ -32456,7 +32890,6 @@ var NotificationCenter = class NotificationCenter {
32456
32890
  deviceAlertSettings() {
32457
32891
  return this.deps.readDeviceAlertSettings?.() ?? {
32458
32892
  batteryLowPercent: NC_DEVICE_ALERT_FALLBACK.batteryLowPercent,
32459
- consumableLowPercent: NC_DEVICE_ALERT_FALLBACK.consumableLowPercent,
32460
32893
  renotifyMinutes: NC_DEVICE_ALERT_FALLBACK.renotifyMinutes,
32461
32894
  notifyRecovery: NC_DEVICE_ALERT_FALLBACK.notifyRecovery
32462
32895
  };
@@ -56024,19 +56457,6 @@ var DeviceAlertSettingsSchema = object({
56024
56457
  */
56025
56458
  batteryLowPercent: number().int().min(1).max(99).default(20),
56026
56459
  /**
56027
- * What counts as a nearly-exhausted CONSUMABLE — a filter, a brush, a mop pad.
56028
- *
56029
- * Its own number and not the battery's, because the two are different chores
56030
- * on different clocks: a battery at 20 % is hours from flat, a brush at 20 %
56031
- * is weeks from worn, and an operator who wants to be told early about one
56032
- * does not necessarily want to be told early about the other.
56033
- *
56034
- * Applies to the MEASURED side only. An item whose provider states a
56035
- * `replace` verdict is low whatever this says — the provider decided, and a
56036
- * threshold cannot talk it out of it (`alerts/consumable-band.ts`).
56037
- */
56038
- consumableLowPercent: number().int().min(1).max(99).default(15),
56039
- /**
56040
56460
  * How long a still-true alert waits before it says itself again.
56041
56461
  *
56042
56462
  * **0 = say it once.** That is the behaviour of every other notification this
@@ -77740,7 +78160,7 @@ function buildGlobalSettingsSchema(cap) {
77740
78160
  {
77741
78161
  id: "device-alerts",
77742
78162
  title: "Battery & offline alerts",
77743
- 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.",
77744
78164
  columns: 2,
77745
78165
  fields: [
77746
78166
  {
@@ -77754,17 +78174,6 @@ function buildGlobalSettingsSchema(cap) {
77754
78174
  default: DEVICE_ALERT_DEFAULTS.batteryLowPercent,
77755
78175
  unit: "%"
77756
78176
  },
77757
- {
77758
- type: "number",
77759
- key: "consumableLowPercent",
77760
- label: "Low consumable at",
77761
- 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.",
77762
- min: 1,
77763
- max: 99,
77764
- step: 1,
77765
- default: DEVICE_ALERT_DEFAULTS.consumableLowPercent,
77766
- unit: "%"
77767
- },
77768
78177
  {
77769
78178
  type: "number",
77770
78179
  key: "alertRenotifyMinutes",
@@ -83924,7 +84333,6 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
83924
84333
  },
83925
84334
  readDeviceAlertSettings: () => ({
83926
84335
  batteryLowPercent: this.deviceAlertSettingsMirror.batteryLowPercent,
83927
- consumableLowPercent: this.deviceAlertSettingsMirror.consumableLowPercent,
83928
84336
  renotifyMinutes: this.deviceAlertSettingsMirror.alertRenotifyMinutes,
83929
84337
  notifyRecovery: this.deviceAlertSettingsMirror.alertNotifyRecovery
83930
84338
  }),