@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.
@@ -2,7 +2,7 @@ Object.defineProperties(exports, {
2
2
  __esModule: { value: true },
3
3
  [Symbol.toStringTag]: { value: "Module" }
4
4
  });
5
- const require_dist = require("../dist-D0zzdvSd.js");
5
+ const require_dist = require("../dist-CT5xOY86.js");
6
6
  let node_fs = require("node:fs");
7
7
  node_fs = require_dist.__toESM(node_fs, 1);
8
8
  let node_path = require("node:path");
@@ -6663,6 +6663,8 @@ var SceneEngine = class {
6663
6663
  var DEFAULT_LOAD_LIMIT = 1e5;
6664
6664
  var DurableLedger = class DurableLedger {
6665
6665
  mirror = /* @__PURE__ */ new Map();
6666
+ /** Whether ANY load has read the store successfully — see {@link hasAnswered}. */
6667
+ answered = false;
6666
6668
  spec;
6667
6669
  store;
6668
6670
  logger;
@@ -6718,6 +6720,7 @@ var DurableLedger = class DurableLedger {
6718
6720
  }
6719
6721
  this.mirror.clear();
6720
6722
  for (const [key, row] of next) this.mirror.set(key, row);
6723
+ this.answered = true;
6721
6724
  if (skipped > 0) this.logger.warn("durable rows skipped as malformed — they gate NOTHING", { meta: {
6722
6725
  collection: this.spec.collection,
6723
6726
  skipped
@@ -6732,6 +6735,16 @@ var DurableLedger = class DurableLedger {
6732
6735
  return [...this.mirror.values()];
6733
6736
  }
6734
6737
  }
6738
+ /**
6739
+ * Whether the mirror is an ANSWER from the store: true once any load has
6740
+ * succeeded, and never false again (a later failed load keeps that answer).
6741
+ * Before it, an empty mirror is "not read yet", never "the store is empty" —
6742
+ * {@link load} swallows its failure, so a caller that must tell the two apart
6743
+ * asks here (D642 review I1).
6744
+ */
6745
+ hasAnswered() {
6746
+ return this.answered;
6747
+ }
6735
6748
  /** Every mirrored row, insertion-ordered. */
6736
6749
  snapshot() {
6737
6750
  return [...this.mirror.values()];
@@ -8465,6 +8478,78 @@ var NcAlertStore = class {
8465
8478
  };
8466
8479
  }
8467
8480
  };
8481
+ //#endregion
8482
+ //#region src/notification-center/alerts/consumable-subject.ts
8483
+ /**
8484
+ * A consumable EPISODE's name, and the thresholds the rule set asks for (D642).
8485
+ *
8486
+ * Before D642 one cluster-wide threshold judged every item, so an item had one
8487
+ * episode: `consumable:<deviceId>:<key>`. The threshold is now the RULE's, and
8488
+ * the ledger keeps one episode per threshold a rule asks for — each with its
8489
+ * own hysteresis, pending raise and reminder — named
8490
+ * `consumable@<threshold>:<deviceId>:<key>`.
8491
+ *
8492
+ * The threshold rides the FAMILY segment on purpose: the device id stays the
8493
+ * second segment (`alertSubjectDeviceId`, D636 — the log tag and the indexed
8494
+ * column read it), and the item key stays last and whole, whatever it holds.
8495
+ *
8496
+ * Pure. No clock, no I/O.
8497
+ */
8498
+ var FAMILY = "consumable";
8499
+ /** The episode of `key` on `deviceId`, judged at `thresholdPercent`. */
8500
+ function consumableSubject(thresholdPercent, deviceId, key) {
8501
+ return `${FAMILY}@${String(thresholdPercent)}:${String(deviceId)}:${key}`;
8502
+ }
8503
+ /** The pre-D642 subject of the same item — read only to carry it over. */
8504
+ function legacyConsumableSubject(deviceId, key) {
8505
+ return `${FAMILY}:${String(deviceId)}:${key}`;
8506
+ }
8507
+ /** `null` for a subject that is not a consumable's. */
8508
+ function parseConsumableSubject(subject) {
8509
+ const first = subject.indexOf(":");
8510
+ const second = first < 0 ? -1 : subject.indexOf(":", first + 1);
8511
+ if (first < 0 || second < 0) return null;
8512
+ const family = subject.slice(0, first);
8513
+ const deviceId = Number(subject.slice(first + 1, second));
8514
+ const key = subject.slice(second + 1);
8515
+ if (!Number.isInteger(deviceId) || key.length === 0) return null;
8516
+ if (family === FAMILY) return {
8517
+ thresholdPercent: 15,
8518
+ deviceId,
8519
+ key,
8520
+ legacy: true
8521
+ };
8522
+ if (!family.startsWith(`${FAMILY}@`)) return null;
8523
+ const thresholdPercent = Number(family.slice(11));
8524
+ if (!Number.isInteger(thresholdPercent)) return null;
8525
+ return {
8526
+ thresholdPercent,
8527
+ deviceId,
8528
+ key,
8529
+ legacy: false
8530
+ };
8531
+ }
8532
+ /**
8533
+ * Every consumable threshold the rule set asks for, ascending.
8534
+ *
8535
+ * A rule asks when it is a system-event rule whose kinds include a consumable
8536
+ * kind — enabled or not: a disabled rule's episode stays PENDING (no rule
8537
+ * matched it) and is re-offered the moment the rule is switched back on
8538
+ * (D636), which is what that rule would have said had it been on. With no such
8539
+ * rule at all the default is still judged, so an episode exists for the first
8540
+ * rule an operator writes to hear (D636's "offered once per rule change").
8541
+ */
8542
+ function consumableThresholdCensus(rules) {
8543
+ const thresholds = /* @__PURE__ */ new Set();
8544
+ for (const rule of rules) {
8545
+ const condition = rule.conditions.systemEvent;
8546
+ if (rule.delivery !== "system-event" || condition === void 0) continue;
8547
+ if (!condition.kinds.some((kind) => require_dist.NC_CONSUMABLE_SYSTEM_EVENT_KINDS.includes(kind))) continue;
8548
+ thresholds.add(require_dist.consumableLowPercentOf(condition));
8549
+ }
8550
+ if (thresholds.size === 0) thresholds.add(15);
8551
+ return [...thresholds].toSorted((a, b) => a - b);
8552
+ }
8468
8553
  function deriveConsumableBand(input) {
8469
8554
  const { level, status } = input.reading;
8470
8555
  const measured = level !== null && Number.isFinite(level);
@@ -10128,6 +10213,9 @@ function systemEventExclusion(condition, subject) {
10128
10213
  if (wantedPackages !== void 0 && applies("packageNames")) {
10129
10214
  if (!subjectPackages(subject).some((name) => wantedPackages.includes(name))) return "packageNames";
10130
10215
  }
10216
+ if (applies("consumableLowPercent") && subject.consumableLowPercent !== void 0) {
10217
+ if (subject.consumableLowPercent !== require_dist.consumableLowPercentOf(condition)) return "consumableLowPercent";
10218
+ }
10131
10219
  }
10132
10220
  function subjectNodes(subject) {
10133
10221
  const ids = [...subject.nodeIds ?? []];
@@ -11007,6 +11095,7 @@ function systemSubject(input) {
11007
11095
  ...input.packageNames !== void 0 ? { packageNames: input.packageNames } : {},
11008
11096
  ...input.textCount !== void 0 ? { textCount: input.textCount } : {},
11009
11097
  ...input.alarmSource !== void 0 ? { alarmSource: input.alarmSource } : {},
11098
+ ...input.consumableLowPercent !== void 0 ? { consumableLowPercent: input.consumableLowPercent } : {},
11010
11099
  data
11011
11100
  }
11012
11101
  },
@@ -11108,9 +11197,11 @@ function incomingFromBatteryBand(input) {
11108
11197
  * these to be treated as the battery notifications are — with two differences
11109
11198
  * that come from the subject and not from taste:
11110
11199
  *
11111
- * - **the subject carries the ITEM.** `consumable:<deviceId>:<key>`, because a
11112
- * vacuum has a brush AND a filter AND a mop pad, and one subject for all
11113
- * three would let the brush's alert cancel the filter's.
11200
+ * - **the subject carries the ITEM.** `consumable@<threshold>:<deviceId>:<key>`,
11201
+ * because a vacuum has a brush AND a filter AND a mop pad, and one subject
11202
+ * for all three would let the brush's alert cancel the filter's. It carries
11203
+ * the THRESHOLD too (D642): each rule's threshold is its own episode, and
11204
+ * the engine pairs a rule with the episode judged at its own number.
11114
11205
  * - **the body variant follows what is KNOWN.** An item with no percentage
11115
11206
  * gets a finished sentence rather than a template with a stand-in number in
11116
11207
  * it, for the same reason a binary battery does.
@@ -11119,17 +11210,46 @@ function incomingFromBatteryBand(input) {
11119
11210
  */
11120
11211
  function incomingFromConsumableBand(input) {
11121
11212
  const kind = input.band === "low" ? "device-consumable-low" : "device-consumable-normal";
11122
- const measured = input.item.level !== null && Number.isFinite(input.item.level);
11213
+ const level = input.item.level;
11214
+ const percentage = level !== null && Number.isFinite(level) ? Math.round(level) : null;
11215
+ const measured = percentage !== null;
11123
11216
  return systemSubject({
11124
11217
  facts: input.facts,
11125
11218
  kind,
11126
- subject: `consumable:${input.deviceId}:${input.item.key}`,
11219
+ subject: consumableSubject(input.thresholdPercent, input.deviceId, input.item.key),
11127
11220
  titleKey: `system.${kind}.title`,
11128
11221
  bodyKey: measured ? `system.${kind}.body` : `system.${kind}.body.unmeasured`,
11129
11222
  textParams: {
11130
11223
  ...deviceTextParams(input.deviceId, input.identity),
11131
11224
  item: input.item.label,
11132
- ...measured ? { percentage: String(Math.round(input.item.level)) } : {}
11225
+ ...percentage !== null ? { percentage: String(percentage) } : {}
11226
+ },
11227
+ deviceId: input.deviceId,
11228
+ consumableLowPercent: input.thresholdPercent,
11229
+ ...input.identity !== void 0 ? { deviceType: input.identity.type } : {}
11230
+ });
11231
+ }
11232
+ /**
11233
+ * A vacuum TANK band, as a system-event subject (D642).
11234
+ *
11235
+ * Built like {@link incomingFromConsumableBand}: `tank:<deviceId>:<tank>` is
11236
+ * one subject per tank (the dust bin's alert must not cancel the clean water's),
11237
+ * and the recovery keeps it. The BODY VARIANT names the tank in the reader's
11238
+ * language — the params are frozen before the language is known, so the tank's
11239
+ * name cannot be a param; `tank` carries the stable token for a custom
11240
+ * template.
11241
+ */
11242
+ function incomingFromTankBand(input) {
11243
+ const kind = input.band === "low" ? "device-tank-low" : input.band === "full" ? "device-tank-full" : "device-tank-normal";
11244
+ return systemSubject({
11245
+ facts: input.facts,
11246
+ kind,
11247
+ subject: `tank:${String(input.deviceId)}:${input.tank}`,
11248
+ titleKey: `system.${kind}.title`,
11249
+ bodyKey: `system.${kind}.body.${input.tank}`,
11250
+ textParams: {
11251
+ ...deviceTextParams(input.deviceId, input.identity),
11252
+ tank: input.tank
11133
11253
  },
11134
11254
  deviceId: input.deviceId,
11135
11255
  ...input.identity !== void 0 ? { deviceType: input.identity.type } : {}
@@ -11717,6 +11837,91 @@ function deliveryForKind(kind) {
11717
11837
  return kind;
11718
11838
  }
11719
11839
  //#endregion
11840
+ //#region src/notification-center/alerts/tank-band.ts
11841
+ /**
11842
+ * A vacuum TANK reduced to the only thing worth storing: its band (D642).
11843
+ *
11844
+ * The `vacuum-control` slice carries four tanks — clean water, dirty water,
11845
+ * detergent, dust bin — each `null` when the hardware has none, else
11846
+ * `{ level, status }`. The operator asked for them to notify like consumables:
11847
+ * clean water / detergent running low, dirty water / dust bin full.
11848
+ *
11849
+ * ## The verdict is the provider's
11850
+ *
11851
+ * A tank's `status` is the device's own float switch (`ok` | `low` | `full`),
11852
+ * and it is the ONLY thing judged. The hub has no threshold for a tank level:
11853
+ * the rule for thresholds is that they belong to whoever generates the event,
11854
+ * and a tank that says `low` has already decided. A tank that reports only a
11855
+ * `level` is therefore not judged at all (`null`) rather than judged against a
11856
+ * number the hub would have to invent.
11857
+ *
11858
+ * ## `null` is the third answer, and it is not `normal`
11859
+ *
11860
+ * An unknown status says nothing — it neither opens nor closes an episode, for
11861
+ * the reason `battery-band.ts` gives: a reading nobody took is not evidence
11862
+ * that anything changed.
11863
+ *
11864
+ * Pure. No clock, no I/O.
11865
+ */
11866
+ /** The four tanks `vacuum-control` names, by their slice field. */
11867
+ var NC_TANK_KEYS = [
11868
+ "cleanWater",
11869
+ "dirtyWater",
11870
+ "detergent",
11871
+ "dustBin"
11872
+ ];
11873
+ /**
11874
+ * How long a tank change must HOLD before it counts (D642 review M2).
11875
+ *
11876
+ * A float switch sloshing at the edge of `low` / `full` flips its status back
11877
+ * and forth while the robot moves; each flip is a durable transition, and
11878
+ * without a hold each one is a raise and a recovery on the operator's phone.
11879
+ * One minute is two reload ticks: long enough to outlast a slosh, short enough
11880
+ * that a genuinely full dust bin is said within a minute and a half.
11881
+ */
11882
+ var NC_TANK_HOLD_MS = 6e4;
11883
+ function deriveTankBand(reading) {
11884
+ switch (reading.status) {
11885
+ case "low": return "low";
11886
+ case "full": return "full";
11887
+ case "ok": return "normal";
11888
+ case null: return null;
11889
+ }
11890
+ }
11891
+ function isRecord$4(value) {
11892
+ return typeof value === "object" && value !== null && !Array.isArray(value);
11893
+ }
11894
+ function tankStatusOf(value) {
11895
+ return value === "ok" || value === "low" || value === "full" ? value : null;
11896
+ }
11897
+ /**
11898
+ * A `device.state-changed` payload carrying a `vacuum-control` slice, reduced
11899
+ * to its tanks. `null` for any other cap. A tank the hardware does not have
11900
+ * (`null`) is skipped; a malformed field reads as unknown, never as a
11901
+ * fabricated verdict (D393: absent is not zero).
11902
+ */
11903
+ function vacuumTanksOf(data) {
11904
+ if (data["capName"] !== "vacuum-control") return null;
11905
+ const deviceId = data["deviceId"];
11906
+ const slice = data["slice"];
11907
+ if (typeof deviceId !== "number" || !isRecord$4(slice)) return null;
11908
+ const tanks = [];
11909
+ for (const key of NC_TANK_KEYS) {
11910
+ const raw = slice[key];
11911
+ if (!isRecord$4(raw)) continue;
11912
+ const level = raw["level"];
11913
+ tanks.push({
11914
+ key,
11915
+ level: typeof level === "number" && Number.isFinite(level) ? level : null,
11916
+ status: tankStatusOf(raw["status"])
11917
+ });
11918
+ }
11919
+ return tanks.length === 0 ? null : {
11920
+ deviceId,
11921
+ tanks
11922
+ };
11923
+ }
11924
+ //#endregion
11720
11925
  //#region src/notification-center/alerts/battery-band.ts
11721
11926
  /**
11722
11927
  * The stand-in a provider writes for a binary NORMAL. Anything below it, on a
@@ -11747,10 +11952,14 @@ function deriveBatteryBand(input) {
11747
11952
  * `NcDeviceAlerts` — the one place a SUSTAINED device fault becomes a
11748
11953
  * notification, and keeps becoming one until it stops being true.
11749
11954
  *
11750
- * It owns two families and nothing else:
11955
+ * It owns these families and nothing else:
11751
11956
  *
11752
11957
  * - **battery band** — `battery:<deviceId>`, fed by the `battery` runtime-state
11753
11958
  * slice that already arrives on `device.state-changed`;
11959
+ * - **consumable band** — `consumable@<threshold>:<deviceId>:<key>`, one
11960
+ * episode per item per threshold a RULE asks for (D642);
11961
+ * - **tank band** — `tank:<deviceId>:<tank>`, a vacuum's tanks, judged on the
11962
+ * provider's own verdict (D642);
11754
11963
  * - **the reminder tick** — the operator's "how often do you say it again".
11755
11964
  *
11756
11965
  * It does NOT own device liveness. `device-offline` already has a producer, a
@@ -11790,6 +11999,10 @@ var PENDING_CADENCE_MAX_MS = 36e5;
11790
11999
  function pendingCadenceMs(settings) {
11791
12000
  return settings.renotifyMinutes > 0 ? Math.min(settings.renotifyMinutes * 6e4, PENDING_CADENCE_MAX_MS) : PENDING_CADENCE_MAX_MS;
11792
12001
  }
12002
+ /** The RAM key of one item's last reading — every threshold's episode reads it. */
12003
+ function itemKeyOf(deviceId, key) {
12004
+ return `${String(deviceId)}:${key}`;
12005
+ }
11793
12006
  var NcDeviceAlerts = class {
11794
12007
  deps;
11795
12008
  now;
@@ -11801,8 +12014,28 @@ var NcDeviceAlerts = class {
11801
12014
  * durable.
11802
12015
  */
11803
12016
  lastReading = /* @__PURE__ */ new Map();
11804
- /** The last reading per consumable SUBJECT, for the reminder sweep. */
12017
+ /**
12018
+ * The last reading per consumable ITEM (`<deviceId>:<key>`), for the
12019
+ * reminder sweep and for judging a threshold a rule gains later (D642).
12020
+ */
11805
12021
  lastConsumable = /* @__PURE__ */ new Map();
12022
+ /** The last reading per tank SUBJECT, for the reminder sweep. */
12023
+ lastTank = /* @__PURE__ */ new Map();
12024
+ /**
12025
+ * Tank changes on HOLD (review M2): a band that would move the episode,
12026
+ * first seen at `since`. It counts once it has held for `NC_TANK_HOLD_MS`,
12027
+ * confirmed by a later slice write or by the reload tick — no timer of its
12028
+ * own, so nothing outlives the device. Bounded by the device's tanks (four),
12029
+ * and dropped with the device by {@link forget}.
12030
+ */
12031
+ tankHold = /* @__PURE__ */ new Map();
12032
+ /**
12033
+ * Thresholds some episode row carries that the LAST census did not ask for.
12034
+ * A threshold's rows are retired only when two consecutive censuses agree it
12035
+ * is gone (D49: a read that would destroy work needs a second read to
12036
+ * agree). RAM only: a restart costs one more tick of patience.
12037
+ */
12038
+ absentThresholds = /* @__PURE__ */ new Set();
11806
12039
  /**
11807
12040
  * Subject → WHEN its PENDING raise was last offered, and to which
11808
12041
  * {@link NcDeviceAlertsDeps.rulesVersion}. RAM only, on purpose: after a
@@ -11860,31 +12093,137 @@ var NcDeviceAlerts = class {
11860
12093
  * observed.
11861
12094
  */
11862
12095
  observeConsumable(deviceId, item) {
11863
- const subject = `consumable:${deviceId}:${item.key}`;
11864
- const previous = this.deps.rows.get(subject);
12096
+ this.lastConsumable.set(itemKeyOf(deviceId, item.key), {
12097
+ deviceId,
12098
+ item
12099
+ });
12100
+ const thresholds = this.deps.consumableThresholds();
12101
+ if (thresholds === null) {
12102
+ this.logger()?.debug("consumable reading kept — the rules have not been read yet", {
12103
+ tags: { deviceId },
12104
+ meta: { item: item.key }
12105
+ });
12106
+ return;
12107
+ }
11865
12108
  const settings = this.deps.settings();
12109
+ for (const thresholdPercent of thresholds) this.judgeConsumable(deviceId, item, thresholdPercent, settings);
12110
+ }
12111
+ /**
12112
+ * ONE vacuum tank, from a device's `vacuum-control` slice (D642).
12113
+ *
12114
+ * The provider's verdict is the band (`tank-band.ts`); an unknown status
12115
+ * leaves everything untouched, an open episode included.
12116
+ */
12117
+ observeTank(deviceId, tank) {
12118
+ const band = deriveTankBand(tank);
12119
+ if (band === null) return;
12120
+ const subject = `tank:${String(deviceId)}:${tank.key}`;
12121
+ this.lastTank.set(subject, {
12122
+ deviceId,
12123
+ tank
12124
+ });
12125
+ if (!this.tankChangeHeld(subject, deviceId, band)) return;
12126
+ this.apply(subject, band !== "normal", this.deps.settings(), (facts, alerting) => incomingFromTankBand({
12127
+ facts,
12128
+ deviceId,
12129
+ band: alerting ? band : "normal",
12130
+ tank: tank.key,
12131
+ identity: this.deps.lookup(deviceId)
12132
+ }));
12133
+ }
12134
+ /**
12135
+ * Has this tank band held long enough to move its episode? A band that
12136
+ * would not move it (a repeat, or low ⇄ full inside one open episode) passes
12137
+ * at once and clears any hold — that is what makes a flapping switch start
12138
+ * its hold over every time it flips back. A first HEALTHY observation passes
12139
+ * too: it seeds silently, so there is nothing to hold.
12140
+ */
12141
+ tankChangeHeld(subject, deviceId, band) {
12142
+ const row = this.deps.rows.get(subject);
12143
+ const alerting = band !== "normal";
12144
+ if (!(row === void 0 ? alerting : row.alerting !== alerting)) {
12145
+ this.tankHold.delete(subject);
12146
+ return true;
12147
+ }
12148
+ const nowMs = this.now();
12149
+ const held = this.tankHold.get(subject);
12150
+ if (held === void 0 || held.band !== band) {
12151
+ this.tankHold.set(subject, {
12152
+ band,
12153
+ since: nowMs
12154
+ });
12155
+ this.logger()?.debug("tank change on hold — counts once it has held", {
12156
+ tags: { deviceId },
12157
+ meta: {
12158
+ subject,
12159
+ band,
12160
+ holdMs: NC_TANK_HOLD_MS
12161
+ }
12162
+ });
12163
+ return false;
12164
+ }
12165
+ if (nowMs - held.since < 6e4) return false;
12166
+ this.tankHold.delete(subject);
12167
+ return true;
12168
+ }
12169
+ /** The tick's half of the hold: a change that has held with no new slice write. */
12170
+ confirmHeldTanks() {
12171
+ const nowMs = this.now();
12172
+ for (const [subject, held] of [...this.tankHold]) {
12173
+ if (nowMs - held.since < 6e4) continue;
12174
+ const reading = this.lastTank.get(subject);
12175
+ if (reading === void 0) {
12176
+ this.tankHold.delete(subject);
12177
+ continue;
12178
+ }
12179
+ this.observeTank(reading.deviceId, reading.tank);
12180
+ }
12181
+ }
12182
+ /** One item against ONE threshold: its own episode, its own hysteresis. */
12183
+ judgeConsumable(deviceId, item, thresholdPercent, settings) {
12184
+ const subject = consumableSubject(thresholdPercent, deviceId, item.key);
12185
+ const previous = this.deps.rows.get(subject) ?? this.adoptLegacy(subject, deviceId, item.key);
11866
12186
  const band = deriveConsumableBand({
11867
12187
  reading: {
11868
12188
  level: item.level,
11869
12189
  status: item.status
11870
12190
  },
11871
- thresholdPercent: settings.consumableLowPercent,
12191
+ thresholdPercent,
11872
12192
  previous: previous?.alerting === true ? "low" : previous === void 0 ? void 0 : "normal"
11873
12193
  });
11874
12194
  if (band === null) return;
11875
- this.lastConsumable.set(subject, {
11876
- deviceId,
11877
- item
11878
- });
11879
12195
  this.apply(subject, band === "low", settings, (facts, alerting) => incomingFromConsumableBand({
11880
12196
  facts,
11881
12197
  deviceId,
11882
12198
  band: alerting ? "low" : "normal",
11883
12199
  item,
12200
+ thresholdPercent,
11884
12201
  identity: this.deps.lookup(deviceId)
11885
12202
  }));
11886
12203
  }
11887
12204
  /**
12205
+ * A pre-D642 row of this item (`consumable:<id>:<key>`, judged at the
12206
+ * cluster threshold, which shipped as the rule default) is carried over to
12207
+ * the default-threshold episode rather than re-announced: the live vacuum
12208
+ * had two items low and reminding when D642 shipped. Any other threshold
12209
+ * starts fresh; an unadopted legacy row retires with the census.
12210
+ */
12211
+ adoptLegacy(subject, deviceId, key) {
12212
+ const parts = parseConsumableSubject(subject);
12213
+ const legacySubject = legacyConsumableSubject(deviceId, key);
12214
+ const legacy = this.deps.rows.get(legacySubject);
12215
+ if (legacy === void 0 || parts === null) return void 0;
12216
+ const legacyParts = parseConsumableSubject(legacySubject);
12217
+ if (legacyParts === null || legacyParts.thresholdPercent !== parts.thresholdPercent) return;
12218
+ const adopted = {
12219
+ ...legacy,
12220
+ subject
12221
+ };
12222
+ this.deps.rows.put(adopted);
12223
+ this.dropRow(legacySubject, "carried over to its threshold episode (D642)");
12224
+ return adopted;
12225
+ }
12226
+ /**
11888
12227
  * The reminder sweep. Called from an existing periodic tick; it allocates
11889
12228
  * nothing on the healthy path.
11890
12229
  *
@@ -11895,6 +12234,9 @@ var NcDeviceAlerts = class {
11895
12234
  */
11896
12235
  tick() {
11897
12236
  const settings = this.deps.settings();
12237
+ const thresholds = this.deps.consumableThresholds();
12238
+ if (thresholds !== null) this.reconcileThresholds(thresholds, settings);
12239
+ this.confirmHeldTanks();
11898
12240
  this.retryPending(settings);
11899
12241
  if (settings.renotifyMinutes <= 0) return;
11900
12242
  for (const row of this.deps.rows.all()) {
@@ -11927,14 +12269,58 @@ var NcDeviceAlerts = class {
11927
12269
  const subjects = this.deps.rows.all().map((row) => row.subject).filter((subject) => alertSubjectDeviceId(subject) === deviceId);
11928
12270
  for (const subject of subjects) {
11929
12271
  this.lastReading.delete(subject);
11930
- this.lastConsumable.delete(subject);
12272
+ this.lastTank.delete(subject);
11931
12273
  this.offeredAt.delete(subject);
11932
12274
  await this.deps.rows.remove(subject);
11933
12275
  }
11934
12276
  this.lastReading.delete(`battery:${deviceId}`);
12277
+ for (const [key, reading] of this.lastConsumable) if (reading.deviceId === deviceId) this.lastConsumable.delete(key);
12278
+ for (const subject of this.tankHold.keys()) if (alertSubjectDeviceId(subject) === deviceId) this.tankHold.delete(subject);
12279
+ for (const subject of this.lastTank.keys()) if (alertSubjectDeviceId(subject) === deviceId) this.lastTank.delete(subject);
11935
12280
  return subjects.length;
11936
12281
  }
11937
12282
  /**
12283
+ * Bring the consumable episodes in line with the thresholds the rules ask
12284
+ * for (D642). A threshold with no episode for an item yet is judged from the
12285
+ * item's last reading — a rule that gains a threshold hears about an item
12286
+ * already past it on the next tick, not at the next slice write. A
12287
+ * threshold that no rule asks for any more has its episodes RETIRED, but
12288
+ * only when two consecutive censuses agree it is gone; retiring is silent
12289
+ * (nothing recovered — nobody is listening at that number any more) and
12290
+ * logged per row.
12291
+ */
12292
+ reconcileThresholds(thresholds, settings) {
12293
+ for (const { deviceId, item } of this.lastConsumable.values()) for (const thresholdPercent of thresholds) {
12294
+ if (this.deps.rows.get(consumableSubject(thresholdPercent, deviceId, item.key))) continue;
12295
+ this.judgeConsumable(deviceId, item, thresholdPercent, settings);
12296
+ }
12297
+ const absent = /* @__PURE__ */ new Set();
12298
+ for (const row of this.deps.rows.all()) {
12299
+ const parts = parseConsumableSubject(row.subject);
12300
+ if (parts === null || thresholds.includes(parts.thresholdPercent)) continue;
12301
+ absent.add(parts.thresholdPercent);
12302
+ if (this.absentThresholds.has(parts.thresholdPercent)) this.dropRow(row.subject, "retired — no rule judges consumables at this threshold (D642)");
12303
+ }
12304
+ this.absentThresholds = absent;
12305
+ }
12306
+ /** Remove one row, mirror and durable, with a line that names why. */
12307
+ dropRow(subject, reason) {
12308
+ this.offeredAt.delete(subject);
12309
+ this.logger()?.info(`device alert ${reason}`, {
12310
+ ...tagsOf(subject),
12311
+ meta: { subject }
12312
+ });
12313
+ this.deps.rows.remove(subject).catch((error) => {
12314
+ this.logger()?.warn("device alert row could not be removed", {
12315
+ ...tagsOf(subject),
12316
+ meta: {
12317
+ subject,
12318
+ error: error instanceof Error ? error.message : String(error)
12319
+ }
12320
+ });
12321
+ });
12322
+ }
12323
+ /**
11938
12324
  * Offer every PENDING raise once more — when the rule set changed since its
11939
12325
  * last offer, OR when {@link pendingCadenceMs} has passed since it. The
11940
12326
  * cadence is what delivers a raise refused only by a rule's SCHEDULE (or any
@@ -11966,14 +12352,25 @@ var NcDeviceAlerts = class {
11966
12352
  }
11967
12353
  /** How to re-word a subject's alert from the last reading, or `null`. */
11968
12354
  restatementOf(subject) {
11969
- const consumable = this.lastConsumable.get(subject);
11970
- if (consumable !== void 0) return (facts) => incomingFromConsumableBand({
12355
+ const parts = parseConsumableSubject(subject);
12356
+ const consumable = parts === null || parts.legacy ? void 0 : this.lastConsumable.get(itemKeyOf(parts.deviceId, parts.key));
12357
+ if (parts !== null && consumable !== void 0) return (facts) => incomingFromConsumableBand({
11971
12358
  facts,
11972
12359
  deviceId: consumable.deviceId,
11973
12360
  band: "low",
11974
12361
  item: consumable.item,
12362
+ thresholdPercent: parts.thresholdPercent,
11975
12363
  identity: this.deps.lookup(consumable.deviceId)
11976
12364
  });
12365
+ const tank = this.lastTank.get(subject);
12366
+ const tankBand = tank === void 0 ? null : deriveTankBand(tank.tank);
12367
+ if (tank !== void 0 && (tankBand === "low" || tankBand === "full")) return (facts) => incomingFromTankBand({
12368
+ facts,
12369
+ deviceId: tank.deviceId,
12370
+ band: tankBand,
12371
+ tank: tank.tank.key,
12372
+ identity: this.deps.lookup(tank.deviceId)
12373
+ });
11977
12374
  const battery = this.lastReading.get(subject);
11978
12375
  if (battery !== void 0) return (facts) => incomingFromBatteryBand({
11979
12376
  facts,
@@ -12073,11 +12470,9 @@ var NcDeviceAlerts = class {
12073
12470
  report(subject, verdict, alerting) {
12074
12471
  const logger = this.deps.logger;
12075
12472
  if (logger === void 0) return;
12076
- const deviceId = alertSubjectDeviceId(subject);
12077
- const tags = deviceId !== void 0 ? { deviceId } : {};
12078
12473
  if (verdict.kind === "silent" && !alerting) {
12079
12474
  logger.debug("device alert cleared without a notification", {
12080
- tags,
12475
+ ...tagsOf(subject),
12081
12476
  meta: {
12082
12477
  subject,
12083
12478
  reason: "notifyRecovery is off, or nothing changed"
@@ -12086,7 +12481,7 @@ var NcDeviceAlerts = class {
12086
12481
  return;
12087
12482
  }
12088
12483
  logger.debug("device alert said nothing", {
12089
- tags,
12484
+ ...tagsOf(subject),
12090
12485
  meta: {
12091
12486
  subject,
12092
12487
  verdict: verdict.kind
@@ -12497,6 +12892,21 @@ var en_default = {
12497
12892
  "system.device-consumable-normal.title": "Consumable replaced",
12498
12893
  "system.device-consumable-normal.body": "{{item}} on {{deviceName}} is back to {{percentage}}%",
12499
12894
  "system.device-consumable-normal.body.unmeasured": "{{item}} on {{deviceName}} is OK again",
12895
+ "system.device-tank-low.title": "Tank low",
12896
+ "system.device-tank-full.title": "Tank full",
12897
+ "system.device-tank-normal.title": "Tank OK",
12898
+ "system.device-tank-low.body.cleanWater": "The clean water tank on {{deviceName}} is running low",
12899
+ "system.device-tank-full.body.cleanWater": "The clean water tank on {{deviceName}} is full",
12900
+ "system.device-tank-normal.body.cleanWater": "The clean water tank on {{deviceName}} is OK again",
12901
+ "system.device-tank-low.body.dirtyWater": "The dirty water tank on {{deviceName}} is running low",
12902
+ "system.device-tank-full.body.dirtyWater": "The dirty water tank on {{deviceName}} is full",
12903
+ "system.device-tank-normal.body.dirtyWater": "The dirty water tank on {{deviceName}} is OK again",
12904
+ "system.device-tank-low.body.detergent": "The detergent tank on {{deviceName}} is running low",
12905
+ "system.device-tank-full.body.detergent": "The detergent tank on {{deviceName}} is full",
12906
+ "system.device-tank-normal.body.detergent": "The detergent tank on {{deviceName}} is OK again",
12907
+ "system.device-tank-low.body.dustBin": "The dust bin on {{deviceName}} is running low",
12908
+ "system.device-tank-full.body.dustBin": "The dust bin on {{deviceName}} is full",
12909
+ "system.device-tank-normal.body.dustBin": "The dust bin on {{deviceName}} is OK again",
12500
12910
  "system.camera-online.title": "Device online",
12501
12911
  "system.camera-online.body": "Device {{deviceName}} is online",
12502
12912
  "system.camera-offline.title": "Device offline",
@@ -12786,6 +13196,21 @@ var it_default = {
12786
13196
  "system.device-consumable-normal.title": "Consumabile sostituito",
12787
13197
  "system.device-consumable-normal.body": "{{item}} su {{deviceName}} è tornato al {{percentage}}%",
12788
13198
  "system.device-consumable-normal.body.unmeasured": "{{item}} su {{deviceName}} è di nuovo a posto",
13199
+ "system.device-tank-low.title": "Serbatoio quasi vuoto",
13200
+ "system.device-tank-full.title": "Serbatoio pieno",
13201
+ "system.device-tank-normal.title": "Serbatoio a posto",
13202
+ "system.device-tank-low.body.cleanWater": "Il serbatoio dell'acqua pulita di {{deviceName}} è quasi vuoto",
13203
+ "system.device-tank-full.body.cleanWater": "Il serbatoio dell'acqua pulita di {{deviceName}} è pieno",
13204
+ "system.device-tank-normal.body.cleanWater": "Il serbatoio dell'acqua pulita di {{deviceName}} è di nuovo a posto",
13205
+ "system.device-tank-low.body.dirtyWater": "Il serbatoio dell'acqua sporca di {{deviceName}} è quasi vuoto",
13206
+ "system.device-tank-full.body.dirtyWater": "Il serbatoio dell'acqua sporca di {{deviceName}} è pieno",
13207
+ "system.device-tank-normal.body.dirtyWater": "Il serbatoio dell'acqua sporca di {{deviceName}} è di nuovo a posto",
13208
+ "system.device-tank-low.body.detergent": "Il serbatoio del detergente di {{deviceName}} è quasi vuoto",
13209
+ "system.device-tank-full.body.detergent": "Il serbatoio del detergente di {{deviceName}} è pieno",
13210
+ "system.device-tank-normal.body.detergent": "Il serbatoio del detergente di {{deviceName}} è di nuovo a posto",
13211
+ "system.device-tank-low.body.dustBin": "Il contenitore della polvere di {{deviceName}} è quasi vuoto",
13212
+ "system.device-tank-full.body.dustBin": "Il contenitore della polvere di {{deviceName}} è pieno",
13213
+ "system.device-tank-normal.body.dustBin": "Il contenitore della polvere di {{deviceName}} è di nuovo a posto",
12789
13214
  "system.camera-online.title": "Dispositivo online",
12790
13215
  "system.camera-online.body": "Il dispositivo {{deviceName}} è online",
12791
13216
  "system.camera-offline.title": "Dispositivo offline",
@@ -14169,6 +14594,10 @@ function systemEventKeys(kind) {
14169
14594
  titleKey: `system.${kind}.title`,
14170
14595
  bodyKey: `system.${kind}.body.ignored`
14171
14596
  };
14597
+ if (require_dist.NC_TANK_SYSTEM_EVENT_KINDS.includes(kind)) return {
14598
+ titleKey: `system.${kind}.title`,
14599
+ bodyKey: `system.${kind}.body.cleanWater`
14600
+ };
14172
14601
  return {
14173
14602
  titleKey: `system.${kind}.title`,
14174
14603
  bodyKey: `system.${kind}.body`
@@ -16184,7 +16613,7 @@ function makeNcActionHandlers(deps) {
16184
16613
  catalog: [...require_dist.NC_CONDITION_CATALOG],
16185
16614
  taxonomy: require_dist.NC_TAXONOMY
16186
16615
  }),
16187
- "nc.getTemplateCatalog": async () => ({ vars: [...require_dist.NC_TEMPLATE_VARS] }),
16616
+ "nc.getTemplateCatalog": async () => ({ vars: require_dist.servedTemplateVars() }),
16188
16617
  "nc.previewTemplate": async (input) => {
16189
16618
  const texts = deps.previewTexts;
16190
16619
  if (texts === void 0) throw new Error("nc.previewTemplate: text catalog not wired");
@@ -23764,7 +24193,7 @@ var NcRuleStore = class {
23764
24193
  */
23765
24194
  async load() {
23766
24195
  await this.ledger.load();
23767
- this.loaded = true;
24196
+ if (this.ledger.hasAnswered()) this.loaded = true;
23768
24197
  this.logger.debug("notification rules loaded", { meta: { rules: this.ledger.size } });
23769
24198
  }
23770
24199
  /**
@@ -28194,7 +28623,6 @@ var ARM_ANYWAY_REJECTION_COPY = {
28194
28623
  */
28195
28624
  var NC_DEVICE_ALERT_FALLBACK = {
28196
28625
  batteryLowPercent: 20,
28197
- consumableLowPercent: 15,
28198
28626
  renotifyMinutes: 720,
28199
28627
  notifyRecovery: true
28200
28628
  };
@@ -29449,6 +29877,7 @@ var NotificationCenter = class NotificationCenter {
29449
29877
  return (await this.consumeEventAndReport(event)).outcomes.some((outcome) => outcome.matched);
29450
29878
  },
29451
29879
  rulesVersion: () => this.rulesFingerprint(),
29880
+ consumableThresholds: () => this.rules.isLoaded() ? consumableThresholdCensus(this.rules.list()) : null,
29452
29881
  settings: () => this.deviceAlertSettings(),
29453
29882
  lookup: (deviceId) => this.deviceDirectory.get(deviceId),
29454
29883
  rows: this.alertStore.port(),
@@ -30557,6 +30986,11 @@ var NotificationCenter = class NotificationCenter {
30557
30986
  for (const item of consumables.items) this.deviceAlerts.observeConsumable(consumables.deviceId, item);
30558
30987
  return;
30559
30988
  }
30989
+ const tanks = event.category === "device.state-changed" ? vacuumTanksOf(event.data) : null;
30990
+ if (tanks !== null) {
30991
+ for (const tank of tanks.tanks) this.deviceAlerts.observeTank(tanks.deviceId, tank);
30992
+ return;
30993
+ }
30560
30994
  const lookup = this.deviceDirectory.lookup();
30561
30995
  const incoming = incomingFromSystemEvent(event, lookup);
30562
30996
  if (incoming === null) {
@@ -30675,7 +31109,7 @@ var NotificationCenter = class NotificationCenter {
30675
31109
  catalog: [...require_dist.NC_CONDITION_CATALOG],
30676
31110
  taxonomy: require_dist.NC_TAXONOMY
30677
31111
  }),
30678
- getTemplateCatalog: async () => ({ vars: [...require_dist.NC_TEMPLATE_VARS] }),
31112
+ getTemplateCatalog: async () => ({ vars: require_dist.servedTemplateVars() }),
30679
31113
  previewTemplate: async (input) => previewTemplate(this.texts, input),
30680
31114
  getHistory: async ({ filter }) => {
30681
31115
  const entries = await this.outbox.queryHistory({
@@ -32478,7 +32912,6 @@ var NotificationCenter = class NotificationCenter {
32478
32912
  deviceAlertSettings() {
32479
32913
  return this.deps.readDeviceAlertSettings?.() ?? {
32480
32914
  batteryLowPercent: NC_DEVICE_ALERT_FALLBACK.batteryLowPercent,
32481
- consumableLowPercent: NC_DEVICE_ALERT_FALLBACK.consumableLowPercent,
32482
32915
  renotifyMinutes: NC_DEVICE_ALERT_FALLBACK.renotifyMinutes,
32483
32916
  notifyRecovery: NC_DEVICE_ALERT_FALLBACK.notifyRecovery
32484
32917
  };
@@ -56100,19 +56533,6 @@ var DeviceAlertSettingsSchema = require_dist.object({
56100
56533
  */
56101
56534
  batteryLowPercent: require_dist.number().int().min(1).max(99).default(20),
56102
56535
  /**
56103
- * What counts as a nearly-exhausted CONSUMABLE — a filter, a brush, a mop pad.
56104
- *
56105
- * Its own number and not the battery's, because the two are different chores
56106
- * on different clocks: a battery at 20 % is hours from flat, a brush at 20 %
56107
- * is weeks from worn, and an operator who wants to be told early about one
56108
- * does not necessarily want to be told early about the other.
56109
- *
56110
- * Applies to the MEASURED side only. An item whose provider states a
56111
- * `replace` verdict is low whatever this says — the provider decided, and a
56112
- * threshold cannot talk it out of it (`alerts/consumable-band.ts`).
56113
- */
56114
- consumableLowPercent: require_dist.number().int().min(1).max(99).default(15),
56115
- /**
56116
56536
  * How long a still-true alert waits before it says itself again.
56117
56537
  *
56118
56538
  * **0 = say it once.** That is the behaviour of every other notification this
@@ -77816,7 +78236,7 @@ function buildGlobalSettingsSchema(cap) {
77816
78236
  {
77817
78237
  id: "device-alerts",
77818
78238
  title: "Battery & offline alerts",
77819
- description: "What counts as a low battery, how often a still-true alert is repeated, and whether the return to normal is announced. These govern the “Battery” and “Devices & streams” system-event rules; the rules themselves still choose which devices and which kinds. A device with a binary low-battery indicator (no real percentage) is judged by the indicator, and the threshold below does not apply to it.",
78239
+ description: "What counts as a low battery, how often a still-true alert is repeated, and whether the return to normal is announced. These govern the device-event rules; the rules themselves still choose which devices and which kinds — and each rule sets its own consumable threshold. A device with a binary low-battery indicator (no real percentage) is judged by the indicator, and the threshold below does not apply to it.",
77820
78240
  columns: 2,
77821
78241
  fields: [
77822
78242
  {
@@ -77830,17 +78250,6 @@ function buildGlobalSettingsSchema(cap) {
77830
78250
  default: DEVICE_ALERT_DEFAULTS.batteryLowPercent,
77831
78251
  unit: "%"
77832
78252
  },
77833
- {
77834
- type: "number",
77835
- key: "consumableLowPercent",
77836
- label: "Low consumable at",
77837
- 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.",
77838
- min: 1,
77839
- max: 99,
77840
- step: 1,
77841
- default: DEVICE_ALERT_DEFAULTS.consumableLowPercent,
77842
- unit: "%"
77843
- },
77844
78253
  {
77845
78254
  type: "number",
77846
78255
  key: "alertRenotifyMinutes",
@@ -84000,7 +84409,6 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
84000
84409
  },
84001
84410
  readDeviceAlertSettings: () => ({
84002
84411
  batteryLowPercent: this.deviceAlertSettingsMirror.batteryLowPercent,
84003
- consumableLowPercent: this.deviceAlertSettingsMirror.consumableLowPercent,
84004
84412
  renotifyMinutes: this.deviceAlertSettingsMirror.alertRenotifyMinutes,
84005
84413
  notifyRecovery: this.deviceAlertSettingsMirror.alertNotifyRecovery
84006
84414
  }),