@camstack/addon-post-analysis 1.2.607 → 1.2.609

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.
Files changed (15) hide show
  1. package/dist/{clip-model-registry-VgOXFRFr.js → clip-model-registry-B11yS32V.js} +472 -47
  2. package/dist/{clip-model-registry-CTfe5v5_.mjs → clip-model-registry-BItfTZEx.mjs} +431 -48
  3. package/dist/embedding-encoder/index.js +1 -1
  4. package/dist/embedding-encoder/index.mjs +1 -1
  5. package/dist/pipeline-analytics/_stub.js +2 -2
  6. package/dist/pipeline-analytics/{_virtual_mf-localSharedImportMap___mfe_internal__addon_pipeline_analytics_widgets-DUNQoBDI.mjs → _virtual_mf-localSharedImportMap___mfe_internal__addon_pipeline_analytics_widgets-LGLlz5Sk.mjs} +3 -3
  7. package/dist/pipeline-analytics/_virtual_mf___mfe_internal__addon_pipeline_analytics_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-iXmJsQ9Y.mjs +26 -0
  8. package/dist/pipeline-analytics/_virtual_mf___mfe_internal__addon_pipeline_analytics_widgets__loadShare___mf_0_camstack_mf_1_ui_mf_2_library__loadShare__.js-Cm7pZeMH.mjs +26 -0
  9. package/dist/pipeline-analytics/{hostInit-B8mC_3oV.mjs → hostInit-Cko0y33G.mjs} +3 -3
  10. package/dist/pipeline-analytics/index.js +583 -68
  11. package/dist/pipeline-analytics/index.mjs +583 -68
  12. package/dist/pipeline-analytics/remoteEntry.js +1 -1
  13. package/package.json +1 -1
  14. package/dist/pipeline-analytics/_virtual_mf___mfe_internal__addon_pipeline_analytics_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-1AlitJg2.mjs +0 -26
  15. package/dist/pipeline-analytics/_virtual_mf___mfe_internal__addon_pipeline_analytics_widgets__loadShare___mf_0_camstack_mf_1_ui_mf_2_library__loadShare__.js-DalpVM1q.mjs +0 -26
@@ -2,7 +2,7 @@ Object.defineProperties(exports, {
2
2
  __esModule: { value: true },
3
3
  [Symbol.toStringTag]: { value: "Module" }
4
4
  });
5
- const require_clip_model_registry = require("../clip-model-registry-VgOXFRFr.js");
5
+ const require_clip_model_registry = require("../clip-model-registry-B11yS32V.js");
6
6
  let node_fs = require("node:fs");
7
7
  node_fs = require_clip_model_registry.__toESM(node_fs, 1);
8
8
  let node_path = require("node:path");
@@ -10281,6 +10281,7 @@ var NC_SYSTEM_STATE_LEGACY = "notification-center:system-state";
10281
10281
  var NC_OCCUPANCY_LEGACY = "notification-center:occupancy";
10282
10282
  var NC_DEVICE_PRUNES_LEGACY = "notification-center:device-prunes";
10283
10283
  var NC_STATE_SUMMARY_STAMPS_LEGACY = "notification-center:state-summary-stamps";
10284
+ var NC_ALERT_EPISODES_LEGACY = "notification-center:alert-episodes";
10284
10285
  var NC_META_LEGACY = "notification-center:meta";
10285
10286
  var NC_SNOOZES_LEGACY = "notification-center:snoozes";
10286
10287
  var NC_SNOOZE_SUPPRESSED_LEGACY = "notification-center:snooze-suppressed";
