@camstack/addon-pipeline 1.2.131 → 1.2.133

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 (36) hide show
  1. package/dist/{addon-utils-DCQ5AP_v.js → addon-utils-FV2O25id.js} +1 -1
  2. package/dist/audio-analyzer/index.js +3 -3
  3. package/dist/audio-analyzer/index.mjs +2 -2
  4. package/dist/detection-pipeline/index.js +5 -5
  5. package/dist/detection-pipeline/index.mjs +3 -3
  6. package/dist/{dist-DO0UVhOk.mjs → dist-7Uc6NdFm.mjs} +736 -67
  7. package/dist/{dist-CqoMNE1e.js → dist-DsnZUYTs.js} +753 -66
  8. package/dist/{event-loop-stall-monitor-eM7_yYfn.js → event-loop-stall-monitor-D86J6d2c.js} +1 -1
  9. package/dist/{event-loop-stall-monitor-D0Juf7Dn.mjs → event-loop-stall-monitor-XzDMVU9d.mjs} +1 -1
  10. package/dist/{lazy-sharp-BGrpS8yi.js → lazy-sharp-UzEUL79V.js} +1 -1
  11. package/dist/motion-wasm/index.js +2 -2
  12. package/dist/motion-wasm/index.mjs +1 -1
  13. package/dist/pipeline-runner/index.js +4 -4
  14. package/dist/pipeline-runner/index.mjs +3 -3
  15. package/dist/{process-memory-Bac3FuG6.mjs → process-memory-Ci90-zF_.mjs} +1 -1
  16. package/dist/{process-memory-xAPjvSw9.js → process-memory-Co59RQIK.js} +1 -1
  17. package/dist/recorder/index.js +677 -99
  18. package/dist/recorder/index.mjs +676 -98
  19. package/dist/{segment-demux-js-skhsAZx6.mjs → segment-demux-js-0NZ9TXro.mjs} +1 -1
  20. package/dist/{segment-demux-js-Cu0oDMUL.js → segment-demux-js-DKcc0bxA.js} +1 -1
  21. package/dist/session-decode/decode-worker-child.js +195 -13
  22. package/dist/session-decode/decode-worker-child.mjs +194 -12
  23. package/dist/stream-broker/_stub.js +2 -2
  24. package/dist/stream-broker/{_virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-DGQkDEYw.mjs → _virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-Dqj2f3H7.mjs} +3 -3
  25. package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-CVBnNONy.mjs +26 -0
  26. package/dist/stream-broker/{_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_ui_mf_2_library__loadShare__.js-CQKwHIHI.mjs → _virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_ui_mf_2_library__loadShare__.js-77rGxv1J.mjs} +1 -1
  27. package/dist/stream-broker/demux-worker-child.js +1 -1
  28. package/dist/stream-broker/demux-worker-child.mjs +1 -1
  29. package/dist/stream-broker/{hostInit-D7FwwZgj.mjs → hostInit-DnwyBL4I.mjs} +3 -3
  30. package/dist/stream-broker/index.js +2 -2
  31. package/dist/stream-broker/index.mjs +2 -2
  32. package/dist/stream-broker/remoteEntry.js +1 -1
  33. package/dist/{worker-protocol-_Ujvj1hl.mjs → worker-protocol-CMDUAqvX.mjs} +12 -6
  34. package/dist/{worker-protocol-G3HXTY8z.js → worker-protocol-DzRUHIcM.js} +12 -6
  35. package/package.json +1 -1
  36. package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-Bme2OZQp.mjs +0 -26
@@ -1,7 +1,7 @@
1
- const require_dist = require("../dist-CqoMNE1e.js");
1
+ const require_dist = require("../dist-DsnZUYTs.js");
2
2
  const require_hub_hostname = require("../hub-hostname-DAJXlOgV.js");
3
3
  const require_restream_intent = require("../restream-intent-Cv9x3jmu.js");
4
- const require_addon_utils = require("../addon-utils-DCQ5AP_v.js");
4
+ const require_addon_utils = require("../addon-utils-FV2O25id.js");
5
5
  const require_retire_root_keys = require("../retire-root-keys-KE6D6Xh_.js");
6
6
  let node_crypto = require("node:crypto");
7
7
  let node_child_process = require("node:child_process");
@@ -33,6 +33,185 @@ function subscribeEventCapture(deps) {
33
33
  });
34
34
  }
35
35
  //#endregion
