@camstack/addon-pipeline 1.2.132 → 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.
- package/dist/{addon-utils-DzCn6z7v.js → addon-utils-FV2O25id.js} +1 -1
- package/dist/audio-analyzer/index.js +3 -3
- package/dist/audio-analyzer/index.mjs +2 -2
- package/dist/detection-pipeline/index.js +5 -5
- package/dist/detection-pipeline/index.mjs +3 -3
- package/dist/{dist-BZYUUBUx.mjs → dist-7Uc6NdFm.mjs} +653 -65
- package/dist/{dist-DBP47QOz.js → dist-DsnZUYTs.js} +670 -64
- package/dist/{event-loop-stall-monitor-CiH_MHfO.js → event-loop-stall-monitor-D86J6d2c.js} +1 -1
- package/dist/{event-loop-stall-monitor-IACE22fK.mjs → event-loop-stall-monitor-XzDMVU9d.mjs} +1 -1
- package/dist/{lazy-sharp-B_kETjyQ.js → lazy-sharp-UzEUL79V.js} +1 -1
- package/dist/motion-wasm/index.js +2 -2
- package/dist/motion-wasm/index.mjs +1 -1
- package/dist/pipeline-runner/index.js +4 -4
- package/dist/pipeline-runner/index.mjs +3 -3
- package/dist/{process-memory-C7rsraCR.mjs → process-memory-Ci90-zF_.mjs} +1 -1
- package/dist/{process-memory-CtXQAA3d.js → process-memory-Co59RQIK.js} +1 -1
- package/dist/recorder/index.js +446 -86
- package/dist/recorder/index.mjs +445 -85
- package/dist/{segment-demux-js-BL1UDdiI.mjs → segment-demux-js-0NZ9TXro.mjs} +1 -1
- package/dist/{segment-demux-js-wTzFi5UW.js → segment-demux-js-DKcc0bxA.js} +1 -1
- package/dist/session-decode/decode-worker-child.js +195 -13
- package/dist/session-decode/decode-worker-child.mjs +194 -12
- package/dist/stream-broker/_stub.js +2 -2
- package/dist/stream-broker/{_virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-DlKJZ-_M.mjs → _virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-Dqj2f3H7.mjs} +3 -3
- 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
- package/dist/stream-broker/{_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_ui_mf_2_library__loadShare__.js-BUw_aTfK.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
- package/dist/stream-broker/demux-worker-child.js +1 -1
- package/dist/stream-broker/demux-worker-child.mjs +1 -1
- package/dist/stream-broker/{hostInit-kDKgfUjp.mjs → hostInit-DnwyBL4I.mjs} +3 -3
- package/dist/stream-broker/index.js +2 -2
- package/dist/stream-broker/index.mjs +2 -2
- package/dist/stream-broker/remoteEntry.js +1 -1
- package/dist/{worker-protocol-Cut8984B.mjs → worker-protocol-CMDUAqvX.mjs} +12 -6
- package/dist/{worker-protocol-C3zaz0Jv.js → worker-protocol-DzRUHIcM.js} +12 -6
- package/package.json +1 -1
- package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-DXKTNEae.mjs +0 -26
package/dist/recorder/index.mjs
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
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();
|
|
@@ -7741,17 +7920,47 @@ function eventsBandCanEverDemand(band) {
|
|
|
7741
7920
|
return bandTriggerSources(band).length > 0 && resolveBandBufferMs(band).postMs > 0;
|
|
7742
7921
|
}
|
|
7743
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
|
+
/**
|
|
7744
7937
|
* LE sorgenti che questa banda ascolta — l'unico posto dove i `triggers` di
|
|
7745
|
-
* una banda diventano `TriggerSource[]`.
|
|
7746
|
-
*
|
|
7747
|
-
*
|
|
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.
|
|
7748
7949
|
*/
|
|
7749
7950
|
function bandTriggerSources(band) {
|
|
7750
7951
|
const t = band.triggers;
|
|
7751
7952
|
if (t === void 0) return [];
|
|
7752
7953
|
const sources = [];
|
|
7753
|
-
if (t.motion === true) sources.push("motion");
|
|
7754
|
-
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
|
+
});
|
|
7755
7964
|
return sources;
|
|
7756
7965
|
}
|
|
7757
7966
|
/**
|
|
@@ -7780,10 +7989,7 @@ function audioLevelQualifies(band, dbfs) {
|
|
|
7780
7989
|
return threshold !== void 0 && dbfs >= threshold;
|
|
7781
7990
|
}
|
|
7782
7991
|
/** No trigger has been seen for this device yet. */
|
|
7783
|
-
var EMPTY_TRIGGER_STATE = {
|
|
7784
|
-
motion: [],
|
|
7785
|
-
audio: []
|
|
7786
|
-
};
|
|
7992
|
+
var EMPTY_TRIGGER_STATE = { windows: /* @__PURE__ */ new Map() };
|
|
7787
7993
|
/**
|
|
7788
7994
|
* How long a window is retained PAST the end of its own keep span
|
|
7789
7995
|
* (`lastMs + postBufferSec`).
|
|
@@ -7795,18 +8001,21 @@ var EMPTY_TRIGGER_STATE = {
|
|
|
7795
8001
|
* room to spare, while bounding a busy camera to a handful of windows.
|
|
7796
8002
|
*/
|
|
7797
8003
|
var TRIGGER_HISTORY_MARGIN_MS = 12e4;
|
|
7798
|
-
/** The windows for a source, in the order they opened.
|
|
7799
|
-
|
|
7800
|
-
|
|
7801
|
-
|
|
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. */
|
|
7802
8013
|
function withWindows(state, source, windows) {
|
|
7803
|
-
|
|
7804
|
-
|
|
7805
|
-
|
|
7806
|
-
|
|
7807
|
-
|
|
7808
|
-
audio: windows
|
|
7809
|
-
};
|
|
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 };
|
|
7810
8019
|
}
|
|
7811
8020
|
/**
|
|
7812
8021
|
* Fold one qualifying trigger into the history, immutably. It EXTENDS the newest
|
|
@@ -7818,7 +8027,7 @@ function withWindows(state, source, windows) {
|
|
|
7818
8027
|
* predates the episode, so the keep span can only ever grow.
|
|
7819
8028
|
*/
|
|
7820
8029
|
function recordTrigger(state, source, atMs, mergeGapMs) {
|
|
7821
|
-
const windows =
|
|
8030
|
+
const windows = triggerWindowsFor(state, source);
|
|
7822
8031
|
const newest = windows[windows.length - 1];
|
|
7823
8032
|
const merged = newest !== void 0 && atMs - newest.lastMs <= mergeGapMs && atMs >= newest.firstMs - mergeGapMs ? [...windows.slice(0, -1), {
|
|
7824
8033
|
firstMs: Math.min(newest.firstMs, atMs),
|
|
@@ -7841,18 +8050,18 @@ function recordTrigger(state, source, atMs, mergeGapMs) {
|
|
|
7841
8050
|
*/
|
|
7842
8051
|
function pruneTriggerHistory(state, postMs, nowMs) {
|
|
7843
8052
|
const cutoffMs = nowMs - postMs - TRIGGER_HISTORY_MARGIN_MS;
|
|
7844
|
-
const
|
|
7845
|
-
|
|
7846
|
-
const
|
|
7847
|
-
|
|
7848
|
-
|
|
7849
|
-
|
|
7850
|
-
|
|
7851
|
-
};
|
|
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;
|
|
7852
8061
|
}
|
|
7853
8062
|
/** Most recent trigger for a source, or null when none is retained. */
|
|
7854
8063
|
function latestTriggerMs(state, source) {
|
|
7855
|
-
const windows =
|
|
8064
|
+
const windows = triggerWindowsFor(state, source);
|
|
7856
8065
|
return windows[windows.length - 1]?.lastMs ?? null;
|
|
7857
8066
|
}
|
|
7858
8067
|
/**
|
|
@@ -7862,10 +8071,10 @@ function latestTriggerMs(state, source) {
|
|
|
7862
8071
|
*/
|
|
7863
8072
|
function eventsBandDemanded(band, triggers, atMs) {
|
|
7864
8073
|
const { postMs } = resolveBandBufferMs(band);
|
|
7865
|
-
|
|
7866
|
-
|
|
7867
|
-
|
|
7868
|
-
|
|
8074
|
+
return bandTriggerSources(band).some((source) => {
|
|
8075
|
+
const lastMs = latestTriggerMs(triggers, source);
|
|
8076
|
+
return lastMs !== null && atMs - lastMs <= postMs;
|
|
8077
|
+
});
|
|
7869
8078
|
}
|
|
7870
8079
|
/**
|
|
7871
8080
|
* Should recording (any mode) be active for this device at `at`? The live
|
|
@@ -7936,9 +8145,7 @@ function segmentRetainedForEvents(segStartMs, segEndMs, band, triggers, attach)
|
|
|
7936
8145
|
if (segStartMs <= window.lastMs + postMs) return true;
|
|
7937
8146
|
return attach.attachedAtMs <= window.lastMs + postMs && segStartMs <= deadlineMs;
|
|
7938
8147
|
};
|
|
7939
|
-
|
|
7940
|
-
if (band.triggers?.audioThresholdDbfs != null && triggers.audio.some(overlaps)) return true;
|
|
7941
|
-
return false;
|
|
8148
|
+
return bandTriggerSources(band).some((source) => triggerWindowsFor(triggers, source).some(overlaps));
|
|
7942
8149
|
}
|
|
7943
8150
|
/**
|
|
7944
8151
|
* By how many ms a discarded segment missed being kept — its start past the
|
|
@@ -7960,7 +8167,7 @@ function segmentMissedByMs(segStartMs, band, triggers, attach) {
|
|
|
7960
8167
|
const windowEndMs = window.lastMs + postMs;
|
|
7961
8168
|
return attach.attachedAtMs <= windowEndMs ? Math.max(windowEndMs, deadlineMs) : windowEndMs;
|
|
7962
8169
|
};
|
|
7963
|
-
const considered =
|
|
8170
|
+
const considered = bandTriggerSources(band).flatMap((source) => [...triggerWindowsFor(triggers, source)]);
|
|
7964
8171
|
if (considered.length === 0) return null;
|
|
7965
8172
|
return segStartMs - Math.max(...considered.map(latestKeptStart));
|
|
7966
8173
|
}
|
|
@@ -7994,10 +8201,10 @@ function foldDurableTriggers(state, source, timestamps, mergeGapMs) {
|
|
|
7994
8201
|
let recovered = 0;
|
|
7995
8202
|
let evicted = 0;
|
|
7996
8203
|
for (const atMs of rows) {
|
|
7997
|
-
const before =
|
|
8204
|
+
const before = triggerWindowsFor(next, source);
|
|
7998
8205
|
const folded = recordTrigger(next, source, atMs, mergeGapMs);
|
|
7999
8206
|
evicted += folded.evicted;
|
|
8000
|
-
const after =
|
|
8207
|
+
const after = triggerWindowsFor(folded.state, source);
|
|
8001
8208
|
if (folded.evicted > 0 || after.length !== before.length || after.some((w, i) => w.firstMs !== before[i]?.firstMs || w.lastMs !== before[i]?.lastMs)) recovered++;
|
|
8002
8209
|
next = folded.state;
|
|
8003
8210
|
}
|
|
@@ -8144,12 +8351,7 @@ function nextBandEdgeMs(config, nowMs) {
|
|
|
8144
8351
|
function eventsWindowCloseMs(band, triggers, nowMs) {
|
|
8145
8352
|
if (band.mode !== "events") return null;
|
|
8146
8353
|
const { postMs } = resolveBandBufferMs(band);
|
|
8147
|
-
const
|
|
8148
|
-
const motionMs = latestTriggerMs(triggers, "motion");
|
|
8149
|
-
const audioMs = latestTriggerMs(triggers, "audio");
|
|
8150
|
-
if (band.triggers?.motion === true && motionMs != null) ends.push(motionMs + postMs);
|
|
8151
|
-
if (band.triggers?.audioThresholdDbfs != null && audioMs != null) ends.push(audioMs + postMs);
|
|
8152
|
-
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);
|
|
8153
8355
|
if (open.length === 0) return null;
|
|
8154
8356
|
return Math.max(...open) + 1;
|
|
8155
8357
|
}
|
|
@@ -9415,44 +9617,78 @@ var RecordingController = class {
|
|
|
9415
9617
|
* of it is discarded and the footage is gone for ever — durable state resting
|
|
9416
9618
|
* on telemetry, which D8/D11 forbid.
|
|
9417
9619
|
*
|
|
9418
|
-
*
|
|
9419
|
-
*
|
|
9420
|
-
*
|
|
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.
|
|
9421
9643
|
*
|
|
9422
9644
|
* Never throws: this runs at the head of the reconcile beat and an escape
|
|
9423
9645
|
* here would cancel the evaluate for the whole device.
|
|
9424
9646
|
*/
|
|
9425
9647
|
async reconcileTriggerHistory(deviceId) {
|
|
9426
|
-
const
|
|
9427
|
-
|
|
9648
|
+
const loadMotion = this.deps.loadMotionEvents;
|
|
9649
|
+
const loadObject = this.deps.loadObjectEvents;
|
|
9650
|
+
if (loadMotion === void 0 && loadObject === void 0 || this.stopped) return;
|
|
9428
9651
|
try {
|
|
9429
9652
|
const config = await this.deps.loadConfig(deviceId);
|
|
9430
9653
|
if (config.enabled !== true) return;
|
|
9431
9654
|
const nowMs = this.now();
|
|
9432
9655
|
const band = activeBandAt({ bands: config.bands ?? [] }, new Date(nowMs));
|
|
9433
|
-
if (!band || band.mode !== "events"
|
|
9656
|
+
if (!band || band.mode !== "events") return;
|
|
9434
9657
|
const { postMs } = resolveBandBufferMs(band);
|
|
9435
9658
|
const sinceMs = nowMs - postMs - TRIGGER_HISTORY_MARGIN_MS;
|
|
9436
|
-
|
|
9437
|
-
|
|
9438
|
-
const
|
|
9439
|
-
|
|
9440
|
-
|
|
9441
|
-
|
|
9442
|
-
|
|
9443
|
-
|
|
9444
|
-
|
|
9445
|
-
|
|
9446
|
-
|
|
9447
|
-
|
|
9448
|
-
|
|
9449
|
-
|
|
9450
|
-
|
|
9451
|
-
|
|
9452
|
-
|
|
9453
|
-
|
|
9454
|
-
|
|
9455
|
-
|
|
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
|
+
}
|
|
9456
9692
|
} catch (err) {
|
|
9457
9693
|
this.deps.logger.warn("recorder: trigger reconcile failed — retries on the next beat", {
|
|
9458
9694
|
tags: { deviceId },
|
|
@@ -9619,7 +9855,7 @@ var RecordingController = class {
|
|
|
9619
9855
|
if (receivedLagMs > TRIGGER_LAG_WARN_MS) this.deps.logger.warn("recorder: trigger arrived late — part of its window is already spent", {
|
|
9620
9856
|
tags: { deviceId },
|
|
9621
9857
|
meta: {
|
|
9622
|
-
source,
|
|
9858
|
+
source: triggerKey(source),
|
|
9623
9859
|
atMs,
|
|
9624
9860
|
receivedLagMs
|
|
9625
9861
|
}
|
|
@@ -9632,7 +9868,7 @@ var RecordingController = class {
|
|
|
9632
9868
|
this.deps.logger.warn("recorder: trigger attach deferred — stream not available yet (retries on next broker ready/reconcile)", {
|
|
9633
9869
|
tags: { deviceId },
|
|
9634
9870
|
meta: {
|
|
9635
|
-
source,
|
|
9871
|
+
source: triggerKey(source),
|
|
9636
9872
|
atMs,
|
|
9637
9873
|
error: errMsg(err)
|
|
9638
9874
|
}
|
|
@@ -9674,7 +9910,7 @@ var RecordingController = class {
|
|
|
9674
9910
|
return;
|
|
9675
9911
|
}
|
|
9676
9912
|
if (!audioLevelQualifies(band, dbfs)) return;
|
|
9677
|
-
await this.onTrigger(deviceId, "audio", atMs);
|
|
9913
|
+
await this.onTrigger(deviceId, { kind: "audio" }, atMs);
|
|
9678
9914
|
}
|
|
9679
9915
|
/**
|
|
9680
9916
|
* The post-buffer that governs this device's trigger history right now: it is
|
|
@@ -9710,7 +9946,7 @@ var RecordingController = class {
|
|
|
9710
9946
|
if (evicted > 0) this.deps.logger.warn("recorder: trigger history full — oldest window(s) evicted, their footage may be discarded", {
|
|
9711
9947
|
tags: { deviceId },
|
|
9712
9948
|
meta: {
|
|
9713
|
-
source,
|
|
9949
|
+
source: triggerKey(source),
|
|
9714
9950
|
atMs,
|
|
9715
9951
|
postMs,
|
|
9716
9952
|
evicted,
|
|
@@ -10155,14 +10391,10 @@ var RecordingController = class {
|
|
|
10155
10391
|
profile,
|
|
10156
10392
|
startMs,
|
|
10157
10393
|
durMs,
|
|
10158
|
-
|
|
10159
|
-
firstMs: w.firstMs,
|
|
10160
|
-
lastMs: w.lastMs
|
|
10161
|
-
})),
|
|
10162
|
-
audioWindows: triggers.audio.map((w) => ({
|
|
10394
|
+
triggerWindows: Object.fromEntries(bandTriggerSources(band).map((source) => [triggerKey(source), triggerWindowsFor(triggers, source).map((w) => ({
|
|
10163
10395
|
firstMs: w.firstMs,
|
|
10164
10396
|
lastMs: w.lastMs
|
|
10165
|
-
})),
|
|
10397
|
+
}))])),
|
|
10166
10398
|
postMs,
|
|
10167
10399
|
preMs,
|
|
10168
10400
|
attachedAtMs: attach.attachedAtMs,
|
|
@@ -11853,6 +12085,28 @@ var RecorderV2Addon = class extends BaseAddon {
|
|
|
11853
12085
|
* down the same way, idempotent re-entry included.
|
|
11854
12086
|
*/
|
|
11855
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
|
+
});
|
|
11856
12110
|
/** Resolved recordings locations (id + root). Refreshed on boot + rescan.
|
|
11857
12111
|
* Already ALIAS-GUARDED: a writable same-type duplicate of another
|
|
11858
12112
|
* location's physical root arrives here demoted to read-only. */
|
|
@@ -12068,6 +12322,29 @@ var RecorderV2Addon = class extends BaseAddon {
|
|
|
12068
12322
|
projection: "slim"
|
|
12069
12323
|
})).map((event) => event.timestamp);
|
|
12070
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
|
+
},
|
|
12071
12348
|
locations: () => this.resolvedLocations,
|
|
12072
12349
|
placeProfile: (deviceId, profile) => this.placeProfile(deviceId, profile),
|
|
12073
12350
|
onPlacementTick: (deviceIds) => this.recomputePlacement(deviceIds),
|
|
@@ -12135,7 +12412,10 @@ var RecorderV2Addon = class extends BaseAddon {
|
|
|
12135
12412
|
calendar: this.calendar,
|
|
12136
12413
|
mfraFor: (deviceId, profile, startMs) => this.mfraTables.get(deviceId, profile, startMs),
|
|
12137
12414
|
hydrateWindow: (deviceId, fromMs, toMs) => hydrateWindowFromStorage(this.index, deviceId, fromMs, toMs, this.resolvedLocations, this.ctx.logger),
|
|
12138
|
-
onConfigChanged: (deviceId) =>
|
|
12415
|
+
onConfigChanged: async (deviceId) => {
|
|
12416
|
+
await this.refreshSensorTriggerBindings();
|
|
12417
|
+
await (this.controller?.onConfigChanged(deviceId) ?? Promise.resolve());
|
|
12418
|
+
},
|
|
12139
12419
|
pauseForStorageMigration: (leaseId) => this.pauseForStorageMigration(leaseId),
|
|
12140
12420
|
resumeForStorageMigration: (leaseId) => this.resumeForStorageMigration(leaseId),
|
|
12141
12421
|
refreshStorageLocationsForMigration: (leaseId) => this.refreshStorageLocationsForMigration(leaseId),
|
|
@@ -12326,6 +12606,10 @@ var RecorderV2Addon = class extends BaseAddon {
|
|
|
12326
12606
|
this.eventUnsub = null;
|
|
12327
12607
|
this.audioTriggerUnsub?.();
|
|
12328
12608
|
this.audioTriggerUnsub = null;
|
|
12609
|
+
this.detectionTriggerUnsub?.();
|
|
12610
|
+
this.detectionTriggerUnsub = null;
|
|
12611
|
+
this.sensorTriggerUnsub?.();
|
|
12612
|
+
this.sensorTriggerUnsub = null;
|
|
12329
12613
|
await this.controller?.stop();
|
|
12330
12614
|
this.controller = null;
|
|
12331
12615
|
this.segmentStore = null;
|
|
@@ -12397,7 +12681,7 @@ var RecorderV2Addon = class extends BaseAddon {
|
|
|
12397
12681
|
padPreMs: MARKER_PAD_PRE_MS,
|
|
12398
12682
|
padPostMs: MARKER_PAD_POST_MS,
|
|
12399
12683
|
onMotion: (deviceId, atMs) => {
|
|
12400
|
-
this.controller?.onTrigger(deviceId, "motion", atMs);
|
|
12684
|
+
this.controller?.onTrigger(deviceId, { kind: "motion" }, atMs);
|
|
12401
12685
|
}
|
|
12402
12686
|
});
|
|
12403
12687
|
if (this.audioTriggerUnsub === null) this.audioTriggerUnsub = this.ctx.eventBus.subscribe({ category: EventCategory.PipelineAudioInferenceResult }, (event) => {
|
|
@@ -12405,6 +12689,48 @@ var RecorderV2Addon = class extends BaseAddon {
|
|
|
12405
12689
|
if (dbfs === void 0) return;
|
|
12406
12690
|
this.controller?.onAudioLevel(event.data.deviceId, dbfs, event.data.frame.timestamp);
|
|
12407
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
|
+
}
|
|
12408
12734
|
try {
|
|
12409
12735
|
await this.segmentHours?.load();
|
|
12410
12736
|
this.segmentHours?.hydrateIndex(this.index);
|
|
@@ -12418,6 +12744,7 @@ var RecorderV2Addon = class extends BaseAddon {
|
|
|
12418
12744
|
this.ctx.logger.warn("recorder: reading persisted device configs failed — skipping hydrate", { meta: { error: errMsg(err) } });
|
|
12419
12745
|
return;
|
|
12420
12746
|
}
|
|
12747
|
+
this.applySensorTriggerBindings(configs);
|
|
12421
12748
|
this.warmToday([...configs.keys()]);
|
|
12422
12749
|
this.ctx.logger.info("recorder: full-archive index walk skipped", { meta: {
|
|
12423
12750
|
reason: "on-demand-windows",
|
|
@@ -12436,6 +12763,39 @@ var RecorderV2Addon = class extends BaseAddon {
|
|
|
12436
12763
|
this.retentionSweeper?.start();
|
|
12437
12764
|
}
|
|
12438
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
|
+
/**
|
|
12439
12799
|
* Resume interrupted exports and arm the expiry sweep.
|
|
12440
12800
|
*
|
|
12441
12801
|
* The resume itself lives in `export-reconcile.ts` — pure, tested, and loud
|