@@ -10301,6 +10302,7 @@ var ncDurableRecordKinds = require_clip_model_registry.defineRecordKinds({
10301
10302
  liveness: ledger(NC_LIVENESS_LEGACY, "a liveness state TRANSITION the centre accepted (seed or flip); a no-flip writes nothing"),
10302
10303
  "device-alerts": ledger(NC_DEVICE_ALERTS_LEGACY, "a sustained device alert episode the centre confirmed or cleared"),
10303
10304
  "system-state": ledger(NC_SYSTEM_STATE_LEGACY, "a system-state subject flip the state summary accepted"),
10305
+ "alert-episodes": ledger(NC_ALERT_EPISODES_LEGACY, "an alert episode the centre notified (critical or error); its resolution deletes the row"),
10304
10306
  occupancy: ledger(NC_OCCUPANCY_LEGACY, "a confirmed occupancy level write-through after the watcher decided an edge"),
10305
10307
  "device-prunes": ledger(NC_DEVICE_PRUNES_LEGACY, "one row recording what a deleted-device prune removed"),
10306
10308
  "state-summary-stamps": ledger(NC_STATE_SUMMARY_STAMPS_LEGACY, "digest delivery stamps per rule kind"),
@@ -10320,6 +10322,7 @@ var NC_PHASE3_RECORD_BACKED_LEGACY_COLLECTIONS = [
10320
10322
  NC_LIVENESS_LEGACY,
10321
10323
  NC_DEVICE_ALERTS_LEGACY,
10322
10324
  NC_SYSTEM_STATE_LEGACY,
10325
+ NC_ALERT_EPISODES_LEGACY,
10323
10326
  NC_OCCUPANCY_LEGACY,
10324
10327
  NC_DEVICE_PRUNES_LEGACY,
10325
10328
  NC_STATE_SUMMARY_STAMPS_LEGACY,
@@ -10741,6 +10744,120 @@ var NcAlertStore = class {
10741
10744
  }
10742
10745
  };
10743
10746
  //#endregion
10747
+ //#region src/notification-center/alert-episode-ledger.ts
10748
+ /**
10749
+ * @durable class=ledger owner=notification-center
10750
+ * write="an alert episode the centre notified (alert-critical / alert-error); its resolution deletes the row"
10751
+ * retention="a row lives while its episode is open. A row whose alert was pruned by the alert centre's own retention is dropped after NC_ALERT_EPISODE_MAX_AGE_MS by the reconcile, never on absence alone (D49)."
10752
+ */
10753
+ var NC_ALERT_EPISODES_COLLECTION = "notification-center:alert-episodes";
10754
+ var NC_ALERT_EPISODES_COLUMNS = [
10755
+ {
10756
+ name: "episodeId",
10757
+ type: "TEXT",
10758
+ primaryKey: true,
10759
+ notNull: true
10760
+ },
10761
+ (
10762
+ /** The kind that was notified: `alert-critical` or `alert-error`. */
10763
+ {
10764
+ name: "kind",
10765
+ type: "TEXT",
10766
+ notNull: true
10767
+ }),
10768
+ {
10769
+ name: "alertId",
10770
+ type: "TEXT",
10771
+ notNull: true
10772
+ },
10773
+ {
10774
+ name: "since",
10775
+ type: "INTEGER",
10776
+ notNull: true
10777
+ }
10778
+ ];
10779
+ /** Longer than the alert centre's maximum retention (365 days). */
10780
+ var NC_ALERT_EPISODE_MAX_AGE_MS = 400 * 24 * 60 * 60 * 1e3;
10781
+ function isEpisodeKind(value) {
10782
+ return value === "alert-critical" || value === "alert-error";
10783
+ }
10784
+ var NC_ALERT_EPISODES_SPEC = {
10785
+ collection: NC_ALERT_EPISODES_COLLECTION,
10786
+ columns: NC_ALERT_EPISODES_COLUMNS,
10787
+ writeMode: "write-behind",
10788
+ keyOf: (row) => row.episodeId,
10789
+ toValue: (row) => ({
10790
+ kind: row.kind,
10791
+ alertId: row.alertId,
10792
+ since: row.since
10793
+ }),
10794
+ fromRecord: (key, data) => {
10795
+ const kind = data["kind"];
10796
+ const alertId = data["alertId"];
10797
+ const since = data["since"];
10798
+ if (!isEpisodeKind(kind) || typeof alertId !== "string" || typeof since !== "number") return null;
10799
+ return {
10800
+ episodeId: key,
10801
+ kind,
10802
+ alertId,
10803
+ since
10804
+ };
10805
+ }
10806
+ };
10807
+ NC_ALERT_EPISODES_SPEC.fromRecord, NC_ALERT_EPISODES_SPEC.toValue;
10808
+ var NcAlertEpisodeLedger = class {
10809
+ ledger;
10810
+ constructor(deps) {
10811
+ this.ledger = new DurableLedger({
10812
+ spec: NC_ALERT_EPISODES_SPEC,
10813
+ kind: resolveDurableKind(NC_ALERT_EPISODES_SPEC, ncDurableRecordKinds["alert-episodes"], deps),
10814
+ logger: deps.logger
10815
+ });
10816
+ }
10817
+ static declare(store) {
10818
+ return declareDurableSpecLegacy(store, NC_ALERT_EPISODES_SPEC);
10819
+ }
10820
+ async load() {
10821
+ return (await this.ledger.load()).length;
10822
+ }
10823
+ /** Has a load READ the store once? Until then the mirror is not the truth. */
10824
+ hasAnswered() {
10825
+ return this.ledger.hasAnswered();
10826
+ }
10827
+ has(episodeId) {
10828
+ return this.ledger.has(episodeId);
10829
+ }
10830
+ snapshot() {
10831
+ return this.ledger.snapshot();
10832
+ }
10833
+ /**
10834
+ * Judge one announced edge and advance the ledger. Synchronous: the verdict
10835
+ * is a function of the mirror (D49); the durable write trails it.
10836
+ */
10837
+ admit(edge, now) {
10838
+ const held = this.ledger.get(edge.episodeId);
10839
+ if (edge.phase === "raised") {
10840
+ if (edge.kind !== "alert-critical" && edge.kind !== "alert-error") return "duplicate";
10841
+ if (held !== void 0 && (held.kind === "alert-critical" || edge.kind === "alert-error")) return "duplicate";
10842
+ this.ledger.put({
10843
+ episodeId: edge.episodeId,
10844
+ kind: edge.kind,
10845
+ alertId: edge.alertId,
10846
+ since: held?.since ?? now
10847
+ });
10848
+ return "notify";
10849
+ }
10850
+ if (held === void 0) return "unknown-episode";
10851
+ this.ledger.forget(edge.episodeId);
10852
+ return held.kind === "alert-critical" && edge.kind === "alert-critical-resolved" ? "notify" : "closed-silently";
10853
+ }
10854
+ /** Drop rows older than the alert centre could still hold the alert. */
10855
+ async pruneOlderThan(cutoffMs) {
10856
+ const keep = new Set(this.ledger.snapshot().filter((row) => row.since >= cutoffMs).map((row) => row.episodeId));
10857
+ return this.ledger.pruneExcept(keep);
10858
+ }
10859
+ };
10860
+ //#endregion
10744
10861
  //#region src/notification-center/device-prune-ledger.ts
10745
10862
  /**
10746
10863
  * @durable class=ledger owner=notification-center
@@ -16026,6 +16143,34 @@ function bestMatchingKindPreference(signal, ownerKind) {
16026
16143
  }
16027
16144
  //#endregion
16028
16145
  //#region src/notification-center/event-intake.ts
16146
+ /**
16147
+ * The notification path's INTAKE SEAM — one event in, no knowledge of who
16148
+ * produced it.
16149
+ *
16150
+ * Why this file exists at all: until now every producer reached the centre
16151
+ * through its own record-shaped door (`onObjectEventPersisted(ObjectEvent)`,
16152
+ * `onSensorEventPersisted(SensorEvent)`, …). The doors did nothing but build a
16153
+ * subject and schedule an evaluation, yet their SIGNATURES made the notification
16154
+ * path look like a function of the pipeline's row types — so "notify about this"
16155
+ * was not expressible without first having a persisted row to hand.
16156
+ *
16157
+ * The split this file draws is between PRODUCING an event and CONSUMING one:
16158
+ *
16159
+ * - The producer owns persistence. The tracker persists its tracks, the sensor
16160
+ * ingest persists its rows, and a synthetic producer (the rule tester)
16161
+ * persists NOTHING. Whether a row exists is the producer's business and
16162
+ * never the consumer's.
16163
+ * - The consumer owns match → media → delivery. It receives an
16164
+ * {@link NcIncomingEvent} and cannot tell which producer built it, because
16165
+ * the only thing the matcher can read is `subject`, and `subject` carries no
16166
+ * provenance beyond the detection `source` a real record would carry anyway.
16167
+ *
16168
+ * {@link NcIncomingEvent.origin} exists for LOGS, and for the outbox row's
16169
+ * marker. It is deliberately NOT part of `NcRuleSubject`: a condition cannot
16170
+ * reach it, so no rule can be authored that behaves differently for a test. That
16171
+ * is the property the tester is worth nothing without — a test that takes a
16172
+ * different branch is a test of the branch, not of the rule.
16173
+ */
16029
16174
  /** Object-event insert (movement-state transitions + zone crossings). */
16030
16175
  function incomingFromObjectEvent(event) {
16031
16176
  return {
@@ -16869,9 +17014,45 @@ function incomingFromSystemEvent(event, lookup) {
16869
17014
  case "system.restarting": return incomingFromNodeRestarting(facts, data);
16870
17015
  case "recording.export.completed": return incomingFromExportCompleted(facts, data);
16871
17016
  case "backup.run-completed": return incomingFromBackupCompleted(facts, data);
17017
+ case "alert.episode": return incomingFromAlertEpisode(facts);
16872
17018
  default: return null;
16873
17019
  }
16874
17020
  }
17021
+ /**
17022
+ * An alert episode edge (`alert-critical`, `alert-error`, `alert-critical-resolved`).
17023
+ *
17024
+ * The WORDS are the alert's own: the alert centre composed the title and the
17025
+ * message when it opened the alert, and a rule's template may reword them with
17026
+ * `{{title}}`, `{{message}}`, `{{severity}}`, `{{category}}`, `{{source}}`,
17027
+ * `{{node}}` and `{{actionUrl}}`. The subject is the EPISODE, so the dedup slot
17028
+ * of a new episode is never the one a previous, resolved, episode held.
17029
+ *
17030
+ * Whether an edge is notified at all (once per episode, reconciled from the
17031
+ * alerts store) is decided before this point, by `NcAlertEpisodeLedger`.
17032
+ */
17033
+ function incomingFromAlertEpisode(facts) {
17034
+ const payload = require_clip_model_registry.parseAlertEpisodePayload(facts.data);
17035
+ if (payload === null) return null;
17036
+ const kind = require_clip_model_registry.alertEpisodeKind(payload.severity, payload.phase);
17037
+ if (kind === null) return null;
17038
+ return systemSubject({
17039
+ facts,
17040
+ kind,
17041
+ subject: `alert:${payload.episodeId}`,
17042
+ titleKey: `system.${kind}.title`,
17043
+ bodyKey: `system.${kind}.body`,
17044
+ textParams: {
17045
+ title: payload.title,
17046
+ message: payload.message,
17047
+ severity: payload.severity,
17048
+ category: payload.category,
17049
+ source: payload.sourceId ?? "",
17050
+ nodeId: payload.nodeId ?? "",
17051
+ actionUrl: payload.actionUrl ?? ""
17052
+ },
17053
+ ...payload.nodeId !== void 0 ? { nodeId: payload.nodeId } : {}
17054
+ });
17055
+ }
16875
17056
  /** The shells whose update has its own sentence. Anything else gets the plain
16876
17057
  * one: a body that names a shell nobody observed is worse than a vague body. */
16877
17058
  var WRAPPER_BODY_KINDS = new Set([
@@ -19354,6 +19535,7 @@ async function declareNcPhase3SettingsStoreLegacyCollections(store) {
19354
19535
  async function declareNcPhase3RecordBackedLegacyCollections(store) {
19355
19536
  await OccupancyStore.declare(store);
19356
19537
  await NcLivenessLedger.declare(store);
19538
+ await NcAlertEpisodeLedger.declare(store);
19357
19539
  await NcAlertStore.declare(store);
19358
19540
  await NcStateSummaryService.declare(store);
19359
19541
  await NcSnoozeStore.declare(store);
@@ -22165,6 +22347,41 @@ function audioDbfsByBucket(events, since, bucketMs) {
22165
22347
  }
22166
22348
  return out;
22167
22349
  }
22350
+ /**
22351
+ * PEAK dBFS per bucket index from device-history points (D431 follow-up).
22352
+ *
22353
+ * The bar answers "was it loud here", so a bucket reports the loudest window
22354
+ * in it. A raw point has `max === value`; a rollup row's `max` is the loudest
22355
+ * point it covers, which a mean would flatten. A bucket with no point is
22356
+ * absent — never zero. A rollup row also credits a held value across a quiet
22357
+ * gap, so its max can echo an earlier measured peak.
22358
+ */
22359
+ function audioPeakByBucket(points, since, bucketMs) {
22360
+ const out = /* @__PURE__ */ new Map();
22361
+ if (bucketMs <= 0) return out;
22362
+ for (const p of points) {
22363
+ if (!Number.isFinite(p.max)) continue;
22364
+ const idx = Math.floor((p.t - since) / bucketMs);
22365
+ if (idx < 0) continue;
22366
+ const prev = out.get(idx);
22367
+ if (prev === void 0 || p.max > prev) out.set(idx, p.max);
22368
+ }
22369
+ return out;
22370
+ }
22371
+ //#endregion
22372
+ //#region src/pipeline-analytics/store/analytics-history-keys.ts
22373
+ /** Declared addon series keys (D850). */
22374
+ var ANALYTICS_HISTORY_BUCKET_SERIES = {
22375
+ motion: "addon:pipeline-analytics/motion",
22376
+ "audio-dbfs": "addon:pipeline-analytics/audio-dbfs",
22377
+ occupancy: "addon:pipeline-analytics/occupancy"
22378
+ };
22379
+ var ANALYTICS_AUDIO_LEVEL_KEY = "addon:pipeline-analytics/audio-level";
22380
+ /** Map the public cap enum to the device-history key (read path). */
22381
+ function historyKeyForLtsSeries(series) {
22382
+ if (series === "battery") return "cap:battery.percentage";
22383
+ return ANALYTICS_HISTORY_BUCKET_SERIES[series];
22384
+ }
22168
22385
  //#endregion
22169
22386
  //#region src/pipeline-analytics/store/event-store.ts
22170
22387
  /**
@@ -22537,6 +22754,24 @@ function eventRowId(logger, operation, deviceId, key) {
22537
22754
  });
22538
22755
  return null;
22539
22756
  }
22757
+ var RAW_RETENTION_MS = 7 * 864e5;
22758
+ var FIVE_MIN_RETENTION_MS = 14 * 864e5;
22759
+ var FIVE_MIN_MS = 3e5;
22760
+ var HOUR_MS$2 = 36e5;
22761
+ /** The producer writes one raw point per window of this length. */
22762
+ var AUDIO_LEVEL_POINT_MS = 1e4;
22763
+ var HISTORY_MAX_POINTS = 5e3;
22764
+ /**
22765
+ * Pick the device-history tier for a density window: raw while the buckets are
22766
+ * finer than the 5m rollup, the window is inside raw retention and its points
22767
+ * fit one query; the 5m rollup while inside its retention; else 1h.
22768
+ */
22769
+ function audioLevelResolution(q, now) {
22770
+ const rawFits = Math.ceil((q.until - q.since) / AUDIO_LEVEL_POINT_MS) <= HISTORY_MAX_POINTS;
22771
+ if (q.bucketMs < FIVE_MIN_MS && rawFits && q.since >= now - RAW_RETENTION_MS) return "raw";
22772
+ if (q.bucketMs < HOUR_MS$2 && q.since >= now - FIVE_MIN_RETENTION_MS) return "5m";
22773
+ return "1h";
22774
+ }
22540
22775
  var EventStore = class EventStore {
22541
22776
  store;
22542
22777
  logger;
@@ -22544,6 +22779,10 @@ var EventStore = class EventStore {
22544
22779
  audioLedger;
22545
22780
  media;
22546
22781
  onMotionSample;
22782
+ audioLevelHistory;
22783
+ nowFn;
22784
+ /** Cameras whose history read already failed once (log once per camera, D315). */
22785
+ levelFallbackLogged = /* @__PURE__ */ new Set();
22547
22786
  constructor(deps) {
22548
22787
  this.store = deps.store;
22549
22788
  this.logger = deps.logger;
@@ -22552,6 +22791,8 @@ var EventStore = class EventStore {
22552
22791
  this.audioLedger = deps.audioLedger ?? wireAudioEventLedger(wiring);
22553
22792
  this.media = deps.media;
22554
22793
  this.onMotionSample = deps.onMotionSample;
22794
+ this.audioLevelHistory = deps.audioLevelHistory;
22795
+ this.nowFn = deps.now ?? (() => Date.now());
22555
22796
  }
22556
22797
  static async declareTracklessEventCollections(store) {
22557
22798
  await store.declareCollection.mutate({
@@ -23280,8 +23521,8 @@ var EventStore = class EventStore {
23280
23521
  const m = motionMap.get(i) ?? 0;
23281
23522
  const o = objectMap.get(i) ?? 0;
23282
23523
  const a = audioMap.get(i) ?? 0;
23283
- if (m + o + a === 0) continue;
23284
23524
  const dbfs = audioDbfsMap.get(i);
23525
+ if (m + o + a === 0 && dbfs === void 0) continue;
23285
23526
  out.push({
23286
23527
  bucketStart: q.since + i * q.bucketMs,
23287
23528
  motion: m,
@@ -23302,6 +23543,28 @@ var EventStore = class EventStore {
23302
23543
  * missing bar is the honest rendering of "not measured" (D431).
23303
23544
  */
23304
23545
  async audioDbfsPerBucket(q) {
23546
+ const history = this.audioLevelHistory;
23547
+ if (history !== void 0) try {
23548
+ const resolution = audioLevelResolution(q, this.nowFn());
23549
+ const points = (await history({
23550
+ deviceId: q.deviceId,
23551
+ key: ANALYTICS_AUDIO_LEVEL_KEY,
23552
+ scope: "",
23553
+ from: q.since,
23554
+ to: q.until,
23555
+ resolution,
23556
+ maxPoints: HISTORY_MAX_POINTS
23557
+ })).series.flatMap((s) => s.points);
23558
+ if (points.length > 0) return audioPeakByBucket(points, q.since, q.bucketMs);
23559
+ } catch (err) {
23560
+ if (!this.levelFallbackLogged.has(q.deviceId)) {
23561
+ this.levelFallbackLogged.add(q.deviceId);
23562
+ this.logger.warn("density: audio level history unreadable — falling back to audio events", {
23563
+ tags: { deviceId: q.deviceId },
23564
+ meta: { error: String(err) }
23565
+ });
23566
+ }
23567
+ }
23305
23568
  try {
23306
23569
  return audioDbfsByBucket((await this.queryAudio({
23307
23570
  deviceId: q.deviceId,
@@ -32881,6 +33144,12 @@ var en_default = {
32881
33144
  "system.backup-completed.title": "Backup completed",
32882
33145
  "system.backup-completed.body": "Backup saved to {{destinationsOk}} of {{destinationsTotal}} destinations ({{sizeMB}} MB)",
32883
33146
  "system.backup-completed.body.partial": "Backup saved to {{destinationsOk}} of {{destinationsTotal}} destinations — {{destinationsFailed}} failed",
33147
+ "system.alert-critical.title": "{{title}}",
33148
+ "system.alert-critical.body": "{{message}}",
33149
+ "system.alert-error.title": "{{title}}",
33150
+ "system.alert-error.body": "{{message}}",
33151
+ "system.alert-critical-resolved.title": "Resolved: {{title}}",
33152
+ "system.alert-critical-resolved.body": "{{message}}",
32884
33153
  "system.device-consumable-low.title": "Consumable low — {{deviceName}}",
32885
33154
  "system.device-consumable-low.body": "{{item}} on {{deviceName}} is at {{percentage}}%",
32886
33155
  "system.device-consumable-low.body.unmeasured": "{{item}} on {{deviceName}} needs replacing",
@@ -33261,6 +33530,12 @@ var it_default = {
33261
33530
  "system.backup-completed.title": "Backup completato",
33262
33531
  "system.backup-completed.body": "Backup salvato su {{destinationsOk}} destinazioni su {{destinationsTotal}} ({{sizeMB}} MB)",
33263
33532
  "system.backup-completed.body.partial": "Backup salvato su {{destinationsOk}} destinazioni su {{destinationsTotal}} — {{destinationsFailed}} fallite",
33533
+ "system.alert-critical.title": "{{title}}",
33534
+ "system.alert-critical.body": "{{message}}",
33535
+ "system.alert-error.title": "{{title}}",
33536
+ "system.alert-error.body": "{{message}}",
33537
+ "system.alert-critical-resolved.title": "Risolto: {{title}}",
33538
+ "system.alert-critical-resolved.body": "{{message}}",
33264
33539
  "system.device-consumable-low.title": "Consumabile basso — {{deviceName}}",
33265
33540
  "system.device-consumable-low.body": "{{item}} su {{deviceName}} è al {{percentage}}%",
33266
33541
  "system.device-consumable-low.body.unmeasured": "{{item}} su {{deviceName}} va sostituito",
@@ -35236,6 +35511,7 @@ function systemEventSubject(kind, scope) {
35236
35511
  if (kind === "node-updating") return `node-updating:${scope.nodeId ?? "hub"}:1.0.1`;
35237
35512
  if (kind === "export-completed") return `export:${scope.deviceId ?? 0}`;
35238
35513
  if (kind === "backup-completed") return "backup:test";
35514
+ if (kind.startsWith("alert-")) return "alert:test";
35239
35515
  return `alarm:${scope.deviceId ?? 0}`;
35240
35516
  }
35241
35517
  /**
@@ -35376,7 +35652,12 @@ function planRuleTest(input) {
35376
35652
  nodes: "hub",
35377
35653
  fromVersion: "1.0.0",
35378
35654
  toVersion: "1.0.1",
35379
- title: "Export",
35655
+ title: systemEventKind.startsWith("alert-") ? "Recording location full" : "Export",
35656
+ message: "No free space left on the recording location",
35657
+ severity: systemEventKind === "alert-error" ? "error" : "critical",
35658
+ category: "recorder.placement",
35659
+ source: "recorder",
35660
+ actionUrl: "/system/addons",
35380
35661
  count: "1"
35381
35662
  },
35382
35663
  ...systemScope.deviceId !== void 0 ? { deviceId: systemScope.deviceId } : {},
@@ -51962,6 +52243,10 @@ function groupedAlarmPhaseKey(ruleId, trackId) {
51962
52243
  var NO_MATCH_REPORT_INTERVAL_MS = 6e4;
51963
52244
  var DEFAULT_DRAIN_INTERVAL_MS = 2e3;
51964
52245
  var DEFAULT_RULE_RELOAD_INTERVAL_MS = 3e4;
52246
+ /** How often open critical/error alerts are reconciled against the notified episodes. */
52247
+ var NC_ALERT_RECONCILE_INTERVAL_MS = 5 * 6e4;
52248
+ /** The most alerts one reconcile reads (the alert centre's own ceiling). */
52249
+ var NC_ALERT_RECONCILE_LIMIT = 5e3;
51965
52250
  var DEFAULT_RECONCILE_WINDOW_MS = 15 * 6e4;
51966
52251
  /** Overlap subtracted from the watermark on boot (idempotent, cheap). */
51967
52252
  var RECONCILE_OVERLAP_MS = 6e4;
@@ -52534,6 +52819,15 @@ var NotificationCenter = class {
52534
52819
  * seeds, only a real flip notifies.
52535
52820
  */
52536
52821
  liveness;
52822
+ /**
52823
+ * Which alert episodes were already notified (`alert-episode-ledger.ts`): the
52824
+ * alert centre sends nothing of its own, so this is what makes its bus
52825
+ * announcements and the reconcile from the alerts store safe to run together.
52826
+ */
52827
+ alertEpisodes;
52828
+ /** The boot load of {@link alertEpisodes} has been ATTEMPTED (bus edges before it are the reconcile's). */
52829
+ alertEpisodesLoaded = false;
52830
+ alertReconcileTimer = null;
52537
52831
  /** Batches `addon.updated` (version-bearing) into one notification per 30 s. */
52538
52832
  addonUpdates;
52539
52833
  /** Trailing 5 min hold-off for camera liveness flaps (plugin restart). */
@@ -53235,6 +53529,10 @@ var NotificationCenter = class {
53235
53529
  ...durableStore,
53236
53530
  logger: this.logger.child("liveness")
53237
53531
  });
53532
+ this.alertEpisodes = new NcAlertEpisodeLedger({
53533
+ ...durableStore,
53534
+ logger: this.logger.child("alert-episodes")
53535
+ });
53238
53536
  this.addonUpdates = new AddonUpdateCoalescer((updates) => {
53239
53537
  const incoming = incomingFromAddonUpdates({
53240
53538
  eventId: `addon-updated:${updates.map((u) => u.packageName).join(",")}`,
@@ -53631,6 +53929,10 @@ var NotificationCenter = class {
53631
53929
  this.reloadTimer = setInterval(() => {
53632
53930
  this.reloadRules();
53633
53931
  }, this.deps.ruleReloadIntervalMs ?? DEFAULT_RULE_RELOAD_INTERVAL_MS);
53932
+ this.alertReconcileTimer = setInterval(() => {
53933
+ this.reconcileAlertEpisodes();
53934
+ }, this.deps.alertReconcileIntervalMs ?? NC_ALERT_RECONCILE_INTERVAL_MS);
53935
+ this.alertReconcileTimer.unref?.();
53634
53936
  const timelapseScheduler = this.timelapseScheduler;
53635
53937
  if (timelapseScheduler !== null) await timed("timelapseProducer", () => timelapseScheduler.start()).catch((err) => {
53636
53938
  this.logger.warn("timelapse producer did not start", { meta: { error: String(err) } });
@@ -53746,6 +54048,7 @@ var NotificationCenter = class {
53746
54048
  await this.deviceDirectory.refresh();
53747
54049
  })) return;
53748
54050
  if (!await phase("liveness", () => this.hydrateLiveness())) return;
54051
+ if (!await phase("alertEpisodes", () => this.hydrateAlertEpisodes())) return;
53749
54052
  if (!await phase("deviceAlerts", async () => {
53750
54053
  const rows = await this.alertStore.load();
53751
54054
  this.logger.info("device alert ledger reseeded", { meta: { rows } });
@@ -53788,6 +54091,10 @@ var NotificationCenter = class {
53788
54091
  clearInterval(this.reloadTimer);
53789
54092
  this.reloadTimer = null;
53790
54093
  }
54094
+ if (this.alertReconcileTimer !== null) {
54095
+ clearInterval(this.alertReconcileTimer);
54096
+ this.alertReconcileTimer = null;
54097
+ }
53791
54098
  this.timelapseScheduler?.stop();
53792
54099
  this.summaryProducer?.stop();
53793
54100
  await this.closeGroupedWindowsOnStop();
@@ -54799,6 +55106,10 @@ var NotificationCenter = class {
54799
55106
  return;
54800
55107
  }
54801
55108
  this.stateSummary.observeRawEvent(event.category, event.data, this.now(), event.source.nodeId);
55109
+ if (event.category === "alert.episode") {
55110
+ if (this.alertEpisodesLoaded) this.admitAlertEpisode(event, "bus");
55111
+ return;
55112
+ }
54802
55113
  if (event.category === "addon.started" || event.category === "addon.restarted") return;
54803
55114
  if (event.category === "pipeline.camera-assigned") return;
54804
55115
  const battery = batterySliceOf(event);
@@ -54832,10 +55143,6 @@ var NotificationCenter = class {
54832
55143
  const system = incoming.subject.systemEvent;
54833
55144
  if (system !== void 0) this.stateSummary.observeSystem(system, this.now());
54834
55145
  if (system !== void 0) this.updateLandings.observe(system, this.now());
54835
- if (system !== void 0 && system.kind === "addon-crash-loop") {
54836
- this.logger.debug("addon-crash-loop is pushed by the hub alert centre, not by a rule", { meta: { subject: system.subject } });
54837
- return;
54838
- }
54839
55146
  const observation = system !== void 0 ? ledgeredLiveness(system) : null;
54840
55147
  if (observation !== null) {
54841
55148
  const previous = this.liveness.stateOf(observation.subject);
@@ -57487,6 +57794,89 @@ var NotificationCenter = class {
57487
57794
  await this.occupancyStore.pruneExcept(this.activeOccupancyKeys());
57488
57795
  }
57489
57796
  /**
57797
+ * One edge of an alert episode, from the bus or from the reconcile. The
57798
+ * ledger decides whether it is news; only news reaches the rules.
57799
+ */
57800
+ admitAlertEpisode(event, via) {
57801
+ const payload = require_clip_model_registry.parseAlertEpisodePayload(event.data);
57802
+ if (payload === null) {
57803
+ this.logger.warn("alert episode event with an unreadable payload ignored", { meta: {
57804
+ via,
57805
+ eventId: event.id
57806
+ } });
57807
+ return false;
57808
+ }
57809
+ const verdict = this.alertEpisodes.admit({
57810
+ kind: require_clip_model_registry.alertEpisodeKind(payload.severity, payload.phase),
57811
+ phase: payload.phase,
57812
+ episodeId: payload.episodeId,
57813
+ alertId: payload.alertId
57814
+ }, this.now());
57815
+ const meta = {
57816
+ via,
57817
+ alertId: payload.alertId,
57818
+ episodeId: payload.episodeId,
57819
+ phase: payload.phase,
57820
+ severity: payload.severity,
57821
+ verdict
57822
+ };
57823
+ if (verdict !== "notify") {
57824
+ this.logger.debug("alert episode edge not notified", { meta });
57825
+ return false;
57826
+ }
57827
+ const incoming = incomingFromSystemEvent(event, this.deviceDirectory.lookup());
57828
+ if (incoming === null) return false;
57829
+ this.logger.info("alert episode notified to the rules", { meta });
57830
+ this.consumeEvent(incoming);
57831
+ return true;
57832
+ }
57833
+ async hydrateAlertEpisodes() {
57834
+ try {
57835
+ const rows = await this.alertEpisodes.load();
57836
+ this.logger.info("alert episode ledger reseeded", { meta: { rows } });
57837
+ } finally {
57838
+ this.alertEpisodesLoaded = true;
57839
+ }
57840
+ await this.reconcileAlertEpisodes();
57841
+ }
57842
+ /**
57843
+ * The alerts store against the notified episodes (D11, D49): every OPEN
57844
+ * critical/error alert not yet notified is notified, and every notified
57845
+ * episode whose alert is CLOSED gets its resolution. Never runs on a ledger
57846
+ * the store has not answered — an empty, unread mirror would re-notify every
57847
+ * open alert — and never reads absence as closure: an alert that is not in
57848
+ * the answer is left alone (rows age out by {@link NC_ALERT_EPISODE_MAX_AGE_MS}).
57849
+ */
57850
+ async reconcileAlertEpisodes() {
57851
+ const read = this.deps.readAlerts;
57852
+ if (read === void 0 || !this.evaluationActive || !this.alertEpisodes.hasAnswered()) return;
57853
+ let alerts;
57854
+ try {
57855
+ alerts = await read();
57856
+ } catch (err) {
57857
+ this.logger.warn("alert episode reconcile could not read the alerts", { meta: { error: require_clip_model_registry.errMsg(err) } });
57858
+ return;
57859
+ }
57860
+ let notified = 0;
57861
+ for (const alert of alerts) {
57862
+ const episodeId = require_clip_model_registry.alertEpisodeId(alert);
57863
+ const open = require_clip_model_registry.isAlertEpisodeOpen(alert.status);
57864
+ if (open && !require_clip_model_registry.isAlertEpisodeSeverity(alert.severity)) continue;
57865
+ if (!open && !this.alertEpisodes.has(episodeId)) continue;
57866
+ const phase = open ? "raised" : "resolved";
57867
+ if (this.admitAlertEpisode(require_clip_model_registry.createEvent(require_clip_model_registry.EventCategory.AlertEpisode, {
57868
+ type: "addon",
57869
+ id: "notification-center"
57870
+ }, require_clip_model_registry.alertEpisodePayload(alert, phase, this.now())), "reconcile")) notified += 1;
57871
+ }
57872
+ const pruned = alerts.length < NC_ALERT_RECONCILE_LIMIT ? await this.alertEpisodes.pruneOlderThan(this.now() - NC_ALERT_EPISODE_MAX_AGE_MS) : 0;
57873
+ if (notified > 0 || pruned > 0) this.logger.info("alert episodes reconciled", { meta: {
57874
+ alerts: alerts.length,
57875
+ notified,
57876
+ pruned
57877
+ } });
57878
+ }
57879
+ /**
57490
57880
  * Boot reseed of the durable liveness ledger.
57491
57881
  *
57492
57882
  * The count is said OUT LOUD at info, because it is the one line that
@@ -94641,6 +95031,62 @@ function pickWritableMediaLocation(locations, stickyId) {
94641
95031
  return writable.toSorted((a, b) => b.headroomBytes - a.headroomBytes || a.id.localeCompare(b.id))[0]?.id ?? null;
94642
95032
  }
94643
95033
  //#endregion
95034
+ //#region src/pipeline-analytics/event-media-write-location.ts
95035
+ var EventMediaWriteLocator = class {
95036
+ deps;
95037
+ cached = null;
95038
+ /**
95039
+ * The last location actually picked. Survives {@link invalidate}, so a ring
95040
+ * keeps writing where it was writing while that stays writable — the
95041
+ * stickiness the class default used to provide (D383).
95042
+ */
95043
+ lastPick = null;
95044
+ constructor(deps) {
95045
+ this.deps = deps;
95046
+ }
95047
+ /** Drop the cached pick: the next write re-reads the location set. */
95048
+ invalidate() {
95049
+ this.cached = null;
95050
+ }
95051
+ async resolve(logger, deviceId) {
95052
+ if (this.cached !== null) return this.cached;
95053
+ let locations;
95054
+ try {
95055
+ locations = await this.deps.list();
95056
+ } catch (err) {
95057
+ logger.debug("pipeline-analytics: eventMedia location list unavailable — writing through the default ref", { meta: { error: require_clip_model_registry.errMsg(err) } });
95058
+ this.cached = DEFAULT_MEDIA_LOCATION;
95059
+ return DEFAULT_MEDIA_LOCATION;
95060
+ }
95061
+ const picked = pickWritableMediaLocation(locations.map((l) => ({
95062
+ id: l.id,
95063
+ mode: require_clip_model_registry.resolveLocationMode(l),
95064
+ headroomBytes: l.capacity?.availableBytes ?? 0
95065
+ })), this.lastPick ?? void 0);
95066
+ if (picked === null) {
95067
+ const seen = locations.length > 0 ? locations.map((l) => l.id).join(", ") : "(none registered)";
95068
+ logger.error("pipeline-analytics: no writable eventMedia storage location — refusing the write instead of failing far downstream as a filesystem error", {
95069
+ tags: { deviceId },
95070
+ meta: { locationsSeen: seen }
95071
+ });
95072
+ throw new Error(`no writable eventMedia storage location (seen: ${seen})`);
95073
+ }
95074
+ this.cached = picked;
95075
+ this.lastPick = picked;
95076
+ return picked;
95077
+ }
95078
+ };
95079
+ /**
95080
+ * Ring the locator from the location-set doorbell. `onRing` lets the caller drop
95081
+ * its other location-derived caches in the same breath.
95082
+ */
95083
+ function subscribeEventMediaWriteLocator(eventBus, locator, onRing) {
95084
+ return eventBus.subscribe({ category: require_clip_model_registry.EventCategory.StorageLocationsChanged }, () => {
95085
+ locator.invalidate();
95086
+ onRing();
95087
+ });
95088
+ }
95089
+ //#endregion
94644
95090
  //#region src/pipeline-analytics/media-relocate-engine.ts
94645
95091
  /**
94646
95092
  * Event-media relocation engine (entity-routing spec, Phase 4) — the
@@ -104634,7 +105080,8 @@ var NC_SYSTEM_EVENT_CATEGORIES = [
104634
105080
  require_clip_model_registry.EventCategory.SystemRestartCompleted,
104635
105081
  require_clip_model_registry.EventCategory.SystemRestarting,
104636
105082
  require_clip_model_registry.EventCategory.RecordingExportCompleted,
104637
- require_clip_model_registry.EventCategory.BackupRunCompleted
105083
+ require_clip_model_registry.EventCategory.BackupRunCompleted,
105084
+ require_clip_model_registry.EventCategory.AlertEpisode
104638
105085
  ];
104639
105086
  /**
104640
105087
  * Install the addon's bus subscriptions. Identical set + relative order to the
@@ -107522,6 +107969,15 @@ Object.freeze({
107522
107969
  "valueType": "boolean"
107523
107970
  }],
107524
107971
  "dishwasher": [
107972
+ {
107973
+ "path": "availability",
107974
+ "valueType": "enum",
107975
+ "enumValues": [
107976
+ "available",
107977
+ "offline",
107978
+ "description-missing"
107979
+ ]
107980
+ },
107525
107981
  {
107526
107982
  "path": "state",
107527
107983
  "valueType": "enum",
@@ -107534,12 +107990,35 @@ Object.freeze({
107534
107990
  "unavailable"
107535
107991
  ]
107536
107992
  },
107993
+ {
107994
+ "path": "operationState",
107995
+ "valueType": "enum",
107996
+ "enumValues": [
107997
+ "Inactive",
107998
+ "Ready",
107999
+ "DelayedStart",
108000
+ "Run",
108001
+ "Pause",
108002
+ "ActionRequired",
108003
+ "Finished",
108004
+ "Error",
108005
+ "Aborting"
108006
+ ]
108007
+ },
107537
108008
  {
107538
108009
  "path": "program",
107539
108010
  "valueType": "string"
107540
108011
  },
107541
108012
  {
107542
- "path": "remainingMinutes",
108013
+ "path": "selectedProgram",
108014
+ "valueType": "string"
108015
+ },
108016
+ {
108017
+ "path": "remainingSeconds",
108018
+ "valueType": "number"
108019
+ },
108020
+ {
108021
+ "path": "delayedStartSeconds",
107543
108022
  "valueType": "number"
107544
108023
  },
107545
108024
  {
@@ -107550,14 +108029,30 @@ Object.freeze({
107550
108029
  "path": "doorOpen",
107551
108030
  "valueType": "boolean"
107552
108031
  },
108032
+ {
108033
+ "path": "doorLocked",
108034
+ "valueType": "boolean"
108035
+ },
107553
108036
  {
107554
108037
  "path": "remoteStartAllowed",
107555
108038
  "valueType": "boolean"
107556
108039
  },
108040
+ {
108041
+ "path": "remoteControlActive",
108042
+ "valueType": "boolean"
108043
+ },
108044
+ {
108045
+ "path": "localControlActive",
108046
+ "valueType": "boolean"
108047
+ },
107557
108048
  {
107558
108049
  "path": "powerOn",
107559
108050
  "valueType": "boolean"
107560
108051
  },
108052
+ {
108053
+ "path": "powerState",
108054
+ "valueType": "string"
108055
+ },
107561
108056
  {
107562
108057
  "path": "saltLevel",
107563
108058
  "valueType": "enum",
@@ -111141,20 +111636,6 @@ var MigrationMarkerStore = class MigrationMarkerStore {
111141
111636
  };
111142
111637
  };
111143
111638
  //#endregion
111144
- //#region src/pipeline-analytics/store/analytics-history-keys.ts
111145
- /** Declared addon series keys (D850). */
111146
- var ANALYTICS_HISTORY_BUCKET_SERIES = {
111147
- motion: "addon:pipeline-analytics/motion",
111148
- "audio-dbfs": "addon:pipeline-analytics/audio-dbfs",
111149
- occupancy: "addon:pipeline-analytics/occupancy"
111150
- };
111151
- var ANALYTICS_AUDIO_LEVEL_KEY = "addon:pipeline-analytics/audio-level";
111152
- /** Map the public cap enum to the device-history key (read path). */
111153
- function historyKeyForLtsSeries(series) {
111154
- if (series === "battery") return "cap:battery.percentage";
111155
- return ANALYTICS_HISTORY_BUCKET_SERIES[series];
111156
- }
111157
- //#endregion
111158
111639
  //#region src/pipeline-analytics/store/analytics-history-producer.ts
111159
111640
  var BUCKET_SERIES_META = [
111160
111641
  {
@@ -111176,6 +111657,10 @@ var AUDIO_LEVEL_SERIES = {
111176
111657
  displayName: "Audio level (dBFS)",
111177
111658
  unit: "dBFS"
111178
111659
  };
111660
+ /** One audio level point per window; equals device-history's 1 point / 10 s record bound. */
111661
+ var AUDIO_LEVEL_WINDOW_MS = 1e4;
111662
+ /** Stored for a measured-silent window (−∞ dBFS cannot be a history value). */
111663
+ var AUDIO_LEVEL_SILENT_DBFS = -120;
111179
111664
  function refusalCounts(result) {
111180
111665
  const counts = {};
111181
111666
  for (const r of result.refused) counts[r.reason] = (counts[r.reason] ?? 0) + r.count;
@@ -111187,6 +111672,7 @@ var AnalyticsHistoryProducer = class {
111187
111672
  logger;
111188
111673
  nowFn;
111189
111674
  declared = false;
111675
+ levelWindows = /* @__PURE__ */ new Map();
111190
111676
  constructor(deps) {
111191
111677
  this.deviceHistory = deps.deviceHistory;
111192
111678
  this.logger = deps.logger;
@@ -111219,6 +111705,43 @@ var AnalyticsHistoryProducer = class {
111219
111705
  deviceId
111220
111706
  }, scope ?? "", value, at ?? this.nowFn());
111221
111707
  }
111708
+ /**
111709
+ * Feed EVERY analysed audio sample. One point per window of
111710
+ * {@link AUDIO_LEVEL_WINDOW_MS} is written, stamped at the window's first
111711
+ * sample, carrying the window's PEAK dBFS: a 2 s spike inside a stable window
111712
+ * is not averaged away, and stable audio still yields a point each window
111713
+ * (continuity over dedup — the level bar draws a continuous line).
111714
+ *
111715
+ * Measured silence (−∞) is stored at {@link AUDIO_LEVEL_SILENT_DBFS}: it is a
111716
+ * measurement, not an absence (D431); NaN carries no claim and is dropped.
111717
+ * A window closes on the first sample past its end, so no timer is needed and
111718
+ * points are always at least a window apart (the history's 1 point / 10 s
111719
+ * bound).
111720
+ */
111721
+ observeAudioLevel(deviceId, dbfs, at) {
111722
+ if (Number.isNaN(dbfs)) return;
111723
+ const value = Number.isFinite(dbfs) ? dbfs : AUDIO_LEVEL_SILENT_DBFS;
111724
+ const win = this.levelWindows.get(deviceId);
111725
+ if (win === void 0) {
111726
+ this.levelWindows.set(deviceId, {
111727
+ start: at,
111728
+ peak: value
111729
+ });
111730
+ return;
111731
+ }
111732
+ if (at - win.start < AUDIO_LEVEL_WINDOW_MS) {
111733
+ if (value > win.peak) this.levelWindows.set(deviceId, {
111734
+ start: win.start,
111735
+ peak: value
111736
+ });
111737
+ return;
111738
+ }
111739
+ this.levelWindows.set(deviceId, {
111740
+ start: at,
111741
+ peak: value
111742
+ });
111743
+ this.recordAudioLevel(deviceId, win.peak, win.start);
111744
+ }
111222
111745
  recordAudioLevel(deviceId, dbfs, at) {
111223
111746
  const record = this.deviceHistory.record;
111224
111747
  if (record === void 0) return;
@@ -113879,20 +114402,13 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_clip_m
113879
114402
  mediaRelocate = null;
113880
114403
  mediaLocationStorage = null;
113881
114404
  /**
113882
- * Cached pick for NEW `eventMedia` writes (`pickWritableMediaLocation`).
113883
- * Resolved once, never per blob (no-RPC-per-blob, same discipline as
113884
- * `mediaLocationStorage`'s own root cache) — dropped together with it by
113885
- * `refreshStorageLocationsForMigration`, since a location's `readOnly` /
113886
- * `enabled` flags only change via operator reconfig.
113887
- */
113888
- eventMediaWriteLocationCache = null;
113889
- /**
113890
- * The last location this process actually picked for an `eventMedia` write.
113891
- * Survives the cache invalidation a migration performs, so a refresh keeps
113892
- * writing where it was writing while that stays writable — the stickiness the
113893
- * class default used to provide before it was deleted (D383).
114405
+ * Where NEW `eventMedia` writes land. Its pick is cached per location-set
114406
+ * generation and dropped by the `StorageLocationsChanged` doorbell (D387) and
114407
+ * by `refreshStorageLocationsForMigration`, so a mode change or a repoint takes
114408
+ * effect without a restart.
113894
114409
  */
113895
- lastEventMediaWritePick = null;
114410
+ eventMediaWriteLocator = null;
114411
+ eventMediaLocationDoorbellUnsub = null;
113896
114412
  mediaWriteGate = new MediaWriteGate();
113897
114413
  storageMigrationLeaseId = null;
113898
114414
  /** Per-camera history of LINKED-device sensor state changes (Part B). */
@@ -115932,33 +116448,9 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_clip_m
115932
116448
  }
115933
116449
  /** One line per process: this repeats on every occupancy refresh otherwise. */
115934
116450
  mediaOccupancyUnattributedLogged = false;
115935
- async resolveEventMediaWriteLocation(api, logger, deviceId) {
115936
- const cached = this.eventMediaWriteLocationCache;
115937
- if (cached !== null) return cached;
115938
- let locations;
115939
- try {
115940
- locations = await api.storage.listLocations.query({ type: DEFAULT_MEDIA_LOCATION });
115941
- } catch (err) {
115942
- logger.debug("pipeline-analytics: eventMedia location list unavailable — writing through the default ref", { meta: { error: require_clip_model_registry.errMsg(err) } });
115943
- this.eventMediaWriteLocationCache = DEFAULT_MEDIA_LOCATION;
115944
- return DEFAULT_MEDIA_LOCATION;
115945
- }
115946
- const picked = pickWritableMediaLocation(locations.map((l) => ({
115947
- id: l.id,
115948
- mode: require_clip_model_registry.resolveLocationMode(l),
115949
- headroomBytes: l.capacity?.availableBytes ?? 0
115950
- })), this.lastEventMediaWritePick ?? void 0);
115951
- if (picked === null) {
115952
- const seen = locations.length > 0 ? locations.map((l) => l.id).join(", ") : "(none registered)";
115953
- logger.error("pipeline-analytics: no writable eventMedia storage location — refusing the write instead of failing far downstream as a filesystem error", {
115954
- tags: { deviceId },
115955
- meta: { locationsSeen: seen }
115956
- });
115957
- throw new Error(`no writable eventMedia storage location (seen: ${seen})`);
115958
- }
115959
- this.eventMediaWriteLocationCache = picked;
115960
- this.lastEventMediaWritePick = picked;
115961
- return picked;
116451
+ resolveEventMediaWriteLocation(api, logger, deviceId) {
116452
+ this.eventMediaWriteLocator ??= new EventMediaWriteLocator({ list: () => api.storage.listLocations.query({ type: DEFAULT_MEDIA_LOCATION }) });
116453
+ return this.eventMediaWriteLocator.resolve(logger, deviceId);
115962
116454
  }
115963
116455
  /** Constructs every SQLite-backed store plus the stationary/package-drop/
115964
116456
  * sensor plumbing, in the exact pre-S7 order. Returns the non-null bundle
@@ -116059,6 +116551,9 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_clip_m
116059
116551
  resolveEventMediaLocation: (deviceId) => this.resolveEventMediaWriteLocation(api, logger, deviceId)
116060
116552
  });
116061
116553
  this.mediaStore = mediaStore;
116554
+ this.eventMediaWriteLocator ??= new EventMediaWriteLocator({ list: () => api.storage.listLocations.query({ type: DEFAULT_MEDIA_LOCATION }) });
116555
+ this.eventMediaLocationDoorbellUnsub?.();
116556
+ this.eventMediaLocationDoorbellUnsub = subscribeEventMediaWriteLocator(this.ctx.eventBus, this.eventMediaWriteLocator, () => this.mediaLocationStorage?.invalidateRoots());
116062
116557
  const eventLedgerWiring = this.eventLedgerWiring(api.settingsStore);
116063
116558
  const retrainFrames = new RetrainFrameStore({
116064
116559
  store: api.settingsStore,
@@ -116209,6 +116704,7 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_clip_m
116209
116704
  const eventStore = new EventStore({
116210
116705
  store: api.settingsStore,
116211
116706
  logger: logger.child("EventStore"),
116707
+ audioLevelHistory: (input) => api.deviceHistory.query.query(input),
116212
116708
  motionLedger: wireMotionEventLedger(eventLedgerWiring),
116213
116709
  audioLedger: wireAudioEventLedger(eventLedgerWiring),
116214
116710
  onMotionSample: (deviceId, regionCount, at) => lts.note("motion", deviceId, regionCount, at),
@@ -116641,6 +117137,7 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_clip_m
116641
117137
  coreBlocks: async () => (await api.coreBlocks.list.query({})).blocks,
116642
117138
  nodeAddons: (nodeId) => api.nodes.getNodeAddons.query({ nodeId })
116643
117139
  },
117140
+ readAlerts: () => api.alerts.list.query({ limit: 5e3 }),
116644
117141
  getSnapshot: (deviceId) => api.snapshot.getSnapshot.query({ deviceId }),
116645
117142
  recognitionOutlook: ({ deviceId, trackId, className, labelKind, now }) => recognitionOutlook({
116646
117143
  className,
@@ -118654,6 +119151,8 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_clip_m
118654
119151
  this.alarmPanelConvergence?.stop();
118655
119152
  this.alarmPanelConvergence = null;
118656
119153
  for (const unsub of this.subscriptionUnsubs) unsub();
119154
+ this.eventMediaLocationDoorbellUnsub?.();
119155
+ this.eventMediaLocationDoorbellUnsub = null;
118657
119156
  this.subscriptionUnsubs = [];
118658
119157
  await Promise.all([this.drainSensorHandlers(), this.motionEpisodes?.closeAll("addon-shutdown")]);
118659
119158
  await this.settleTrackFusions();
@@ -121681,6 +122180,7 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_clip_m
121681
122180
  score: topClassification.score
121682
122181
  } : {}
121683
122182
  }, this.audioMarkerConfig(settings));
122183
+ if (level !== void 0) this.lts?.observeAudioLevel(deviceId, level.dbfs, timestamp);
121684
122184
  this.notificationCenter?.observeAudio(deviceId, ncAudioSampleFromFrame(frame, timestamp, settings));
121685
122185
  const route = classifyAudioFrame(topClassification ? {
121686
122186
  className: topClassification.macroClass,
@@ -121756,8 +122256,6 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_clip_m
121756
122256
  });
121757
122257
  this.levelStateByDevice.set(deviceId, res.nextState);
121758
122258
  if (!res.emit) return;
121759
- const dbfs = level?.dbfs ?? Number.NEGATIVE_INFINITY;
121760
- this.lts?.recordAudioLevel(deviceId, dbfs, timestamp);
121761
122259
  }
121762
122260
  /**
121763
122261
  * Persist motion-analysis bus events into the EPISODE tracker (D475): a
@@ -125908,7 +126406,7 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_clip_m
125908
126406
  async refreshStorageLocationsForMigration(input) {
125909
126407
  if (this.storageMigrationLeaseId !== input.leaseId) throw new Error("event-media storage migration lease does not match");
125910
126408
  this.mediaLocationStorage?.invalidateRoots();
125911
- this.eventMediaWriteLocationCache = null;
126409
+ this.eventMediaWriteLocator?.invalidate();
125912
126410
  return { refreshed: true };
125913
126411
  }
125914
126412
  /**
@@ -126271,7 +126769,24 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_clip_m
126271
126769
  async startStorageMigrationMove(input) {
126272
126770
  if (!this.mediaRelocate) throw new Error("media relocation unavailable");
126273
126771
  if (this.storageMigrationLeaseId !== input.leaseId) throw new Error("event-media storage migration lease does not match");
126274
- return { jobId: this.mediaRelocate.start(input) };
126772
+ return { jobId: await this.startMediaMover(input) };
126773
+ }
126774
+ /**
126775
+ * Start a media move only while no single-location repoint holds an overlapping
126776
+ * physical root (the storage orchestrator owns that claim). A shared recordings
126777
+ * root is the case: a repoint of another class there must not run beside us.
126778
+ */
126779
+ startMediaMover(input) {
126780
+ const engine = this.mediaRelocate;
126781
+ if (!engine) throw new Error("media relocation unavailable");
126782
+ return require_clip_model_registry.startMoverUnderClearance({
126783
+ clearance: (locationIds) => this.ctx.api.storageMigration.moverClearance.query({ locationIds }),
126784
+ start: () => engine.start(input),
126785
+ cancel: (jobId) => {
126786
+ engine.cancel(jobId);
126787
+ },
126788
+ locationIds: [input.fromLocationId ?? "*", input.toLocationId]
126789
+ });
126275
126790
  }
126276
126791
  async getStorageMigrationMoveStatus(input) {
126277
126792
  return this.mediaRelocate?.list().find((job) => job.jobId === input.jobId) ?? null;
@@ -126289,7 +126804,7 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_clip_m
126289
126804
  */
126290
126805
  async relocateMedia(input) {
126291
126806
  if (!this.mediaRelocate) throw new Error("media relocation unavailable");
126292
- const jobId = this.mediaRelocate.start(input);
126807
+ const jobId = await this.startMediaMover(input);
126293
126808
  this.ctx.logger.info("pipeline-analytics: media relocate job enqueued", { meta: {
126294
126809
  jobId,
126295
126810
  mode: input.mode ?? "move",