36
+ //#region src/recorder/object-trigger.ts
37
+ /**
38
+ * Il gate fra `pipeline-analytics.detection-event` e "apri una finestra di
39
+ * registrazione".
40
+ *
41
+ * Fratello di `recorder/event-capture.ts` e per lo stesso motivo: la qualifica
42
+ * ha dei rami, e un ramo che scarta lavoro senza dirlo è indistinguibile da una
43
+ * funzione mai cablata. Ogni verdetto negativo NOMINA il motivo, e il chiamante
44
+ * lo logga.
45
+ *
46
+ * Puro: nessun bus, nessun orologio, nessuna config. La scelta di QUALI classi
47
+ * ascoltare è della banda (`bandTriggerSources`); qui si decide solo se
48
+ * l'evento è una detection utilizzabile e di che macro classe è.
49
+ */
50
+ /** Type guard, non cast: `event.data` è `unknown` finché non passa di qui. */
51
+ function isDetectionEventData(value) {
52
+ if (typeof value !== "object" || value === null) return false;
53
+ if (!("deviceId" in value) || typeof value.deviceId !== "number") return false;
54
+ if (!("kind" in value) || typeof value.kind !== "string") return false;
55
+ if (!("timestamp" in value) || typeof value.timestamp !== "number") return false;
56
+ if (!Number.isFinite(value.timestamp) || value.timestamp <= 0) return false;
57
+ if (!("className" in value) || typeof value.className !== "string") return false;
58
+ if ("source" in value && value.source !== void 0 && typeof value.source !== "string") return false;
59
+ return true;
60
+ }
61
+ function qualifyObjectTrigger(data) {
62
+ if (!isDetectionEventData(data)) return {
63
+ qualified: false,
64
+ reason: "malformed"
65
+ };
66
+ if (data.kind !== "object") return {
67
+ qualified: false,
68
+ reason: "not-object"
69
+ };
70
+ if ((data.source ?? "pipeline") !== "pipeline") return {
71
+ qualified: false,
72
+ reason: "not-pipeline"
73
+ };
74
+ const className = data.className.trim().toLowerCase();
75
+ if (!require_dist.isFirstLevelMacroClass(className)) return {
76
+ qualified: false,
77
+ reason: "not-a-macro"
78
+ };
79
+ return {
80
+ qualified: true,
81
+ deviceId: data.deviceId,
82
+ macroClass: className,
83
+ atMs: data.timestamp
84
+ };
85
+ }
86
+ //#endregion
87
+ //#region src/recorder/sensor-trigger.ts
88
+ /**
89
+ * Da `DeviceStateChanged` a "quali camere devono registrare".
90
+ *
91
+ * Due responsabilità, entrambe in RAM e nessuna durevole:
92
+ * - la MEMORIA del fronte, per `(deviceId, capName)`, che rende `false→true`
93
+ * distinguibile da una ri-emissione. Il predicato è `evaluateSensorEdge`
94
+ * (`@camstack/types`) — la stessa tabella `SOURCE_CAP_ACTIVE_FIELD` del
95
+ * virtual-doorbell, non una copia;
96
+ * - l'INDICE INVERSO sorgente→camere, uno stato rispecchiato aggiornato FUORI
97
+ * dal percorso dell'evento (D49): `setBindings` è chiamato al boot dalle
98
+ * config già lette e a ogni `onConfigChanged`, che è un'RPC. Una lettura
99
+ * fallibile sul percorso del trigger potrebbe rispondere "nessuno ascolta"
100
+ * e perdere l'episodio, che è il fallimento contro cui D49 è scritta.
101
+ *
102
+ * L'ordine dei controlli è deliberato: `DeviceStateChanged` viaggia a ritmo di
103
+ * bus su tutta la flotta (batteria, la `motion` nativa di ogni camera, la
104
+ * connettività). L'indice inverso è una `Map.get` senza allocazioni e va per
105
+ * primo; il fronte — che legge due tabelle e la slice — viene dopo. È la stessa
106
+ * scelta che post-analysis scrive per la sua sottoscrizione: *"the
107
+ * EVENT_KIND_BY_CAP gate runs FIRST so non-sensor cap churn exits before any
108
+ * async work"*.
109
+ *
110
+ * Puro rispetto al mondo: nessun bus, nessun logger, nessun orologio proprio.
111
+ * Il chiamante logga il verdetto — un ramo che non accende nulla lo DICE.
112
+ */
113
+ /**
114
+ * Type GUARD, not a cast: `event.data` is `unknown`, and a version-skewed
115
+ * emitter must leave this router untouched rather than route a `deviceId` of
116
+ * `undefined`. Same discipline as `parseTopologySnapshotNodes`.
117
+ *
118
+ * A missing or non-object `slice` is REFUSED rather than defaulted to `{}`:
119
+ * `evaluateSensorEdge` would read `non-boolean-value` from an empty object and
120
+ * that would look like a sensor reporting nonsense, when what actually
121
+ * happened is that there was nothing to read.
122
+ */
123
+ function parseSensorStateChanged(data) {
124
+ if (typeof data !== "object" || data === null) return null;
125
+ if (!("deviceId" in data) || typeof data.deviceId !== "number") return null;
126
+ if (!("capName" in data) || typeof data.capName !== "string") return null;
127
+ if (!("slice" in data) || typeof data.slice !== "object" || data.slice === null) return null;
128
+ const slice = { ...data.slice };
129
+ return {
130
+ deviceId: data.deviceId,
131
+ capName: data.capName,
132
+ slice
133
+ };
134
+ }
135
+ /**
136
+ * sourceDeviceId → cameras whose config names it.
137
+ *
138
+ * From EVERY band, not only the one active now: the index only changes on a
139
+ * config write, while the active band changes with the clock, and an index
140
+ * that followed the clock would lose the first trigger after every band edge.
141
+ * Filtering by band is already the gate's job — `bandTriggerSources` reads the
142
+ * band active at the instant of evaluation.
143
+ *
144
+ * A disabled camera is skipped: it records nothing whatever a sensor does, and
145
+ * it re-enters through `onConfigChanged`, which rebuilds this.
146
+ */
147
+ function buildSensorTriggerBindings(configs) {
148
+ const index = /* @__PURE__ */ new Map();
149
+ for (const [cameraId, config] of configs) {
150
+ if (config.enabled !== true) continue;
151
+ for (const band of config.bands ?? []) for (const sourceDeviceId of band.triggers?.sensorDeviceIds ?? []) {
152
+ const cameras = index.get(sourceDeviceId) ?? /* @__PURE__ */ new Set();
153
+ cameras.add(cameraId);
154
+ index.set(sourceDeviceId, cameras);
155
+ }
156
+ }
157
+ return index;
158
+ }
159
+ var SensorTriggerRouter = class {
160
+ /** sourceDeviceId → camere la cui config lo nomina. */
161
+ bindings = /* @__PURE__ */ new Map();
162
+ /** Ultimo booleano osservato, per `${deviceId}:${capName}`. */
163
+ lastValues = /* @__PURE__ */ new Map();
164
+ now;
165
+ startedAtMs;
166
+ freshnessMs;
167
+ constructor(options) {
168
+ this.now = options.now;
169
+ this.startedAtMs = options.startedAtMs;
170
+ this.freshnessMs = options.firstSightingFreshnessMs ?? 3e4;
171
+ }
172
+ /**
173
+ * Sostituisce l'indice inverso. NON tocca `lastValues`: un sensore tolto e
174
+ * rimesso in una banda deve ritrovare il suo livello precedente, altrimenti
175
+ * la prima slice dopo il rientro sarebbe una "prima osservazione" e un
176
+ * contatto rimasto aperto tutto il tempo diventerebbe un fronte alto.
177
+ */
178
+ setBindings(bindings) {
179
+ this.bindings = bindings;
180
+ }
181
+ /** Quali sorgenti l'indice contiene ora — per la riga di log al boot. */
182
+ listenedSourceIds() {
183
+ return [...this.bindings.keys()];
184
+ }
185
+ onStateSlice(deviceId, capName, slice) {
186
+ const cameras = this.bindings.get(deviceId);
187
+ if (cameras === void 0 || cameras.size === 0) return {
188
+ fired: [],
189
+ skipped: "not-listened"
190
+ };
191
+ const key = `${deviceId}:${capName}`;
192
+ const verdict = require_dist.evaluateSensorEdge({
193
+ capName,
194
+ slice,
195
+ prior: this.lastValues.get(key),
196
+ startedAtMs: this.startedAtMs,
197
+ nowMs: this.now(),
198
+ firstSightingFreshnessMs: this.freshnessMs
199
+ });
200
+ if (verdict.edge === "none") {
201
+ if (verdict.value !== null) this.lastValues.set(key, verdict.value);
202
+ return {
203
+ fired: [],
204
+ skipped: verdict.reason
205
+ };
206
+ }
207
+ this.lastValues.set(key, true);
208
+ return {
209
+ fired: [...cameras],
210
+ skipped: null
211
+ };
212
+ }
213
+ };
214
+ //#endregion
36
215
  //#region src/recorder/event-map.ts
