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