37
216
  var EventMap = class {
38
217
  byDevice = /* @__PURE__ */ new Map();
@@ -350,8 +529,13 @@ function fragmentRangeFor(index, offsetTicks) {
350
529
  * "nothing to read" (D49's letter, applied to a byte budget: a fallible
351
530
  * computation must never silently look like an unbind).
352
531
  */
353
- function fragmentsWithinBudget(index, offsetTicks, maxBytes) {
354
- const { samples, mfraOffset } = index;
532
+ /**
533
+ * The last sync sample at or before `offsetTicks` — the keyframe a decoder
534
+ * must start from. Shared by {@link fragmentsWithinBudget} and
535
+ * {@link fragmentsInWindow}: both walk forward from the same anchor, they
536
+ * only differ in the condition that stops the walk.
537
+ */
538
+ function findAnchor(samples, offsetTicks) {
355
539
  let lo = 0;
356
540
  let hi = samples.length - 1;
357
541
  let anchor = 0;
@@ -364,6 +548,11 @@ function fragmentsWithinBudget(index, offsetTicks, maxBytes) {
364
548
  lo = mid + 1;
365
549
  } else hi = mid - 1;
366
550
  }
551
+ return anchor;
552
+ }
553
+ function fragmentsWithinBudget(index, offsetTicks, maxBytes) {
554
+ const { samples, mfraOffset } = index;
555
+ const anchor = findAnchor(samples, offsetTicks);
367
556
  const endOf = (i) => samples[i + 1]?.moofOffset ?? mfraOffset;
368
557
  const anchorStart = samples[anchor]?.moofOffset ?? 0;
369
558
  const anchorEnd = endOf(anchor);
@@ -400,6 +589,70 @@ function fragmentsWithinBudget(index, offsetTicks, maxBytes) {
400
589
  oversizeFragment
401
590
  };
402
591
  }
592
+ /**
593
+ * Convert a TIME WINDOW `[fromTicks, toTicks)` into the set of consecutive
594
+ * fragments that covers it — the replay's primitive for "give me this many
595
+ * seconds of one segment", as opposed to {@link fragmentsWithinBudget}'s
596
+ * "give me as much as fits in N bytes" (the scrub GOP read's primitive).
597
+ *
598
+ * Stops walking forward the instant the NEXT fragment's own start time
599
+ * already reaches `toTicks`: at that point the fragments already collected
600
+ * cover the whole window, because each fragment's coverage runs up to the
601
+ * next one's start (the same relationship {@link fragmentsWithinBudget}'s
602
+ * `endOf` encodes). `maxBytes` is a SAFETY CEILING, not the primary stop
603
+ * condition — a caller with a well-formed window is expected to hit the time
604
+ * condition first; the byte cap exists so a pathological request cannot pull
605
+ * an unbounded amount of one segment into memory.
606
+ *
607
+ * Same never-empty guarantee as {@link fragmentsWithinBudget}: the anchor
608
+ * fragment is always returned, even when it alone exceeds `maxBytes`.
609
+ */
610
+ function fragmentsInWindow(index, fromTicks, toTicks, maxBytes) {
611
+ const { samples, mfraOffset } = index;
612
+ const anchor = findAnchor(samples, fromTicks);
613
+ const endOf = (i) => samples[i + 1]?.moofOffset ?? mfraOffset;
614
+ const windowCoveredAt = (i) => {
615
+ const next = samples[i + 1];
616
+ return next === void 0 || next.timeTicks >= toTicks;
617
+ };
618
+ const anchorStart = samples[anchor]?.moofOffset ?? 0;
619
+ const anchorEnd = endOf(anchor);
620
+ const fragments = [{
621
+ start: anchorStart,
622
+ end: anchorEnd,
623
+ sampleIndex: anchor
624
+ }];
625
+ let total = anchorEnd - anchorStart;
626
+ const oversizeFragment = total > maxBytes;
627
+ let i = anchor;
628
+ let reachesWindowEnd = windowCoveredAt(i);
629
+ while (!reachesWindowEnd) {
630
+ const nextIndex = i + 1;
631
+ const nextSample = samples[nextIndex];
632
+ if (nextSample === void 0) break;
633
+ const nextStart = nextSample.moofOffset;
634
+ const nextEnd = endOf(nextIndex);
635
+ const nextSize = nextEnd - nextStart;
636
+ if (total + nextSize > maxBytes) break;
637
+ fragments.push({
638
+ start: nextStart,
639
+ end: nextEnd,
640
+ sampleIndex: nextIndex
641
+ });
642
+ total += nextSize;
643
+ i = nextIndex;
644
+ reachesWindowEnd = windowCoveredAt(i);
645
+ }
646
+ const last = fragments[fragments.length - 1];
647
+ return {
648
+ fragments,
649
+ start: fragments[0].start,
650
+ end: last.end,
651
+ reachesSegmentEnd: last.end >= mfraOffset,
652
+ reachesWindowEnd,
653
+ oversizeFragment
654
+ };
655
+ }
403
656
  //#endregion
404
657
  //#region src/recorder/mfra-capture.ts
405
658
  /**
@@ -4440,6 +4693,34 @@ var SCRUB_GOP_BUDGET_MAX_BYTES = 15e5;
4440
4693
  function ticksToMs(ticks, timescale) {
4441
4694
  return Math.round(ticks / timescale * 1e3);
4442
4695
  }
4696
+ /** ms → ticks on the video timescale — the inverse of {@link ticksToMs}. */
4697
+ function msToTicks(ms, timescale) {
4698
+ return ms / 1e3 * timescale;
4699
+ }
4700
+ /**
4701
+ * Steps 1-2 shared by {@link readGopRange} and {@link readGopWindowRange}:
4702
+ * locate the `mfra` (seeded or from the tail), read the `ftyp`+`moov` head,
4703
+ * and pick the VIDEO track's sync table (never the audio one, which lists
4704
+ * every `moof` and would send a seek to bytes that do not open a GOP).
4705
+ * `null` on anything that does not look like a recorded segment.
4706
+ */
4707
+ async function resolveSegmentIndex(file, seededIndex, read) {
4708
+ const parsed = seededIndex === void 0 ? await parseTail(file, read) : null;
4709
+ if (seededIndex === void 0 && parsed === null) return null;
4710
+ const index = seededIndex ?? parsed.index;
4711
+ const firstMoof = index.samples[0]?.moofOffset ?? 0;
4712
+ if (firstMoof <= 0 || firstMoof >= file.size) return null;
4713
+ const head = await read(0, firstMoof);
4714
+ const init = parseInitInfo(head);
4715
+ if (init === null) return null;
4716
+ const videoIndex = index.trackId === init.videoTrackId ? index : parsed === null ? null : reparseTail(parsed.tail, file.size, init.videoTrackId);
4717
+ if (videoIndex === null) return null;
4718
+ return {
4719
+ head,
4720
+ init,
4721
+ videoIndex
4722
+ };
4723
+ }
4443
4724
  /**
4444
4725
  * Read the single GOP covering `epochMs` from `file`, by byte range.
4445
4726
  *
@@ -4455,17 +4736,10 @@ async function readGopRange(file, args) {
4455
4736
  bytesRead += chunk.length;
4456
4737
  return chunk;
4457
4738
  };
4458
- const parsed = args.seededIndex === void 0 ? await parseTail(file, read) : null;
4459
- if (args.seededIndex === void 0 && parsed === null) return null;
4460
- const index = args.seededIndex ?? parsed.index;
4461
- const firstMoof = index.samples[0]?.moofOffset ?? 0;
4462
- if (firstMoof <= 0 || firstMoof >= file.size) return null;
4463
- const head = await read(0, firstMoof);
4464
- const init = parseInitInfo(head);
4465
- if (init === null) return null;
4466
- const videoIndex = index.trackId === init.videoTrackId ? index : parsed === null ? null : reparseTail(parsed.tail, file.size, init.videoTrackId);
4467
- if (videoIndex === null) return null;
4468
- const offsetTicks = Math.max(0, args.epochMs - args.segmentStartMs) / 1e3 * init.videoTimescale;
4739
+ const resolved = await resolveSegmentIndex(file, args.seededIndex, read);
4740
+ if (resolved === null) return null;
4741
+ const { head, init, videoIndex } = resolved;
4742
+ const offsetTicks = msToTicks(Math.max(0, args.epochMs - args.segmentStartMs), init.videoTimescale);
4469
4743
  let start;
4470
4744
  let end;
4471
4745
  let firstSampleIndex;
@@ -4510,6 +4784,49 @@ async function readGopRange(file, args) {
4510
4784
  };
4511
4785
  }
4512
4786
  /**
4787
+ * Read the fragments covering `[fromMs, toMs)` from `file`, by byte range —
4788
+ * the WINDOW twin of {@link readGopRange}'s single instant. Same shape, same
4789
+ * standalone-demuxable `ftyp`+`moov` head, same `null`-on-unparseable
4790
+ * contract; the only difference is the stop condition
4791
+ * ({@link fragmentsInWindow} instead of {@link fragmentRangeFor} /
4792
+ * {@link fragmentsWithinBudget}).
4793
+ */
4794
+ async function readGopWindowRange(file, args) {
4795
+ let bytesRead = 0;
4796
+ const read = async (offset, length) => {
4797
+ const chunk = await file.read(offset, length);
4798
+ bytesRead += chunk.length;
4799
+ return chunk;
4800
+ };
4801
+ const resolved = await resolveSegmentIndex(file, args.seededIndex, read);
4802
+ if (resolved === null) return null;
4803
+ const { head, init, videoIndex } = resolved;
4804
+ const fromOffsetMs = Math.max(0, args.fromMs - args.segmentStartMs);
4805
+ const toOffsetMs = Math.max(fromOffsetMs, args.toMs - args.segmentStartMs);
4806
+ const plan = fragmentsInWindow(videoIndex, msToTicks(fromOffsetMs, init.videoTimescale), msToTicks(toOffsetMs, init.videoTimescale), args.maxBytes);
4807
+ const { start, end } = plan;
4808
+ if (start <= 0 || end <= start || end > file.size) return null;
4809
+ const fragment = await read(start, end - start);
4810
+ const firstSampleIndex = plan.fragments[0].sampleIndex;
4811
+ const lastSampleIndex = plan.fragments[plan.fragments.length - 1].sampleIndex;
4812
+ const startTicks = videoIndex.samples[firstSampleIndex]?.timeTicks ?? 0;
4813
+ const nextTicks = videoIndex.samples[lastSampleIndex + 1]?.timeTicks;
4814
+ const gopStartOffsetMs = ticksToMs(startTicks, init.videoTimescale);
4815
+ const gopDurMs = nextTicks !== void 0 ? ticksToMs(nextTicks - startTicks, init.videoTimescale) : Math.max(1, args.segmentDurMs - gopStartOffsetMs);
4816
+ const data = new Uint8Array(head.length + fragment.length);
4817
+ data.set(head, 0);
4818
+ data.set(fragment, head.length);
4819
+ return {
4820
+ data,
4821
+ gopStartMs: args.segmentStartMs + gopStartOffsetMs,
4822
+ gopDurMs,
4823
+ bytesRead,
4824
+ reachesSegmentEnd: plan.reachesSegmentEnd,
4825
+ reachesWindowEnd: plan.reachesWindowEnd,
4826
+ oversizeFragment: plan.oversizeFragment
4827
+ };
4828
+ }
4829
+ /**
4513
4830
  * Fetch + parse the `mfra` tail, honouring `MfraParseError.needBytes` with ONE
4514
4831
  * widened retry. Null on anything else — bytes that are not a segment.
4515
4832
  */
@@ -4801,6 +5118,16 @@ var RANGE_MERGE_GAP_MS = 5e3;
4801
5118
  * hour but covers the requested epoch is hydrated too. Recorder segments are
4802
5119
  * currently much shorter; the margin keeps this boundary future-safe. */
4803
5120
  var LOCATE_HYDRATE_MARGIN_MS = 6e4;
5121
+ /**
5122
+ * Safety ceiling for one `readWindowBytes` answer — NOT the primary stop
5123
+ * condition (the requested `toMs` is; see `fragmentsInWindow` in
5124
+ * `mfra-index.ts`). Sized off `high`'s own bandwidth hint above
5125
+ * (`PROFILE_BANDWIDTH.high` = 4 Mbps = 500 000 B/s): a 30 s replay window at
5126
+ * that rate is ~15 MB, so 20 MB covers it with headroom while still bounding
5127
+ * a pathological request. A window this call actually truncates is reported
5128
+ * (`reachesRequestedEnd: false`) rather than silently shortened.
5129
+ */
5130
+ var WINDOW_READ_MAX_BYTES = 2e7;
4804
5131
  /** Empty "no playback" manifest. */
4805
5132
  function noPlayback(deviceId) {
4806
5133
  return {
@@ -5318,6 +5645,76 @@ function buildRecordingProvider(deps) {
5318
5645
  gopDurMs: seg.durMs
5319
5646
  };
5320
5647
  },
5648
+ readWindowBytes: async ({ deviceId, profile, startMs, fromMs, toMs }) => {
5649
+ const { abs, seg } = resolveSegmentFile(deps, deviceId, profile, startMs);
5650
+ const segmentEndMs = seg.startMs + seg.durMs;
5651
+ if (toMs > segmentEndMs) {
5652
+ deps.logger.warn("recording: readWindowBytes refused — window runs past this segment, not silently clipped", {
5653
+ tags: { deviceId },
5654
+ meta: {
5655
+ profile,
5656
+ startMs,
5657
+ fromMs,
5658
+ toMs,
5659
+ segmentEndMs
5660
+ }
5661
+ });
5662
+ return {
5663
+ kind: "spans-multiple-segments",
5664
+ segmentEndMs
5665
+ };
5666
+ }
5667
+ const file = await (deps.openRangeReadable ?? openRangeFromDisk)(abs, seg.bytes);
5668
+ try {
5669
+ const gop = await readGopWindowRange(file, {
5670
+ segmentStartMs: seg.startMs,
5671
+ segmentDurMs: seg.durMs,
5672
+ fromMs,
5673
+ toMs,
5674
+ seededIndex: deps.mfraFor?.(deviceId, profile, startMs),
5675
+ maxBytes: WINDOW_READ_MAX_BYTES
5676
+ });
5677
+ if (gop !== null) {
5678
+ if (gop.reachesWindowEnd !== true) deps.logger.warn("recording: readWindowBytes truncated by its safety byte cap before covering the requested window", {
5679
+ tags: { deviceId },
5680
+ meta: {
5681
+ profile,
5682
+ startMs,
5683
+ fromMs,
5684
+ toMs,
5685
+ bytes: gop.data.length,
5686
+ WINDOW_READ_MAX_BYTES
5687
+ }
5688
+ });
5689
+ return {
5690
+ kind: "ok",
5691
+ data: gop.data,
5692
+ gopStartMs: gop.gopStartMs,
5693
+ gopDurMs: gop.gopDurMs,
5694
+ reachesRequestedEnd: gop.reachesWindowEnd === true
5695
+ };
5696
+ }
5697
+ } finally {
5698
+ await file.close();
5699
+ }
5700
+ deps.logger.warn("recording: readWindowBytes fell back to the WHOLE segment (mfra/moov unparseable)", {
5701
+ tags: { deviceId },
5702
+ meta: {
5703
+ profile,
5704
+ startMs,
5705
+ fromMs,
5706
+ toMs
5707
+ }
5708
+ });
5709
+ const data = await readFile(abs);
5710
+ return {
5711
+ kind: "ok",
5712
+ data: Uint8Array.from(data),
5713
+ gopStartMs: seg.startMs,
5714
+ gopDurMs: seg.durMs,
5715
+ reachesRequestedEnd: true
5716
+ };
5717
+ },
5321
5718
  setDeviceConfig: async ({ deviceId, config }) => {
5322
5719
  const saved = await saveDeviceConfig(deps.configStore, deviceId, config);
5323
5720
  if (deps.onConfigChanged) try {
@@ -7525,17 +7922,47 @@ function eventsBandCanEverDemand(band) {
7525
7922
  return bandTriggerSources(band).length > 0 && resolveBandBufferMs(band).postMs > 0;
7526
7923
  }
7527
7924
  /**
7925
+ * La chiave sotto cui una sorgente conserva le sue finestre. È anche la stringa
7926
+ * che compare nei log — quattro sorgenti che aprono la stessa finestra senza
7927
+ * dire chi è stato rendono impossibile rispondere a "perché questa camera
7928
+ * registra tanto" — quindi è leggibile per progetto.
7929
+ */
7930
+ function triggerKey(source) {
7931
+ switch (source.kind) {
7932
+ case "motion": return "motion";
7933
+ case "audio": return "audio";
7934
+ case "object": return `object:${source.macroClass}`;
7935
+ case "sensor": return `sensor:${source.sourceDeviceId}`;
7936
+ }
7937
+ }
7938
+ /**
7528
7939
  * LE sorgenti che questa banda ascolta — l'unico posto dove i `triggers` di
7529
- * una banda diventano `TriggerSource[]`. `eventsBandCanEverDemand` e
7530
- * `RecordingController.reportUnrecordable` lo leggono invece di ripetere
7531
- * l'if-ladder motion/audio a mano.
7940
+ * una banda diventano `TriggerSource[]`.
7941
+ *
7942
+ * Prima erano CINQUE copie dello stesso if-ladder motion/audio
7943
+ * (`eventsBandCanEverDemand`, `eventsBandDemanded`, `segmentRetainedForEvents`,
7944
+ * `segmentMissedByMs`, `eventsWindowCloseMs`), che con quattro sorgenti
7945
+ * sarebbero diventate cinque scale a quattro rami. `RecordingController`
7946
+ * (`reportUnrecordable`, la riconciliazione durevole) legge la stessa lista.
7947
+ *
7948
+ * Una chiave in {@link UNFED_TRIGGER_KEYS} non produce sorgenti: la banda che
7949
+ * ascolta solo quella "non può mai chiedere", ed è così che il recorder la
7950
+ * denuncia invece di restare zitto.
7532
7951
  */
7533
7952
  function bandTriggerSources(band) {
7534
7953
  const t = band.triggers;
7535
7954
  if (t === void 0) return [];
7536
7955
  const sources = [];
7537
- if (t.motion === true) sources.push("motion");
7538
- if (t.audioThresholdDbfs !== void 0) sources.push("audio");
7956
+ if (t.motion === true) sources.push({ kind: "motion" });
7957
+ if (t.audioThresholdDbfs !== void 0) sources.push({ kind: "audio" });
7958
+ for (const macroClass of t.objectClasses ?? []) sources.push({
7959
+ kind: "object",
7960
+ macroClass
7961
+ });
7962
+ for (const sourceDeviceId of t.sensorDeviceIds ?? []) sources.push({
7963
+ kind: "sensor",
7964
+ sourceDeviceId
7965
+ });
7539
7966
  return sources;
7540
7967
  }
7541
7968
  /**
@@ -7564,10 +7991,7 @@ function audioLevelQualifies(band, dbfs) {
7564
7991
  return threshold !== void 0 && dbfs >= threshold;
7565
7992
  }
7566
7993
  /** No trigger has been seen for this device yet. */
7567
- var EMPTY_TRIGGER_STATE = {
7568
- motion: [],
7569
- audio: []
7570
- };
7994
+ var EMPTY_TRIGGER_STATE = { windows: /* @__PURE__ */ new Map() };
7571
7995
  /**
7572
7996
  * How long a window is retained PAST the end of its own keep span
7573
7997
  * (`lastMs + postBufferSec`).
@@ -7579,18 +8003,21 @@ var EMPTY_TRIGGER_STATE = {
7579
8003
  * room to spare, while bounding a busy camera to a handful of windows.
7580
8004
  */
7581
8005
  var TRIGGER_HISTORY_MARGIN_MS = 12e4;
7582
- /** The windows for a source, in the order they opened. */
7583
- function windowsFor(state, source) {
7584
- return source === "motion" ? state.motion : state.audio;
7585
- }
8006
+ /** The windows for a source, in the order they opened. Empty when the source
8007
+ * has never fired — an absent key and a source with no history are the same
8008
+ * fact, and only one of them is ever stored. */
8009
+ function triggerWindowsFor(state, source) {
8010
+ return state.windows.get(triggerKey(source)) ?? [];
8011
+ }
8012
+ /** Replace one source's windows. An EMPTY list deletes the key rather than
8013
+ * storing a zero-length array: that is what bounds the map when an operator
8014
+ * removes a sensor from a band. */
7586
8015
  function withWindows(state, source, windows) {
7587
- return source === "motion" ? {
7588
- ...state,
7589
- motion: windows
7590
- } : {
7591
- ...state,
7592
- audio: windows
7593
- };
8016
+ const next = new Map(state.windows);
8017
+ const key = triggerKey(source);
8018
+ if (windows.length === 0) next.delete(key);
8019
+ else next.set(key, windows);
8020
+ return { windows: next };
7594
8021
  }
7595
8022
  /**
7596
8023
  * Fold one qualifying trigger into the history, immutably. It EXTENDS the newest
@@ -7602,7 +8029,7 @@ function withWindows(state, source, windows) {
7602
8029
  * predates the episode, so the keep span can only ever grow.
7603
8030
  */
7604
8031
  function recordTrigger(state, source, atMs, mergeGapMs) {
7605
- const windows = windowsFor(state, source);
8032
+ const windows = triggerWindowsFor(state, source);
7606
8033
  const newest = windows[windows.length - 1];
7607
8034
  const merged = newest !== void 0 && atMs - newest.lastMs <= mergeGapMs && atMs >= newest.firstMs - mergeGapMs ? [...windows.slice(0, -1), {
7608
8035
  firstMs: Math.min(newest.firstMs, atMs),
@@ -7625,18 +8052,18 @@ function recordTrigger(state, source, atMs, mergeGapMs) {
7625
8052
  */
7626
8053
  function pruneTriggerHistory(state, postMs, nowMs) {
7627
8054
  const cutoffMs = nowMs - postMs - TRIGGER_HISTORY_MARGIN_MS;
7628
- const live = (w) => w.lastMs >= cutoffMs;
7629
- const motion = state.motion.filter(live);
7630
- const audio = state.audio.filter(live);
7631
- if (motion.length === state.motion.length && audio.length === state.audio.length) return state;
7632
- return {
7633
- motion,
7634
- audio
7635
- };
8055
+ const next = /* @__PURE__ */ new Map();
8056
+ let changed = false;
8057
+ for (const [key, windows] of state.windows) {
8058
+ const live = windows.filter((w) => w.lastMs >= cutoffMs);
8059
+ if (live.length !== windows.length) changed = true;
8060
+ if (live.length > 0) next.set(key, live);
8061
+ }
8062
+ return changed ? { windows: next } : state;
7636
8063
  }
7637
8064
  /** Most recent trigger for a source, or null when none is retained. */
7638
8065
  function latestTriggerMs(state, source) {
7639
- const windows = windowsFor(state, source);
8066
+ const windows = triggerWindowsFor(state, source);
7640
8067
  return windows[windows.length - 1]?.lastMs ?? null;
7641
8068
  }
7642
8069
  /**
@@ -7646,10 +8073,10 @@ function latestTriggerMs(state, source) {
7646
8073
  */
7647
8074
  function eventsBandDemanded(band, triggers, atMs) {
7648
8075
  const { postMs } = resolveBandBufferMs(band);
7649
- const fresh = (lastMs) => lastMs != null && atMs - lastMs <= postMs;
7650
- if (band.triggers?.motion && fresh(latestTriggerMs(triggers, "motion"))) return true;
7651
- if (band.triggers?.audioThresholdDbfs != null && fresh(latestTriggerMs(triggers, "audio"))) return true;
7652
- return false;
8076
+ return bandTriggerSources(band).some((source) => {
8077
+ const lastMs = latestTriggerMs(triggers, source);
8078
+ return lastMs !== null && atMs - lastMs <= postMs;
8079
+ });
7653
8080
  }
7654
8081
  /**
7655
8082
  * Should recording (any mode) be active for this device at `at`? The live
@@ -7720,9 +8147,7 @@ function segmentRetainedForEvents(segStartMs, segEndMs, band, triggers, attach)
7720
8147
  if (segStartMs <= window.lastMs + postMs) return true;
7721
8148
  return attach.attachedAtMs <= window.lastMs + postMs && segStartMs <= deadlineMs;
7722
8149
  };
7723
- if (band.triggers?.motion && triggers.motion.some(overlaps)) return true;
7724
- if (band.triggers?.audioThresholdDbfs != null && triggers.audio.some(overlaps)) return true;
7725
- return false;
8150
+ return bandTriggerSources(band).some((source) => triggerWindowsFor(triggers, source).some(overlaps));
7726
8151
  }
7727
8152
  /**
7728
8153
  * By how many ms a discarded segment missed being kept — its start past the
@@ -7744,7 +8169,7 @@ function segmentMissedByMs(segStartMs, band, triggers, attach) {
7744
8169
  const windowEndMs = window.lastMs + postMs;
7745
8170
  return attach.attachedAtMs <= windowEndMs ? Math.max(windowEndMs, deadlineMs) : windowEndMs;
7746
8171
  };
7747
- const considered = [...band.triggers?.motion === true ? triggers.motion : [], ...band.triggers?.audioThresholdDbfs != null ? triggers.audio : []];
8172
+ const considered = bandTriggerSources(band).flatMap((source) => [...triggerWindowsFor(triggers, source)]);
7748
8173
  if (considered.length === 0) return null;
7749
8174
  return segStartMs - Math.max(...considered.map(latestKeptStart));
7750
8175
  }
@@ -7778,10 +8203,10 @@ function foldDurableTriggers(state, source, timestamps, mergeGapMs) {
7778
8203
  let recovered = 0;
7779
8204
  let evicted = 0;
7780
8205
  for (const atMs of rows) {
7781
- const before = windowsFor(next, source);
8206
+ const before = triggerWindowsFor(next, source);
7782
8207
  const folded = recordTrigger(next, source, atMs, mergeGapMs);
7783
8208
  evicted += folded.evicted;
7784
- const after = windowsFor(folded.state, source);
8209
+ const after = triggerWindowsFor(folded.state, source);
7785
8210
  if (folded.evicted > 0 || after.length !== before.length || after.some((w, i) => w.firstMs !== before[i]?.firstMs || w.lastMs !== before[i]?.lastMs)) recovered++;
7786
8211
  next = folded.state;
7787
8212
  }
@@ -7928,12 +8353,7 @@ function nextBandEdgeMs(config, nowMs) {
7928
8353
  function eventsWindowCloseMs(band, triggers, nowMs) {
7929
8354
  if (band.mode !== "events") return null;
7930
8355
  const { postMs } = resolveBandBufferMs(band);
7931
- const ends = [];
7932
- const motionMs = latestTriggerMs(triggers, "motion");
7933
- const audioMs = latestTriggerMs(triggers, "audio");
7934
- if (band.triggers?.motion === true && motionMs != null) ends.push(motionMs + postMs);
7935
- if (band.triggers?.audioThresholdDbfs != null && audioMs != null) ends.push(audioMs + postMs);
7936
- const open = ends.filter((ms) => ms >= nowMs);
8356
+ const open = bandTriggerSources(band).map((source) => latestTriggerMs(triggers, source)).filter((ms) => ms !== null).map((ms) => ms + postMs).filter((ms) => ms >= nowMs);
7937
8357
  if (open.length === 0) return null;
7938
8358
  return Math.max(...open) + 1;
7939
8359
  }
@@ -9199,44 +9619,78 @@ var RecordingController = class {
9199
9619
  * of it is discarded and the footage is gone for ever — durable state resting
9200
9620
  * on telemetry, which D8/D11 forbid.
9201
9621
  *
9202
- * Motion only. Audio triggers have no durable counterpart the recorder can
9203
- * ask for at the same granularity, and a reconcile that silently covered one
9204
- * source and not the other would read as covering both.
9622
+ * ## Which sources it covers, and which it does NOT
9623
+ *
9624
+ * MOTION and OBJECT-DETECTION. Both have a durable counterpart at the same
9625
+ * granularity the bus carries — `pipelineAnalytics.getMotionEvents` and
9626
+ * `getObjectEvents` — so a dropped event is recoverable from rows.
9627
+ *
9628
+ * AUDIO and SENSOR are NOT covered, and that is a stated gap, not an
9629
+ * oversight:
9630
+ * - audio has no per-window durable row the recorder can ask for;
9631
+ * - `SensorEvent` rows exist but are LEVEL, not EDGE (a contact closing
9632
+ * writes a row exactly like one opening), and they carry the taxonomy
9633
+ * `kind`, not the `capName` that `SOURCE_CAP_ACTIVE_FIELD` is indexed by.
9634
+ * The inversion was MEASURED on 2026-08-27 rather than eyeballed:
9635
+ * `CAP_TO_KIND` (`types/capabilities/sensor-event-kinds.ts`) has no two
9636
+ * caps sharing a kind, so `kind → capName` is invertible — but `binary`,
9637
+ * a source cap, has NO entry in it at all, so a `binary` sensor's edges
9638
+ * never reach the `SensorEvent` table and could never be recovered. A
9639
+ * reconcile covering nine source caps out of ten reads as covering all
9640
+ * ten, which is the failure this whole comment exists to avoid.
9641
+ *
9642
+ * A reconcile that silently covered some sources and not others would read as
9643
+ * covering all of them, so it is named here and the un-covered sources are
9644
+ * simply skipped — never faked.
9205
9645
  *
9206
9646
  * Never throws: this runs at the head of the reconcile beat and an escape
9207
9647
  * here would cancel the evaluate for the whole device.
9208
9648
  */
9209
9649
  async reconcileTriggerHistory(deviceId) {
9210
- const load = this.deps.loadMotionEvents;
9211
- if (load === void 0 || this.stopped) return;
9650
+ const loadMotion = this.deps.loadMotionEvents;
9651
+ const loadObject = this.deps.loadObjectEvents;
9652
+ if (loadMotion === void 0 && loadObject === void 0 || this.stopped) return;
9212
9653
  try {
9213
9654
  const config = await this.deps.loadConfig(deviceId);
9214
9655
  if (config.enabled !== true) return;
9215
9656
  const nowMs = this.now();
9216
9657
  const band = activeBandAt({ bands: config.bands ?? [] }, new Date(nowMs));
9217
- if (!band || band.mode !== "events" || band.triggers?.motion !== true) return;
9658
+ if (!band || band.mode !== "events") return;
9218
9659
  const { postMs } = resolveBandBufferMs(band);
9219
9660
  const sinceMs = nowMs - postMs - TRIGGER_HISTORY_MARGIN_MS;
9220
- const rows = await load(deviceId, sinceMs, nowMs);
9221
- if (this.stopped || rows.length === 0) return;
9222
- const folded = foldDurableTriggers(pruneTriggerHistory(this.triggersFor(deviceId), postMs, this.now()), "motion", rows, postMs);
9223
- this.triggers.set(deviceId, folded.state);
9224
- if (folded.recovered > 0) this.deps.logger.info("recorder: trigger windows recovered from the durable rows the bus had not delivered them", {
9225
- tags: { deviceId },
9226
- meta: {
9227
- rows: rows.length,
9228
- recovered: folded.recovered,
9229
- sinceMs,
9230
- postMs
9231
- }
9232
- });
9233
- if (folded.evicted > 0) this.deps.logger.warn("recorder: trigger history full during reconcile — oldest window(s) evicted", {
9234
- tags: { deviceId },
9235
- meta: {
9236
- evicted: folded.evicted,
9237
- maxWindows: 32
9238
- }
9239
- });
9661
+ /** Durable rows for one source, or null when that source has no durable
9662
+ * half (audio, sensor) or its loader was not injected. */
9663
+ const durableRowsFor = async (source) => {
9664
+ if (source.kind === "motion") return loadMotion === void 0 ? null : await loadMotion(deviceId, sinceMs, nowMs);
9665
+ if (source.kind === "object") return loadObject === void 0 ? null : await loadObject(deviceId, source.macroClass, sinceMs, nowMs);
9666
+ return null;
9667
+ };
9668
+ for (const source of bandTriggerSources(band)) {
9669
+ if (this.stopped) return;
9670
+ const rows = await durableRowsFor(source);
9671
+ if (rows === null || rows.length === 0) continue;
9672
+ if (this.stopped) return;
9673
+ const folded = foldDurableTriggers(pruneTriggerHistory(this.triggersFor(deviceId), postMs, this.now()), source, rows, postMs);
9674
+ this.triggers.set(deviceId, folded.state);
9675
+ if (folded.recovered > 0) this.deps.logger.info("recorder: trigger windows recovered from the durable rows — the bus had not delivered them", {
9676
+ tags: { deviceId },
9677
+ meta: {
9678
+ source: triggerKey(source),
9679
+ rows: rows.length,
9680
+ recovered: folded.recovered,
9681
+ sinceMs,
9682
+ postMs
9683
+ }
9684
+ });
9685
+ if (folded.evicted > 0) this.deps.logger.warn("recorder: trigger history full during reconcile — oldest window(s) evicted", {
9686
+ tags: { deviceId },
9687
+ meta: {
9688
+ source: triggerKey(source),
9689
+ evicted: folded.evicted,
9690
+ maxWindows: 32
9691
+ }
9692
+ });
9693
+ }
9240
9694
  } catch (err) {
9241
9695
  this.deps.logger.warn("recorder: trigger reconcile failed — retries on the next beat", {
9242
9696
  tags: { deviceId },
@@ -9403,7 +9857,7 @@ var RecordingController = class {
9403
9857
  if (receivedLagMs > TRIGGER_LAG_WARN_MS) this.deps.logger.warn("recorder: trigger arrived late — part of its window is already spent", {
9404
9858
  tags: { deviceId },
9405
9859
  meta: {
9406
- source,
9860
+ source: triggerKey(source),
9407
9861
  atMs,
9408
9862
  receivedLagMs
9409
9863
  }
@@ -9416,7 +9870,7 @@ var RecordingController = class {
9416
9870
  this.deps.logger.warn("recorder: trigger attach deferred — stream not available yet (retries on next broker ready/reconcile)", {
9417
9871
  tags: { deviceId },
9418
9872
  meta: {
9419
- source,
9873
+ source: triggerKey(source),
9420
9874
  atMs,
9421
9875
  error: require_dist.errMsg(err)
9422
9876
  }
@@ -9458,7 +9912,7 @@ var RecordingController = class {
9458
9912
  return;
9459
9913
  }
9460
9914
  if (!audioLevelQualifies(band, dbfs)) return;
9461
- await this.onTrigger(deviceId, "audio", atMs);
9915
+ await this.onTrigger(deviceId, { kind: "audio" }, atMs);
9462
9916
  }
9463
9917
  /**
9464
9918
  * The post-buffer that governs this device's trigger history right now: it is
@@ -9494,7 +9948,7 @@ var RecordingController = class {
9494
9948
  if (evicted > 0) this.deps.logger.warn("recorder: trigger history full — oldest window(s) evicted, their footage may be discarded", {
9495
9949
  tags: { deviceId },
9496
9950
  meta: {
9497
- source,
9951
+ source: triggerKey(source),
9498
9952
  atMs,
9499
9953
  postMs,
9500
9954
  evicted,
@@ -9939,14 +10393,10 @@ var RecordingController = class {
9939
10393
  profile,
9940
10394
  startMs,
9941
10395
  durMs,
9942
- motionWindows: triggers.motion.map((w) => ({
10396
+ triggerWindows: Object.fromEntries(bandTriggerSources(band).map((source) => [triggerKey(source), triggerWindowsFor(triggers, source).map((w) => ({
9943
10397
  firstMs: w.firstMs,
9944
10398
  lastMs: w.lastMs
9945
- })),
9946
- audioWindows: triggers.audio.map((w) => ({
9947
- firstMs: w.firstMs,
9948
- lastMs: w.lastMs
9949
- })),
10399
+ }))])),
9950
10400
  postMs,
9951
10401
  preMs,
9952
10402
  attachedAtMs: attach.attachedAtMs,
@@ -11637,6 +12087,28 @@ var RecorderV2Addon = class extends require_dist.BaseAddon {
11637
12087
  * down the same way, idempotent re-entry included.
11638
12088
  */
11639
12089
  audioTriggerUnsub = null;
12090
+ /**
12091
+ * Unsubscribe handle for the `PipelineAnalyticsDetectionEvent` subscription —
12092
+ * the feed behind `RecordingTriggers.objectClasses` (Traccia A of the same
12093
+ * plan). Same guard/teardown discipline as the two above.
12094
+ */
12095
+ detectionTriggerUnsub = null;
12096
+ /**
12097
+ * Unsubscribe handle for the `DeviceStateChanged` subscription — the feed
12098
+ * behind `RecordingTriggers.sensorDeviceIds` (Traccia B).
12099
+ */
12100
+ sensorTriggerUnsub = null;
12101
+ /**
12102
+ * Edge memory + reverse index (source device → cameras) for the sensor
12103
+ * trigger. Constructed once per addon instance: `startedAtMs` is the floor a
12104
+ * first-sighting rise must beat, and a fresh instance per boot is exactly
12105
+ * what makes a runner respawn err towards silence rather than record 30 s
12106
+ * per camera on every hydrated contact.
12107
+ */
12108
+ sensorRouter = new SensorTriggerRouter({
12109
+ now: () => Date.now(),
12110
+ startedAtMs: Date.now()
12111
+ });
11640
12112
  /** Resolved recordings locations (id + root). Refreshed on boot + rescan.
11641
12113
  * Already ALIAS-GUARDED: a writable same-type duplicate of another
11642
12114
  * location's physical root arrives here demoted to read-only. */
@@ -11852,6 +12324,29 @@ var RecorderV2Addon = class extends require_dist.BaseAddon {
11852
12324
  projection: "slim"
11853
12325
  })).map((event) => event.timestamp);
11854
12326
  },
12327
+ /**
12328
+ * The DURABLE half of the OBJECT source. Same shape as `loadMotionEvents`
12329
+ * — SQLite rows instead of a broadcast, one call per macro class because
12330
+ * `TriggerState` keys per class and the store filters server-side.
12331
+ *
12332
+ * `projection: 'full'` and NOT `'slim'`, deliberately: `SLIM_OBJECT_COLUMNS`
12333
+ * (`addon-post-analysis/.../store/event-store.ts`) does not carry `source`,
12334
+ * and the filter below reads an absent `source` as `pipeline` — so a slim
12335
+ * read would fold ONBOARD rows, re-opening from SQL exactly the windows
12336
+ * `qualifyObjectTrigger` refuses on the live path. Two truths about one
12337
+ * detection, which is what this whole track exists to avoid. The extra
12338
+ * cost is bounded: only bands that actually list `objectClasses` reach
12339
+ * here, `classFilter` narrows to one class, and `limit` caps the rows.
12340
+ */
12341
+ loadObjectEvents: async (deviceId, macroClass, sinceMs, untilMs) => {
12342
+ return (await this.ctx.api.pipelineAnalytics.getObjectEvents.query({
12343
+ deviceId,
12344
+ since: sinceMs,
12345
+ until: untilMs,
12346
+ classFilter: macroClass,
12347
+ limit: TRIGGER_RECONCILE_ROW_LIMIT
12348
+ })).filter((event) => (event.source ?? "pipeline") === "pipeline").map((event) => event.timestamp);
12349
+ },
11855
12350
  locations: () => this.resolvedLocations,
11856
12351
  placeProfile: (deviceId, profile) => this.placeProfile(deviceId, profile),
11857
12352
  onPlacementTick: (deviceIds) => this.recomputePlacement(deviceIds),
@@ -11919,7 +12414,10 @@ var RecorderV2Addon = class extends require_dist.BaseAddon {
11919
12414
  calendar: this.calendar,
11920
12415
  mfraFor: (deviceId, profile, startMs) => this.mfraTables.get(deviceId, profile, startMs),
11921
12416
  hydrateWindow: (deviceId, fromMs, toMs) => hydrateWindowFromStorage(this.index, deviceId, fromMs, toMs, this.resolvedLocations, this.ctx.logger),
11922
- onConfigChanged: (deviceId) => this.controller?.onConfigChanged(deviceId) ?? Promise.resolve(),
12417
+ onConfigChanged: async (deviceId) => {
12418
+ await this.refreshSensorTriggerBindings();
12419
+ await (this.controller?.onConfigChanged(deviceId) ?? Promise.resolve());
12420
+ },
11923
12421
  pauseForStorageMigration: (leaseId) => this.pauseForStorageMigration(leaseId),
11924
12422
  resumeForStorageMigration: (leaseId) => this.resumeForStorageMigration(leaseId),
11925
12423
  refreshStorageLocationsForMigration: (leaseId) => this.refreshStorageLocationsForMigration(leaseId),
@@ -12110,6 +12608,10 @@ var RecorderV2Addon = class extends require_dist.BaseAddon {
12110
12608
  this.eventUnsub = null;
12111
12609
  this.audioTriggerUnsub?.();
12112
12610
  this.audioTriggerUnsub = null;
12611
+ this.detectionTriggerUnsub?.();
12612
+ this.detectionTriggerUnsub = null;
12613
+ this.sensorTriggerUnsub?.();
12614
+ this.sensorTriggerUnsub = null;
12113
12615
  await this.controller?.stop();
12114
12616
  this.controller = null;
12115
12617
  this.segmentStore = null;
@@ -12181,7 +12683,7 @@ var RecorderV2Addon = class extends require_dist.BaseAddon {
12181
12683
  padPreMs: MARKER_PAD_PRE_MS,
12182
12684
  padPostMs: MARKER_PAD_POST_MS,
12183
12685
  onMotion: (deviceId, atMs) => {
12184
- this.controller?.onTrigger(deviceId, "motion", atMs);
12686
+ this.controller?.onTrigger(deviceId, { kind: "motion" }, atMs);
12185
12687
  }
12186
12688
  });
12187
12689
  if (this.audioTriggerUnsub === null) this.audioTriggerUnsub = this.ctx.eventBus.subscribe({ category: require_dist.EventCategory.PipelineAudioInferenceResult }, (event) => {
@@ -12189,6 +12691,48 @@ var RecorderV2Addon = class extends require_dist.BaseAddon {
12189
12691
  if (dbfs === void 0) return;
12190
12692
  this.controller?.onAudioLevel(event.data.deviceId, dbfs, event.data.frame.timestamp);
12191
12693
  });
12694
+ if (this.detectionTriggerUnsub === null) this.detectionTriggerUnsub = this.ctx.eventBus.subscribe({ category: require_dist.EventCategory.PipelineAnalyticsDetectionEvent }, (event) => {
12695
+ const verdict = qualifyObjectTrigger(event.data);
12696
+ if (!verdict.qualified) {
12697
+ this.ctx.logger.debug("recorder: detection event did not qualify as a trigger", { meta: { reason: verdict.reason } });
12698
+ return;
12699
+ }
12700
+ this.controller?.onTrigger(verdict.deviceId, {
12701
+ kind: "object",
12702
+ macroClass: verdict.macroClass
12703
+ }, verdict.atMs);
12704
+ });
12705
+ if (this.sensorTriggerUnsub === null) {
12706
+ await this.refreshSensorTriggerBindings();
12707
+ this.sensorTriggerUnsub = this.ctx.eventBus.subscribe({ category: require_dist.EventCategory.DeviceStateChanged }, (event) => {
12708
+ const parsed = parseSensorStateChanged(event.data);
12709
+ if (parsed === null) return;
12710
+ const atMs = event.timestamp instanceof Date ? event.timestamp.getTime() : Date.now();
12711
+ const result = this.sensorRouter.onStateSlice(parsed.deviceId, parsed.capName, parsed.slice);
12712
+ for (const cameraId of result.fired) {
12713
+ this.ctx.logger.info("recorder: sensor rising edge opened a recording window", {
12714
+ tags: { deviceId: cameraId },
12715
+ meta: {
12716
+ sourceDeviceId: parsed.deviceId,
12717
+ capName: parsed.capName
12718
+ }
12719
+ });
12720
+ this.controller?.onTrigger(cameraId, {
12721
+ kind: "sensor",
12722
+ sourceDeviceId: parsed.deviceId
12723
+ }, atMs);
12724
+ }
12725
+ if (result.skipped === null || result.skipped === "not-listened") return;
12726
+ const level = result.skipped === "baseline-seeded-stale-active" ? "warn" : "debug";
12727
+ this.ctx.logger[level]("recorder: sensor state change opened no window", {
12728
+ tags: { deviceId: parsed.deviceId },
12729
+ meta: {
12730
+ capName: parsed.capName,
12731
+ reason: result.skipped
12732
+ }
12733
+ });
12734
+ });
12735
+ }
12192
12736
  try {
12193
12737
  await this.segmentHours?.load();
12194
12738
  this.segmentHours?.hydrateIndex(this.index);
@@ -12202,6 +12746,7 @@ var RecorderV2Addon = class extends require_dist.BaseAddon {
12202
12746
  this.ctx.logger.warn("recorder: reading persisted device configs failed — skipping hydrate", { meta: { error: require_dist.errMsg(err) } });
12203
12747
  return;
12204
12748
  }
12749
+ this.applySensorTriggerBindings(configs);
12205
12750
  this.warmToday([...configs.keys()]);
12206
12751
  this.ctx.logger.info("recorder: full-archive index walk skipped", { meta: {
12207
12752
  reason: "on-demand-windows",
@@ -12220,6 +12765,39 @@ var RecorderV2Addon = class extends require_dist.BaseAddon {
12220
12765
  this.retentionSweeper?.start();
12221
12766
  }
12222
12767
  /**
12768
+ * Rebuild the sensor trigger's reverse index from the persisted configs.
12769
+ *
12770
+ * D49: this is a MIRROR refreshed off the event path — at boot and on every
12771
+ * config write (an RPC that cannot be dropped), never from a fallible read
12772
+ * while a `DeviceStateChanged` is being routed.
12773
+ *
12774
+ * A failed read leaves the PREVIOUS index standing and says so. An index that
12775
+ * emptied itself on a transient store failure would answer "nobody listens"
12776
+ * to every sensor edge until the next config write — silently, and that is
12777
+ * precisely the "a read that fails changes nothing" half of D49.
12778
+ */
12779
+ async refreshSensorTriggerBindings() {
12780
+ try {
12781
+ this.applySensorTriggerBindings(await readDeviceConfigs(this.configStore()));
12782
+ } catch (err) {
12783
+ this.ctx.logger.warn("recorder: sensor trigger index NOT refreshed — config read failed, the previous index stands", { meta: { error: require_dist.errMsg(err) } });
12784
+ }
12785
+ }
12786
+ /** Apply an already-read config map to the sensor index. Logs only when the
12787
+ * set of listened sources CHANGED — a config write that touches something
12788
+ * else must not print a line that reads as a rewiring. */
12789
+ applySensorTriggerBindings(configs) {
12790
+ const before = [...this.sensorRouter.listenedSourceIds()].toSorted((a, b) => a - b);
12791
+ const bindings = buildSensorTriggerBindings(configs);
12792
+ this.sensorRouter.setBindings(bindings);
12793
+ const after = [...bindings.keys()].toSorted((a, b) => a - b);
12794
+ if (before.length === after.length && before.every((id, i) => id === after[i])) return;
12795
+ this.ctx.logger.info("recorder: sensor trigger index rebuilt", { meta: {
12796
+ sourceDeviceIds: after,
12797
+ cameras: configs.size
12798
+ } });
12799
+ }
12800
+ /**
12223
12801
  * Resume interrupted exports and arm the expiry sweep.
12224
12802
  *
12225
12803
  * The resume itself lives in `export-reconcile.ts` — pure, tested, and loud