@camstack/addon-post-analysis 0.1.1

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 (80) hide show
  1. package/dist/embedding-encoder/index.js +928 -0
  2. package/dist/embedding-encoder/index.js.map +1 -0
  3. package/dist/embedding-encoder/index.mjs +888 -0
  4. package/dist/embedding-encoder/index.mjs.map +1 -0
  5. package/dist/enrichment-engine/index.js +774 -0
  6. package/dist/enrichment-engine/index.js.map +1 -0
  7. package/dist/enrichment-engine/index.mjs +774 -0
  8. package/dist/enrichment-engine/index.mjs.map +1 -0
  9. package/dist/ffmpeg-config-DRONlBsj.mjs +56 -0
  10. package/dist/ffmpeg-config-DRONlBsj.mjs.map +1 -0
  11. package/dist/ffmpeg-config-uANz3sV5.js +73 -0
  12. package/dist/ffmpeg-config-uANz3sV5.js.map +1 -0
  13. package/dist/pipeline-analytics/@mf-types/compiled-types/pipeline-analytics/widgets/AudioHistoryChart.d.ts +4 -0
  14. package/dist/pipeline-analytics/@mf-types/compiled-types/pipeline-analytics/widgets/AudioMetricsPanel.d.ts +10 -0
  15. package/dist/pipeline-analytics/@mf-types/compiled-types/pipeline-analytics/widgets/DetectionHistoryChart.d.ts +4 -0
  16. package/dist/pipeline-analytics/@mf-types/compiled-types/pipeline-analytics/widgets/MotionHistoryChart.d.ts +4 -0
  17. package/dist/pipeline-analytics/@mf-types/compiled-types/pipeline-analytics/widgets/OccupancyHistoryChart.d.ts +4 -0
  18. package/dist/pipeline-analytics/@mf-types/compiled-types/pipeline-analytics/widgets/OccupancyPanel.d.ts +10 -0
  19. package/dist/pipeline-analytics/@mf-types/compiled-types/pipeline-analytics/widgets/chart-utils.d.ts +97 -0
  20. package/dist/pipeline-analytics/@mf-types/compiled-types/pipeline-analytics/widgets/index.d.ts +27 -0
  21. package/dist/pipeline-analytics/@mf-types/widgets.d.ts +2 -0
  22. package/dist/pipeline-analytics/@mf-types.d.ts +3 -0
  23. package/dist/pipeline-analytics/@mf-types.zip +0 -0
  24. package/dist/pipeline-analytics/__mfe_internal__addon_pipeline_analytics_widgets__loadShare___mf_0_camstack_mf_1_sdk__loadShare__.mjs-h5aXOPSA.mjs +12 -0
  25. package/dist/pipeline-analytics/__mfe_internal__addon_pipeline_analytics_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.mjs-46iNwbxa.mjs +16 -0
  26. package/dist/pipeline-analytics/__mfe_internal__addon_pipeline_analytics_widgets__loadShare___mf_0_camstack_mf_1_ui_mf_2_library__loadShare__.mjs-Db6yh4op.mjs +15 -0
  27. package/dist/pipeline-analytics/__mfe_internal__addon_pipeline_analytics_widgets__loadShare___mf_0_tanstack_mf_1_react_mf_2_query__loadShare__.mjs-DoWbefqS.mjs +104 -0
  28. package/dist/pipeline-analytics/__mfe_internal__addon_pipeline_analytics_widgets__loadShare___mf_0_trpc_mf_1_client__loadShare__.mjs-52bfkwC8.mjs +85 -0
  29. package/dist/pipeline-analytics/__mfe_internal__addon_pipeline_analytics_widgets__loadShare___mf_0_trpc_mf_1_react_mf_2_query__loadShare__.mjs-CVrnrGED.mjs +62 -0
  30. package/dist/pipeline-analytics/__mfe_internal__addon_pipeline_analytics_widgets__loadShare__react__loadShare__.mjs-DuO9h7li.mjs +85 -0
  31. package/dist/pipeline-analytics/__mfe_internal__addon_pipeline_analytics_widgets__loadShare__react__loadShare__.mjs_commonjs-proxy-CmqNjq44.mjs +29 -0
  32. package/dist/pipeline-analytics/__mfe_internal__addon_pipeline_analytics_widgets__loadShare__react_mf_1_jsx_mf_2_runtime__loadShare__.mjs-BsyrX6NO.mjs +36 -0
  33. package/dist/pipeline-analytics/__mfe_internal__addon_pipeline_analytics_widgets__loadShare__react_mf_2_dom__loadShare__.mjs-Dp8hqYOB.mjs +45 -0
  34. package/dist/pipeline-analytics/__mfe_internal__addon_pipeline_analytics_widgets__loadShare__react_mf_2_dom__loadShare__.mjs_commonjs-proxy-CA8cCIEl.mjs +6 -0
  35. package/dist/pipeline-analytics/__mfe_internal__addon_pipeline_analytics_widgets__loadShare__react_mf_2_dom_mf_1_client__loadShare__.mjs-BZjEt71l.mjs +34 -0
  36. package/dist/pipeline-analytics/_stub.js +1397 -0
  37. package/dist/pipeline-analytics/_virtual_mf-localSharedImportMap___mfe_internal__addon_pipeline_analytics_widgets-Bfo-or09.mjs +156 -0
  38. package/dist/pipeline-analytics/client-DdXDZxzK.mjs +10063 -0
  39. package/dist/pipeline-analytics/getErrorShape-BPSzUA7W-TlK8ipWe.mjs +211 -0
  40. package/dist/pipeline-analytics/hostInit-CqUKNfOr.mjs +168 -0
  41. package/dist/pipeline-analytics/index-B4OKsa9p.mjs +2603 -0
  42. package/dist/pipeline-analytics/index-B7r1koiV.mjs +19109 -0
  43. package/dist/pipeline-analytics/index-CRN37Hr9.mjs +15195 -0
  44. package/dist/pipeline-analytics/index-DRx99znz.mjs +1617 -0
  45. package/dist/pipeline-analytics/index-DyYvUfc7.mjs +725 -0
  46. package/dist/pipeline-analytics/index-k0CA0h_r.mjs +185 -0
  47. package/dist/pipeline-analytics/index-kIgjN-uq.mjs +435 -0
  48. package/dist/pipeline-analytics/index-xncRG7-x.mjs +2713 -0
  49. package/dist/pipeline-analytics/index.js +2563 -0
  50. package/dist/pipeline-analytics/index.js.map +1 -0
  51. package/dist/pipeline-analytics/index.mjs +2564 -0
  52. package/dist/pipeline-analytics/index.mjs.map +1 -0
  53. package/dist/pipeline-analytics/jsx-runtime-4ro1c69i.mjs +55 -0
  54. package/dist/pipeline-analytics/remoteEntry.js +2973 -0
  55. package/dist/pipeline-analytics/virtualExposes-8FzWTdq3.mjs +42 -0
  56. package/dist/playlist-generator-EhPaB7Hn.js +48 -0
  57. package/dist/playlist-generator-EhPaB7Hn.js.map +1 -0
  58. package/dist/playlist-generator-VTkgn53O.mjs +48 -0
  59. package/dist/playlist-generator-VTkgn53O.mjs.map +1 -0
  60. package/dist/recording/index.js +257 -0
  61. package/dist/recording/index.js.map +1 -0
  62. package/dist/recording/index.mjs +235 -0
  63. package/dist/recording/index.mjs.map +1 -0
  64. package/dist/recording-coordinator-C2sATEhe.js +1052 -0
  65. package/dist/recording-coordinator-C2sATEhe.js.map +1 -0
  66. package/dist/recording-coordinator-DuP3BUTV.mjs +1012 -0
  67. package/dist/recording-coordinator-DuP3BUTV.mjs.map +1 -0
  68. package/dist/recording-db-gOgaoQh0.js +348 -0
  69. package/dist/recording-db-gOgaoQh0.js.map +1 -0
  70. package/dist/recording-db-lIkSMTLq.mjs +348 -0
  71. package/dist/recording-db-lIkSMTLq.mjs.map +1 -0
  72. package/dist/recording-service-facade-B9lG6OFn.mjs +123 -0
  73. package/dist/recording-service-facade-B9lG6OFn.mjs.map +1 -0
  74. package/dist/recording-service-facade-Do1PKlAL.js +123 -0
  75. package/dist/recording-service-facade-Do1PKlAL.js.map +1 -0
  76. package/dist/storage-estimator-CRpoQc9j.js +72 -0
  77. package/dist/storage-estimator-CRpoQc9j.js.map +1 -0
  78. package/dist/storage-estimator-DzD8gWJH.mjs +72 -0
  79. package/dist/storage-estimator-DzD8gWJH.mjs.map +1 -0
  80. package/package.json +148 -0
@@ -0,0 +1,2563 @@
1
+ "use strict";
2
+ const types = require("@camstack/types");
3
+ const node_crypto = require("node:crypto");
4
+ function pointInPolygon(point, polygon) {
5
+ if (polygon.length < 3) return false;
6
+ let inside = false;
7
+ for (let i = 0, j = polygon.length - 1; i < polygon.length; j = i++) {
8
+ const xi = polygon[i].x, yi = polygon[i].y;
9
+ const xj = polygon[j].x, yj = polygon[j].y;
10
+ const intersect = yi > point.y !== yj > point.y && point.x < (xj - xi) * (point.y - yi) / (yj - yi) + xi;
11
+ if (intersect) inside = !inside;
12
+ }
13
+ return inside;
14
+ }
15
+ function bboxCentroid(bbox) {
16
+ return { x: bbox.x + bbox.w / 2, y: bbox.y + bbox.h / 2 };
17
+ }
18
+ function normalizeToPixel(p, width, height) {
19
+ return { x: p.x * width, y: p.y * height };
20
+ }
21
+ const DEFAULT_TRACKER_CONFIG = {
22
+ iouThreshold: 0.3,
23
+ maxMissedFrames: 30,
24
+ minHits: 3
25
+ };
26
+ const MAX_PATH_LENGTH = 300;
27
+ let nextTrackId = 1;
28
+ function iou(a, b) {
29
+ const ax1 = a.x, ay1 = a.y, ax2 = a.x + a.w, ay2 = a.y + a.h;
30
+ const bx1 = b.x, by1 = b.y, bx2 = b.x + b.w, by2 = b.y + b.h;
31
+ const ix1 = Math.max(ax1, bx1), iy1 = Math.max(ay1, by1);
32
+ const ix2 = Math.min(ax2, bx2), iy2 = Math.min(ay2, by2);
33
+ const iw = Math.max(0, ix2 - ix1), ih = Math.max(0, iy2 - iy1);
34
+ const interArea = iw * ih;
35
+ const aArea = a.w * a.h;
36
+ const bArea = b.w * b.h;
37
+ const unionArea = aArea + bArea - interArea;
38
+ return unionArea > 0 ? interArea / unionArea : 0;
39
+ }
40
+ class SortTracker {
41
+ config;
42
+ tracks = [];
43
+ lostTracks = [];
44
+ constructor(config = {}) {
45
+ this.config = { ...DEFAULT_TRACKER_CONFIG, ...config };
46
+ }
47
+ update(detections, timestamp) {
48
+ const used = /* @__PURE__ */ new Set();
49
+ const matchedTracks = /* @__PURE__ */ new Set();
50
+ const matched = /* @__PURE__ */ new Map();
51
+ const pairs = [];
52
+ for (const track of this.tracks) {
53
+ for (let di = 0; di < detections.length; di++) {
54
+ const score = iou(track.bbox, detections[di].bbox);
55
+ if (score >= this.config.iouThreshold) {
56
+ pairs.push({ track, detIdx: di, score });
57
+ }
58
+ }
59
+ }
60
+ pairs.sort((a, b) => b.score - a.score);
61
+ for (const pair of pairs) {
62
+ if (matchedTracks.has(pair.track) || used.has(pair.detIdx)) continue;
63
+ matched.set(pair.track, detections[pair.detIdx]);
64
+ matchedTracks.add(pair.track);
65
+ used.add(pair.detIdx);
66
+ }
67
+ for (const [track, det] of matched) {
68
+ const prevCenter = bboxCentroid({ x: track.bbox.x, y: track.bbox.y, w: track.bbox.w, h: track.bbox.h });
69
+ const newCenter = bboxCentroid({ x: det.bbox.x, y: det.bbox.y, w: det.bbox.w, h: det.bbox.h });
70
+ track.bbox = det.bbox;
71
+ track.class = det.class;
72
+ track.originalClass = det.originalClass;
73
+ track.score = det.score;
74
+ track.age = 0;
75
+ track.hits++;
76
+ track.lastSeen = timestamp;
77
+ track.velocity = { dx: newCenter.x - prevCenter.x, dy: newCenter.y - prevCenter.y };
78
+ track.path.push(det.bbox);
79
+ if (track.path.length > MAX_PATH_LENGTH) track.path.shift();
80
+ }
81
+ const surviving = [];
82
+ for (const track of this.tracks) {
83
+ if (matchedTracks.has(track)) {
84
+ surviving.push(track);
85
+ } else {
86
+ track.age++;
87
+ if (track.age > this.config.maxMissedFrames) {
88
+ track.lost = true;
89
+ this.lostTracks.push(track);
90
+ } else {
91
+ surviving.push(track);
92
+ }
93
+ }
94
+ }
95
+ for (let di = 0; di < detections.length; di++) {
96
+ if (used.has(di)) continue;
97
+ const det = detections[di];
98
+ surviving.push({
99
+ id: `track-${nextTrackId++}`,
100
+ bbox: det.bbox,
101
+ class: det.class,
102
+ originalClass: det.originalClass,
103
+ score: det.score,
104
+ age: 0,
105
+ hits: 1,
106
+ path: [det.bbox],
107
+ firstSeen: timestamp,
108
+ lastSeen: timestamp,
109
+ velocity: { dx: 0, dy: 0 },
110
+ lost: false
111
+ });
112
+ }
113
+ this.tracks = surviving;
114
+ return this.tracks.filter((t) => t.hits >= this.config.minHits).map((t) => ({
115
+ class: t.class,
116
+ originalClass: t.originalClass,
117
+ score: t.score,
118
+ bbox: t.bbox,
119
+ trackId: t.id,
120
+ trackAge: t.hits,
121
+ velocity: t.velocity,
122
+ path: [...t.path]
123
+ }));
124
+ }
125
+ getActiveTracks() {
126
+ return this.tracks;
127
+ }
128
+ getLostTracks() {
129
+ return this.lostTracks;
130
+ }
131
+ reset() {
132
+ this.tracks = [];
133
+ this.lostTracks = [];
134
+ }
135
+ }
136
+ const DEFAULT_STATE_ANALYZER_CONFIG = {
137
+ stationaryThresholdSec: 10,
138
+ loiteringThresholdSec: 60,
139
+ velocityThreshold: 2,
140
+ enteringFrames: 5
141
+ };
142
+ class StateAnalyzer {
143
+ config;
144
+ states = /* @__PURE__ */ new Map();
145
+ constructor(config = {}) {
146
+ this.config = { ...DEFAULT_STATE_ANALYZER_CONFIG, ...config };
147
+ }
148
+ analyze(tracks, timestamp) {
149
+ const currentIds = new Set(tracks.map((t) => t.trackId));
150
+ const results = [];
151
+ for (const track of tracks) {
152
+ let ts = this.states.get(track.trackId);
153
+ const center = bboxCentroid({ x: track.bbox.x, y: track.bbox.y, w: track.bbox.w, h: track.bbox.h });
154
+ if (!ts) {
155
+ ts = {
156
+ enteredAt: timestamp,
157
+ stationarySince: void 0,
158
+ totalDistancePx: 0,
159
+ lastPosition: center,
160
+ frameCount: 1
161
+ };
162
+ this.states.set(track.trackId, ts);
163
+ } else {
164
+ const dx = center.x - ts.lastPosition.x;
165
+ const dy = center.y - ts.lastPosition.y;
166
+ const dist = Math.sqrt(dx * dx + dy * dy);
167
+ ts.totalDistancePx += dist;
168
+ ts.lastPosition = center;
169
+ ts.frameCount++;
170
+ }
171
+ const velocity = track.velocity ? Math.sqrt(track.velocity.dx ** 2 + track.velocity.dy ** 2) : 0;
172
+ const dwellTimeMs = timestamp - ts.enteredAt;
173
+ let state;
174
+ if (ts.frameCount <= this.config.enteringFrames) {
175
+ state = "entering";
176
+ } else if (velocity <= this.config.velocityThreshold) {
177
+ if (!ts.stationarySince) {
178
+ ts.stationarySince = timestamp;
179
+ }
180
+ const stationaryDurationSec = (timestamp - ts.stationarySince) / 1e3;
181
+ if (stationaryDurationSec >= this.config.loiteringThresholdSec) {
182
+ state = "loitering";
183
+ } else if (stationaryDurationSec >= this.config.stationaryThresholdSec) {
184
+ state = "stationary";
185
+ } else {
186
+ state = "moving";
187
+ }
188
+ } else {
189
+ ts.stationarySince = void 0;
190
+ state = "moving";
191
+ }
192
+ results.push({
193
+ trackId: track.trackId,
194
+ state,
195
+ stationarySince: ts.stationarySince,
196
+ enteredAt: ts.enteredAt,
197
+ totalDistancePx: ts.totalDistancePx,
198
+ dwellTimeMs
199
+ });
200
+ }
201
+ for (const [trackId, ts] of this.states) {
202
+ if (!currentIds.has(trackId)) {
203
+ results.push({
204
+ trackId,
205
+ state: "leaving",
206
+ stationarySince: ts.stationarySince,
207
+ enteredAt: ts.enteredAt,
208
+ totalDistancePx: ts.totalDistancePx,
209
+ dwellTimeMs: timestamp - ts.enteredAt
210
+ });
211
+ this.states.delete(trackId);
212
+ }
213
+ }
214
+ return results;
215
+ }
216
+ reset() {
217
+ this.states.clear();
218
+ }
219
+ }
220
+ const DEFAULT_EVENT_EMITTER_CONFIG = {
221
+ minTrackAge: 3,
222
+ cooldownSec: 5,
223
+ enabledTypes: [
224
+ "object.entering",
225
+ "object.leaving",
226
+ "object.stationary",
227
+ "object.loitering",
228
+ "zone.enter",
229
+ "zone.exit",
230
+ "tripwire.cross"
231
+ ]
232
+ };
233
+ const STATE_TO_EVENT = {
234
+ entering: "object.entering",
235
+ leaving: "object.leaving",
236
+ stationary: "object.stationary",
237
+ loitering: "object.loitering"
238
+ };
239
+ const ZONE_TYPE_TO_EVENT = {
240
+ "zone-enter": "zone.enter",
241
+ "zone-exit": "zone.exit",
242
+ "zone-loiter": "zone.enter",
243
+ "tripwire-cross": "tripwire.cross"
244
+ };
245
+ let eventIdCounter = 0;
246
+ class DetectionEventEmitter {
247
+ config;
248
+ previousStates = /* @__PURE__ */ new Map();
249
+ lastEmitted = /* @__PURE__ */ new Map();
250
+ constructor(config = {}) {
251
+ this.config = { ...DEFAULT_EVENT_EMITTER_CONFIG, ...config };
252
+ }
253
+ emit(tracks, states, zoneEvents, classifications, deviceId) {
254
+ const events = [];
255
+ const now = Date.now();
256
+ const stateMap = new Map(states.map((s) => [s.trackId, s]));
257
+ const trackMap = new Map(tracks.map((t) => [t.trackId, t]));
258
+ for (const state of states) {
259
+ const track = trackMap.get(state.trackId);
260
+ if (!track) continue;
261
+ if (track.trackAge < this.config.minTrackAge) continue;
262
+ const eventType = STATE_TO_EVENT[state.state];
263
+ if (!eventType) continue;
264
+ if (!this.config.enabledTypes.includes(eventType)) continue;
265
+ const prevState = this.previousStates.get(state.trackId);
266
+ if (prevState === state.state) continue;
267
+ const cooldownKey = `${state.trackId}:${eventType}`;
268
+ const lastTime = this.lastEmitted.get(cooldownKey) ?? 0;
269
+ if ((now - lastTime) / 1e3 < this.config.cooldownSec) continue;
270
+ this.previousStates.set(state.trackId, state.state);
271
+ this.lastEmitted.set(cooldownKey, now);
272
+ events.push({
273
+ id: `evt-${++eventIdCounter}`,
274
+ type: eventType,
275
+ timestamp: now,
276
+ deviceId,
277
+ detection: track,
278
+ classifications,
279
+ objectState: state,
280
+ zoneEvents: zoneEvents.filter((z) => z.trackId === state.trackId),
281
+ trackPath: [...track.path]
282
+ });
283
+ }
284
+ for (const ze of zoneEvents) {
285
+ const eventType = ZONE_TYPE_TO_EVENT[ze.type];
286
+ if (!eventType) continue;
287
+ if (!this.config.enabledTypes.includes(eventType)) continue;
288
+ const track = trackMap.get(ze.trackId);
289
+ if (!track || track.trackAge < this.config.minTrackAge) continue;
290
+ const state = stateMap.get(ze.trackId);
291
+ events.push({
292
+ id: `evt-${++eventIdCounter}`,
293
+ type: eventType,
294
+ timestamp: now,
295
+ deviceId,
296
+ detection: track,
297
+ classifications,
298
+ objectState: state ?? {
299
+ trackId: ze.trackId,
300
+ state: "moving",
301
+ enteredAt: now,
302
+ totalDistancePx: 0,
303
+ dwellTimeMs: 0
304
+ },
305
+ zoneEvents: [ze],
306
+ trackPath: [...track.path]
307
+ });
308
+ }
309
+ for (const state of states) {
310
+ if (state.state === "leaving") {
311
+ this.previousStates.delete(state.trackId);
312
+ }
313
+ }
314
+ return events;
315
+ }
316
+ reset() {
317
+ this.previousStates.clear();
318
+ this.lastEmitted.clear();
319
+ }
320
+ }
321
+ function bboxPolygonOverlap(bbox, polygon) {
322
+ const area = bbox.w * bbox.h;
323
+ if (area <= 0) return 0;
324
+ const gridSize = 8;
325
+ let inside = 0;
326
+ const total = gridSize * gridSize;
327
+ for (let row = 0; row < gridSize; row++) {
328
+ for (let col = 0; col < gridSize; col++) {
329
+ const px = bbox.x + (col + 0.5) * (bbox.w / gridSize);
330
+ const py = bbox.y + (row + 0.5) * (bbox.h / gridSize);
331
+ if (pointInPolygon({ x: px, y: py }, polygon)) {
332
+ inside++;
333
+ }
334
+ }
335
+ }
336
+ return inside / total;
337
+ }
338
+ function maskPolygonOverlap(mask, maskWidth, maskHeight, bbox, polygon, _frameWidth, _frameHeight) {
339
+ let totalMaskPixels = 0;
340
+ let insidePolygon = 0;
341
+ for (let my = 0; my < maskHeight; my++) {
342
+ for (let mx = 0; mx < maskWidth; mx++) {
343
+ if (mask[my * maskWidth + mx] === 0) continue;
344
+ totalMaskPixels++;
345
+ const frameX = bbox.x + mx / maskWidth * bbox.w;
346
+ const frameY = bbox.y + my / maskHeight * bbox.h;
347
+ if (pointInPolygon({ x: frameX, y: frameY }, polygon)) {
348
+ insidePolygon++;
349
+ }
350
+ }
351
+ }
352
+ if (totalMaskPixels === 0) return 0;
353
+ return insidePolygon / totalMaskPixels;
354
+ }
355
+ const DEFAULT_OVERLAP_THRESHOLD = 0.85;
356
+ function resolveRuleThreshold(rule) {
357
+ if (typeof rule.bboxInclusionPct === "number") return rule.bboxInclusionPct / 100;
358
+ if (typeof rule.overlapThreshold === "number") return rule.overlapThreshold;
359
+ return DEFAULT_OVERLAP_THRESHOLD;
360
+ }
361
+ class ZoneEngine {
362
+ /**
363
+ * Annotate a single detection with its zone memberships.
364
+ * Returns zones where the detection overlaps above any active
365
+ * rule's threshold (or the engine default if no rule sets one).
366
+ */
367
+ annotateDetection(bbox, zones, frameWidth, frameHeight, mask, maskWidth, maskHeight) {
368
+ const memberships = [];
369
+ for (const zone of zones) {
370
+ const pixelPolygon = zone.polygon.map((p) => normalizeToPixel(p, frameWidth, frameHeight));
371
+ const overlap = mask && maskWidth && maskHeight ? maskPolygonOverlap(mask, maskWidth, maskHeight, bbox, pixelPolygon) : bboxPolygonOverlap(bbox, pixelPolygon);
372
+ if (overlap >= DEFAULT_OVERLAP_THRESHOLD) {
373
+ memberships.push({ zoneId: zone.id, zoneName: zone.name, overlap });
374
+ }
375
+ }
376
+ return memberships;
377
+ }
378
+ /**
379
+ * Filter detections through a zone-rule set. `zones` provides the
380
+ * geometry catalogue; `rules` decides include / exclude behaviour.
381
+ * Pass an empty `rules` array to short-circuit (everything passes).
382
+ */
383
+ filterDetections(detections, zones, rules, frameWidth, frameHeight, getClassName, getMask) {
384
+ const annotations = /* @__PURE__ */ new Map();
385
+ if (rules.length === 0 || zones.length === 0) {
386
+ for (const det of detections) {
387
+ annotations.set(det, this.annotateDetection(
388
+ det,
389
+ zones,
390
+ frameWidth,
391
+ frameHeight
392
+ ));
393
+ }
394
+ return { passed: detections, excluded: [], annotations };
395
+ }
396
+ const zonesById = new Map(zones.map((z) => [z.id, z]));
397
+ const activeRules = rules.filter((r) => r.enabled !== false).map((rule) => ({
398
+ rule,
399
+ threshold: resolveRuleThreshold(rule),
400
+ zonesById
401
+ }));
402
+ const includeRules = activeRules.filter((r) => r.rule.mode === "include");
403
+ const excludeRules = activeRules.filter((r) => r.rule.mode === "exclude");
404
+ const whitelistMode = includeRules.length > 0;
405
+ const passed = [];
406
+ const excluded = [];
407
+ for (const det of detections) {
408
+ const memberships = this.annotateDetection(det, zones, frameWidth, frameHeight);
409
+ annotations.set(det, memberships);
410
+ const className = getClassName?.(det);
411
+ const maskInfo = getMask?.(det);
412
+ const inIncludeRule = includeRules.some(
413
+ (r) => ruleApplies(r, det, className, maskInfo, zones, frameWidth, frameHeight)
414
+ );
415
+ const inExcludeRule = excludeRules.some(
416
+ (r) => ruleApplies(r, det, className, maskInfo, zones, frameWidth, frameHeight)
417
+ );
418
+ if (whitelistMode) {
419
+ if (inIncludeRule && !inExcludeRule) {
420
+ passed.push(det);
421
+ } else {
422
+ excluded.push(det);
423
+ }
424
+ } else {
425
+ if (inExcludeRule) {
426
+ excluded.push(det);
427
+ } else {
428
+ passed.push(det);
429
+ }
430
+ }
431
+ }
432
+ return { passed, excluded, annotations };
433
+ }
434
+ }
435
+ function ruleApplies(resolved, det, className, maskInfo, _zones, frameWidth, frameHeight) {
436
+ const { rule, threshold, zonesById } = resolved;
437
+ if (rule.classFilter && rule.classFilter.length > 0) {
438
+ if (!className || !rule.classFilter.includes(className)) return false;
439
+ }
440
+ for (const zoneId of rule.zoneIds) {
441
+ const zone = zonesById.get(zoneId);
442
+ if (!zone) continue;
443
+ const pixelPolygon = zone.polygon.map((p) => normalizeToPixel(p, frameWidth, frameHeight));
444
+ const overlap = rule.preferMask && maskInfo ? maskPolygonOverlap(maskInfo.mask, maskInfo.width, maskInfo.height, det, pixelPolygon) : bboxPolygonOverlap(det, pixelPolygon);
445
+ if (overlap >= threshold) return true;
446
+ }
447
+ return false;
448
+ }
449
+ function mapObjectStateToTrackState(s) {
450
+ switch (s) {
451
+ case "entering":
452
+ return "entered";
453
+ case "leaving":
454
+ return "left";
455
+ case "stationary":
456
+ case "loitering":
457
+ return "idle";
458
+ case "moving":
459
+ return "moving";
460
+ default:
461
+ return "new";
462
+ }
463
+ }
464
+ class FrameProcessor {
465
+ deviceId;
466
+ tracker;
467
+ stateAnalyzer;
468
+ eventEmitter;
469
+ zones;
470
+ /**
471
+ * Detection-stage rules — applied as a safety-net post-pipeline
472
+ * filter. When motion-wasm + pipeline-executor land their own
473
+ * runtime gating (Phase 2b) the upstream pipeline already drops
474
+ * filtered detections; keeping the analytics-side filter as well
475
+ * ensures the same semantics for legacy paths and forked-worker
476
+ * runners that haven't picked up the new gating yet.
477
+ */
478
+ detectionRules;
479
+ zoneEngine = new ZoneEngine();
480
+ constructor(deviceId) {
481
+ this.deviceId = deviceId;
482
+ this.tracker = new SortTracker();
483
+ this.stateAnalyzer = new StateAnalyzer();
484
+ this.eventEmitter = new DetectionEventEmitter();
485
+ this.zones = [];
486
+ this.detectionRules = [];
487
+ }
488
+ setZones(zones) {
489
+ this.zones = zones;
490
+ }
491
+ setDetectionRules(rules) {
492
+ this.detectionRules = rules;
493
+ }
494
+ process(input) {
495
+ const { timestamp, frame } = input;
496
+ const frameWidth = frame.width;
497
+ const frameHeight = frame.height;
498
+ const flatDetections = frame.detections.filter((d) => d.kind === "first-level").map((det) => {
499
+ const bbox = {
500
+ x: det.bbox.x,
501
+ y: det.bbox.y,
502
+ w: det.bbox.width,
503
+ h: det.bbox.height
504
+ };
505
+ const detection = {
506
+ class: det.macroClass,
507
+ originalClass: det.debug?.originalClass ?? det.macroClass,
508
+ score: det.score,
509
+ bbox
510
+ };
511
+ return { ...bbox, detection, sourceId: det.id };
512
+ });
513
+ const { passed } = this.zoneEngine.filterDetections(
514
+ flatDetections,
515
+ this.zones,
516
+ this.detectionRules,
517
+ frameWidth,
518
+ frameHeight,
519
+ (fd) => fd.detection.class
520
+ );
521
+ const filteredDetections = passed.map((fd) => fd.detection);
522
+ const trackedDetections = this.tracker.update(filteredDetections, timestamp);
523
+ const objectStates = this.stateAnalyzer.analyze(trackedDetections, timestamp);
524
+ const rawEvents = this.eventEmitter.emit(
525
+ trackedDetections,
526
+ objectStates,
527
+ [],
528
+ [],
529
+ String(this.deviceId)
530
+ );
531
+ const zonesByTrack = /* @__PURE__ */ new Map();
532
+ for (const td of trackedDetections) {
533
+ const memberships = this.zoneEngine.annotateDetection(
534
+ td.bbox,
535
+ this.zones,
536
+ frameWidth,
537
+ frameHeight
538
+ );
539
+ zonesByTrack.set(td.trackId, memberships.map((m) => m.zoneId));
540
+ }
541
+ const tracked = trackedDetections.map((td) => {
542
+ const state = mapObjectStateToTrackState(
543
+ objectStates.find((o) => o.trackId === td.trackId)?.state
544
+ );
545
+ return {
546
+ trackId: td.trackId,
547
+ className: td.class,
548
+ confidence: td.score,
549
+ bbox: { ...td.bbox },
550
+ zones: zonesByTrack.get(td.trackId) ?? [],
551
+ state
552
+ };
553
+ });
554
+ const objectEvents = rawEvents.filter((e) => e.detection.trackId).map((e) => {
555
+ const td = trackedDetections.find((t) => t.trackId === e.detection.trackId);
556
+ const state = mapObjectStateToTrackState(
557
+ objectStates.find((o) => o.trackId === e.detection.trackId)?.state
558
+ );
559
+ const zones = zonesByTrack.get(e.detection.trackId) ?? [];
560
+ return {
561
+ id: node_crypto.randomUUID(),
562
+ deviceId: this.deviceId,
563
+ timestamp,
564
+ kind: "object",
565
+ trackId: e.detection.trackId,
566
+ className: e.detection.class,
567
+ confidence: e.detection.score,
568
+ bbox: td ? { ...td.bbox } : { x: 0, y: 0, w: 0, h: 0 },
569
+ zones,
570
+ state
571
+ };
572
+ });
573
+ return {
574
+ deviceId: this.deviceId,
575
+ timestamp,
576
+ frameWidth,
577
+ frameHeight,
578
+ tracked,
579
+ objectEvents,
580
+ rawTrackedDetections: trackedDetections
581
+ };
582
+ }
583
+ }
584
+ class BindingCache {
585
+ api;
586
+ logger;
587
+ capName;
588
+ state = /* @__PURE__ */ new Map();
589
+ inflight = /* @__PURE__ */ new Map();
590
+ constructor(deps) {
591
+ this.api = deps.api;
592
+ this.logger = deps.logger;
593
+ this.capName = deps.capName ?? "pipeline-analytics";
594
+ }
595
+ async isActive(deviceId) {
596
+ const cached = this.state.get(deviceId);
597
+ if (cached !== void 0) return cached;
598
+ const pending = this.inflight.get(deviceId);
599
+ if (pending) return pending;
600
+ const promise = (async () => {
601
+ try {
602
+ const res = await this.api.deviceManager.getBindings.query({ deviceId });
603
+ const active = res.entries.some((e) => e.capName === this.capName);
604
+ this.state.set(deviceId, active);
605
+ return active;
606
+ } catch (err) {
607
+ this.logger.debug("BindingCache.isActive lookup failed", {
608
+ tags: { deviceId },
609
+ meta: { error: String(err) }
610
+ });
611
+ return false;
612
+ } finally {
613
+ this.inflight.delete(deviceId);
614
+ }
615
+ })();
616
+ this.inflight.set(deviceId, promise);
617
+ return promise;
618
+ }
619
+ onBindingsChanged(event) {
620
+ if (event.capName !== this.capName) return;
621
+ if (event.reason === "wrapper-activated") {
622
+ this.state.set(event.deviceId, true);
623
+ return;
624
+ }
625
+ if (event.reason === "wrapper-deactivated") {
626
+ this.state.set(event.deviceId, false);
627
+ return;
628
+ }
629
+ this.state.delete(event.deviceId);
630
+ }
631
+ invalidate(deviceId) {
632
+ this.state.delete(deviceId);
633
+ }
634
+ clearAll() {
635
+ this.state.clear();
636
+ this.inflight.clear();
637
+ }
638
+ }
639
+ const DEFAULT_CONFIG = {
640
+ ttlMs: 3e4,
641
+ maxPositionHistory: 300
642
+ };
643
+ const TRACKS_COLLECTION = "pipeline-analytics:tracks";
644
+ const TRACKS_COLUMNS = [
645
+ { name: "id", type: "TEXT", primaryKey: true, notNull: true },
646
+ { name: "deviceId", type: "INTEGER", notNull: true },
647
+ { name: "className", type: "TEXT", notNull: true },
648
+ { name: "label", type: "TEXT" },
649
+ { name: "firstSeen", type: "INTEGER", notNull: true },
650
+ { name: "lastSeen", type: "INTEGER", notNull: true },
651
+ { name: "positions", type: "JSON" },
652
+ { name: "snapshots", type: "JSON" },
653
+ { name: "zonesVisited", type: "JSON" },
654
+ { name: "totalDistance", type: "REAL" },
655
+ { name: "state", type: "TEXT" }
656
+ ];
657
+ const TRACKS_INDEXES = [
658
+ { name: "idx_tracks_device_lastSeen", columns: ["deviceId", "lastSeen"] },
659
+ { name: "idx_tracks_device_firstSeen", columns: ["deviceId", "firstSeen"] }
660
+ ];
661
+ function cloneTrack(t) {
662
+ return {
663
+ trackId: t.trackId,
664
+ deviceId: t.deviceId,
665
+ className: t.className,
666
+ label: t.label,
667
+ firstSeen: t.firstSeen,
668
+ lastSeen: t.lastSeen,
669
+ positions: t.positions.map((p) => ({ ...p, bbox: { ...p.bbox } })),
670
+ snapshots: t.snapshots.map((s) => ({ ...s, position: { ...s.position, bbox: { ...s.position.bbox } } })),
671
+ zonesVisited: [...t.zonesVisited],
672
+ totalDistance: t.totalDistance,
673
+ state: t.state,
674
+ active: t.active
675
+ };
676
+ }
677
+ class TrackStore {
678
+ active = /* @__PURE__ */ new Map();
679
+ config;
680
+ logger;
681
+ store;
682
+ constructor(deps) {
683
+ this.logger = deps.logger;
684
+ this.store = deps.store;
685
+ this.config = { ...DEFAULT_CONFIG, ...deps.config };
686
+ }
687
+ /** One-time collection declaration. Call from addon onInitialize. */
688
+ static async declare(store) {
689
+ await store.declareCollection.mutate({
690
+ collection: TRACKS_COLLECTION,
691
+ columns: [...TRACKS_COLUMNS],
692
+ indexes: [...TRACKS_INDEXES]
693
+ });
694
+ }
695
+ /** Create or update the track record for a sighting in this frame. */
696
+ upsert(params) {
697
+ const existing = this.active.get(params.trackId);
698
+ if (existing) {
699
+ const last = existing.positions[existing.positions.length - 1];
700
+ const dist = last ? Math.sqrt((params.position.x - last.x) ** 2 + (params.position.y - last.y) ** 2) : 0;
701
+ existing.lastSeen = params.timestamp;
702
+ existing.totalDistance += dist;
703
+ existing.state = params.state;
704
+ if (existing.positions.length >= this.config.maxPositionHistory) {
705
+ const half = Math.floor(existing.positions.length / 2);
706
+ existing.positions = existing.positions.filter((_, i) => i >= half || i % 2 === 0);
707
+ existing.positions.push(params.position);
708
+ } else {
709
+ existing.positions.push(params.position);
710
+ }
711
+ for (const z of params.zones) {
712
+ if (!existing.zonesVisited.includes(z)) existing.zonesVisited.push(z);
713
+ }
714
+ return existing;
715
+ }
716
+ const fresh = {
717
+ trackId: params.trackId,
718
+ deviceId: params.deviceId,
719
+ className: params.className,
720
+ ...params.label !== void 0 ? { label: params.label } : {},
721
+ firstSeen: params.timestamp,
722
+ lastSeen: params.timestamp,
723
+ positions: [params.position],
724
+ snapshots: [],
725
+ zonesVisited: [...params.zones],
726
+ totalDistance: 0,
727
+ state: params.state,
728
+ active: true,
729
+ lastSnapshotAt: 0
730
+ };
731
+ this.active.set(params.trackId, fresh);
732
+ return fresh;
733
+ }
734
+ /** Attach a snapshot reference to an active track. */
735
+ addSnapshot(trackId, snapshot) {
736
+ const t = this.active.get(trackId);
737
+ if (!t) return;
738
+ t.snapshots.push(snapshot);
739
+ t.lastSnapshotAt = snapshot.timestamp;
740
+ }
741
+ lastSnapshotAt(trackId) {
742
+ return this.active.get(trackId)?.lastSnapshotAt ?? 0;
743
+ }
744
+ getActive(deviceId) {
745
+ const out = [];
746
+ for (const t of this.active.values()) {
747
+ if (t.deviceId === deviceId && t.active) out.push(cloneTrack(t));
748
+ }
749
+ return out;
750
+ }
751
+ getActiveByTrack(trackId) {
752
+ const t = this.active.get(trackId);
753
+ return t && t.active ? cloneTrack(t) : null;
754
+ }
755
+ /** Expire tracks whose `lastSeen` is older than TTL. Persists each
756
+ * expired track to the declared collection and returns them. */
757
+ async expireStale(nowMs) {
758
+ const expired = [];
759
+ for (const [trackId, t] of this.active) {
760
+ if (nowMs - t.lastSeen < this.config.ttlMs) continue;
761
+ t.active = false;
762
+ const record = cloneTrack(t);
763
+ try {
764
+ await this.persistCompleted(record);
765
+ } catch (err) {
766
+ this.logger.warn("persist completed track failed", {
767
+ meta: { trackId, error: String(err) }
768
+ });
769
+ }
770
+ this.active.delete(trackId);
771
+ expired.push(record);
772
+ }
773
+ return expired;
774
+ }
775
+ /** Discard active tracks for a device without persisting (used on
776
+ * operator clearTracks / device unregistration). */
777
+ clearDevice(deviceId) {
778
+ for (const [trackId, t] of this.active) {
779
+ if (t.deviceId === deviceId) this.active.delete(trackId);
780
+ }
781
+ }
782
+ clearAll() {
783
+ this.active.clear();
784
+ }
785
+ /** Historical query — hits the persisted collection. */
786
+ async queryHistorical(params) {
787
+ const filter = { where: { deviceId: params.deviceId } };
788
+ if (params.since !== void 0 || params.until !== void 0) {
789
+ filter.whereBetween = {
790
+ firstSeen: [params.since ?? 0, params.until ?? Date.now()]
791
+ };
792
+ }
793
+ const records = await this.store.query.query({
794
+ collection: TRACKS_COLLECTION,
795
+ filter: {
796
+ ...filter,
797
+ orderBy: { field: "firstSeen", direction: "desc" },
798
+ limit: params.limit ?? 50
799
+ }
800
+ });
801
+ return records.map((r) => this.rowToTrack(r.id, r.data));
802
+ }
803
+ async getPersistedByTrackId(trackId) {
804
+ const records = await this.store.query.query({
805
+ collection: TRACKS_COLLECTION,
806
+ filter: { where: { id: trackId }, limit: 1 }
807
+ });
808
+ if (records.length === 0) return null;
809
+ const row = records[0];
810
+ return this.rowToTrack(row.id, row.data);
811
+ }
812
+ async persistCompleted(t) {
813
+ await this.store.set.mutate({
814
+ collection: TRACKS_COLLECTION,
815
+ key: t.trackId,
816
+ value: {
817
+ deviceId: t.deviceId,
818
+ className: t.className,
819
+ ...t.label !== void 0 ? { label: t.label } : {},
820
+ firstSeen: t.firstSeen,
821
+ lastSeen: t.lastSeen,
822
+ positions: [...t.positions],
823
+ snapshots: [...t.snapshots],
824
+ zonesVisited: [...t.zonesVisited],
825
+ totalDistance: t.totalDistance,
826
+ state: t.state
827
+ }
828
+ });
829
+ }
830
+ rowToTrack(id, data) {
831
+ const positions = data["positions"] ?? [];
832
+ const snapshots = data["snapshots"] ?? [];
833
+ const zones = data["zonesVisited"] ?? [];
834
+ const label = data["label"];
835
+ return {
836
+ trackId: id,
837
+ deviceId: Number(data["deviceId"]),
838
+ className: String(data["className"]),
839
+ ...typeof label === "string" ? { label } : {},
840
+ firstSeen: Number(data["firstSeen"]),
841
+ lastSeen: Number(data["lastSeen"]),
842
+ positions,
843
+ snapshots,
844
+ zonesVisited: zones,
845
+ totalDistance: Number(data["totalDistance"] ?? 0),
846
+ state: data["state"] ?? "idle",
847
+ active: false
848
+ };
849
+ }
850
+ }
851
+ const MEDIA_COLLECTION = "pipeline-analytics:media";
852
+ const MEDIA_COLUMNS = [
853
+ { name: "id", type: "TEXT", primaryKey: true, notNull: true },
854
+ { name: "deviceId", type: "INTEGER", notNull: true },
855
+ { name: "ownerKind", type: "TEXT", notNull: true },
856
+ { name: "ownerId", type: "TEXT", notNull: true },
857
+ { name: "kind", type: "TEXT", notNull: true },
858
+ { name: "timestamp", type: "INTEGER", notNull: true },
859
+ { name: "path", type: "TEXT", notNull: true },
860
+ { name: "sizeBytes", type: "INTEGER", notNull: true }
861
+ ];
862
+ const MEDIA_INDEXES = [
863
+ { name: "idx_media_owner", columns: ["ownerKind", "ownerId"] },
864
+ { name: "idx_media_device_ts", columns: ["deviceId", "timestamp"] }
865
+ ];
866
+ function buildKey(params) {
867
+ return `${params.ownerKind}:${params.ownerId}:${params.kind}:${params.timestamp}`;
868
+ }
869
+ function buildPath(params) {
870
+ return `pipeline-analytics/${params.deviceId}/${params.ownerKind}/${params.ownerId}/${params.kind}-${params.timestamp}.jpg`;
871
+ }
872
+ class MediaStore {
873
+ storage;
874
+ store;
875
+ logger;
876
+ constructor(deps) {
877
+ this.storage = deps.storage;
878
+ this.store = deps.store;
879
+ this.logger = deps.logger;
880
+ }
881
+ static async declare(store) {
882
+ await store.declareCollection.mutate({
883
+ collection: MEDIA_COLLECTION,
884
+ columns: [...MEDIA_COLUMNS],
885
+ indexes: [...MEDIA_INDEXES]
886
+ });
887
+ }
888
+ /** Persist a media blob and register its metadata row. Returns the
889
+ * collection key (MediaFile.key) that callers store as reference. */
890
+ async put(params) {
891
+ const key = buildKey(params);
892
+ const path = buildPath(params);
893
+ try {
894
+ await this.storage.write({ location: "data", relativePath: path, data: params.data });
895
+ await this.store.insert.mutate({
896
+ collection: MEDIA_COLLECTION,
897
+ record: {
898
+ id: key,
899
+ data: {
900
+ deviceId: params.deviceId,
901
+ ownerKind: params.ownerKind,
902
+ ownerId: params.ownerId,
903
+ kind: params.kind,
904
+ timestamp: params.timestamp,
905
+ path,
906
+ sizeBytes: params.data.length
907
+ }
908
+ }
909
+ });
910
+ return key;
911
+ } catch (err) {
912
+ this.logger.warn("media put failed", { meta: { key, error: String(err) } });
913
+ throw err;
914
+ }
915
+ }
916
+ async listByOwner(ownerKind, ownerId) {
917
+ const rows = await this.store.query.query({
918
+ collection: MEDIA_COLLECTION,
919
+ filter: { where: { ownerKind, ownerId }, orderBy: { field: "timestamp", direction: "asc" } }
920
+ });
921
+ const files = [];
922
+ for (const row of rows) {
923
+ const data = row.data;
924
+ const path = String(data["path"]);
925
+ const timestamp = Number(data["timestamp"]);
926
+ const sizeBytes = Number(data["sizeBytes"]);
927
+ const kind = String(data["kind"]);
928
+ try {
929
+ const buf = await this.storage.read({ location: "data", relativePath: path });
930
+ files.push({
931
+ key: row.id,
932
+ kind,
933
+ base64: buf.toString("base64"),
934
+ sizeBytes,
935
+ timestamp
936
+ });
937
+ } catch (err) {
938
+ this.logger.debug("media read failed — row kept but blob missing", {
939
+ meta: { key: row.id, path, error: String(err) }
940
+ });
941
+ }
942
+ }
943
+ return files;
944
+ }
945
+ /** Retention sweep: delete any media row + blob older than cutoff.
946
+ * Returns number of entries removed. */
947
+ async evictBefore(cutoffMs) {
948
+ const rows = await this.store.query.query({
949
+ collection: MEDIA_COLLECTION,
950
+ filter: { whereBetween: { timestamp: [0, cutoffMs] }, limit: 500 }
951
+ });
952
+ let removed = 0;
953
+ for (const row of rows) {
954
+ const path = String(row.data["path"] ?? "");
955
+ try {
956
+ if (path) await this.storage.delete({ location: "data", relativePath: path });
957
+ } catch {
958
+ }
959
+ try {
960
+ await this.store.delete.mutate({ collection: MEDIA_COLLECTION, key: row.id });
961
+ removed++;
962
+ } catch (err) {
963
+ this.logger.debug("media evict delete failed", {
964
+ meta: { key: row.id, error: String(err) }
965
+ });
966
+ }
967
+ }
968
+ return removed;
969
+ }
970
+ }
971
+ const MOTION_EVENTS_COLLECTION = "pipeline-analytics:motion-events";
972
+ const OBJECT_EVENTS_COLLECTION = "pipeline-analytics:object-events";
973
+ const AUDIO_EVENTS_COLLECTION = "pipeline-analytics:audio-events";
974
+ const COMMON_BASE_COLUMNS = [
975
+ { name: "id", type: "TEXT", primaryKey: true, notNull: true },
976
+ { name: "deviceId", type: "INTEGER", notNull: true },
977
+ { name: "timestamp", type: "INTEGER", notNull: true }
978
+ ];
979
+ const MOTION_COLUMNS = [
980
+ ...COMMON_BASE_COLUMNS,
981
+ { name: "regionCount", type: "INTEGER", notNull: true },
982
+ { name: "regions", type: "JSON" },
983
+ { name: "frameWidth", type: "INTEGER" },
984
+ { name: "frameHeight", type: "INTEGER" }
985
+ ];
986
+ const OBJECT_COLUMNS = [
987
+ ...COMMON_BASE_COLUMNS,
988
+ { name: "trackId", type: "TEXT", notNull: true },
989
+ { name: "className", type: "TEXT", notNull: true },
990
+ { name: "label", type: "TEXT" },
991
+ { name: "confidence", type: "REAL", notNull: true },
992
+ { name: "bbox", type: "JSON" },
993
+ { name: "zones", type: "JSON" },
994
+ { name: "state", type: "TEXT" },
995
+ { name: "mediaKey", type: "TEXT" }
996
+ ];
997
+ const AUDIO_COLUMNS = [
998
+ ...COMMON_BASE_COLUMNS,
999
+ { name: "rms", type: "REAL", notNull: true },
1000
+ { name: "dbfs", type: "REAL", notNull: true },
1001
+ { name: "classification", type: "JSON" }
1002
+ ];
1003
+ const COMMON_INDEXES = (prefix) => [
1004
+ { name: `idx_${prefix}_device_ts`, columns: ["deviceId", "timestamp"] }
1005
+ ];
1006
+ class EventStore {
1007
+ store;
1008
+ logger;
1009
+ constructor(deps) {
1010
+ this.store = deps.store;
1011
+ this.logger = deps.logger;
1012
+ }
1013
+ static async declare(store) {
1014
+ await store.declareCollection.mutate({
1015
+ collection: MOTION_EVENTS_COLLECTION,
1016
+ columns: [...MOTION_COLUMNS],
1017
+ indexes: COMMON_INDEXES("motion")
1018
+ });
1019
+ await store.declareCollection.mutate({
1020
+ collection: OBJECT_EVENTS_COLLECTION,
1021
+ columns: [...OBJECT_COLUMNS],
1022
+ indexes: [
1023
+ ...COMMON_INDEXES("object"),
1024
+ { name: "idx_object_track", columns: ["trackId"] }
1025
+ ]
1026
+ });
1027
+ await store.declareCollection.mutate({
1028
+ collection: AUDIO_EVENTS_COLLECTION,
1029
+ columns: [...AUDIO_COLUMNS],
1030
+ indexes: COMMON_INDEXES("audio")
1031
+ });
1032
+ }
1033
+ // ── Writes ────────────────────────────────────────────────────────
1034
+ async insertMotion(ev) {
1035
+ try {
1036
+ const { id, ...rest } = ev;
1037
+ await this.store.insert.mutate({
1038
+ collection: MOTION_EVENTS_COLLECTION,
1039
+ record: { id, data: rest }
1040
+ });
1041
+ } catch (err) {
1042
+ this.logger.warn("insertMotion failed", { meta: { eventId: ev.id, error: String(err) } });
1043
+ }
1044
+ }
1045
+ async insertObject(ev) {
1046
+ try {
1047
+ const { id, ...rest } = ev;
1048
+ await this.store.insert.mutate({
1049
+ collection: OBJECT_EVENTS_COLLECTION,
1050
+ record: { id, data: rest }
1051
+ });
1052
+ } catch (err) {
1053
+ this.logger.warn("insertObject failed", { meta: { eventId: ev.id, error: String(err) } });
1054
+ }
1055
+ }
1056
+ async insertAudio(ev) {
1057
+ try {
1058
+ const { id, ...rest } = ev;
1059
+ await this.store.insert.mutate({
1060
+ collection: AUDIO_EVENTS_COLLECTION,
1061
+ record: { id, data: rest }
1062
+ });
1063
+ } catch (err) {
1064
+ this.logger.warn("insertAudio failed", { meta: { eventId: ev.id, error: String(err) } });
1065
+ }
1066
+ }
1067
+ // ── Reads ────────────────────────────────────────────────────────
1068
+ buildFilter(q) {
1069
+ const filter = {
1070
+ where: { deviceId: q.deviceId },
1071
+ orderBy: { field: "timestamp", direction: "desc" }
1072
+ };
1073
+ if (q.since !== void 0 || q.until !== void 0) {
1074
+ filter.whereBetween = { timestamp: [q.since ?? 0, q.until ?? Date.now()] };
1075
+ }
1076
+ if (q.limit !== void 0) filter.limit = q.limit;
1077
+ return filter;
1078
+ }
1079
+ async queryMotion(q) {
1080
+ const rows = await this.store.query.query({
1081
+ collection: MOTION_EVENTS_COLLECTION,
1082
+ filter: this.buildFilter(q)
1083
+ });
1084
+ return rows.map((r) => {
1085
+ const ev = { id: r.id, kind: "motion", ...stripNulls(r.data) };
1086
+ return ev;
1087
+ });
1088
+ }
1089
+ async queryObject(q) {
1090
+ const filter = this.buildFilter(q);
1091
+ if (q.classFilter !== void 0) {
1092
+ const where = filter.where;
1093
+ where["className"] = q.classFilter;
1094
+ }
1095
+ const rows = await this.store.query.query({
1096
+ collection: OBJECT_EVENTS_COLLECTION,
1097
+ filter
1098
+ });
1099
+ return rows.map((r) => {
1100
+ const ev = { id: r.id, kind: "object", ...stripNulls(r.data) };
1101
+ return ev;
1102
+ });
1103
+ }
1104
+ async queryAudio(q) {
1105
+ const rows = await this.store.query.query({
1106
+ collection: AUDIO_EVENTS_COLLECTION,
1107
+ filter: this.buildFilter(q)
1108
+ });
1109
+ return rows.map((r) => {
1110
+ const ev = { id: r.id, kind: "audio", ...stripNulls(r.data) };
1111
+ return ev;
1112
+ });
1113
+ }
1114
+ // ── Retention ────────────────────────────────────────────────────
1115
+ // (helper below)
1116
+ async evictBefore(params) {
1117
+ const deletedCounts = { motion: 0, object: 0, audio: 0 };
1118
+ const batch = async (collection, cutoffMs) => {
1119
+ const rows = await this.store.query.query({
1120
+ collection,
1121
+ filter: { whereBetween: { timestamp: [0, cutoffMs] }, limit: 500 }
1122
+ });
1123
+ let count = 0;
1124
+ for (const row of rows) {
1125
+ try {
1126
+ await this.store.delete.mutate({ collection, key: row.id });
1127
+ count++;
1128
+ } catch {
1129
+ }
1130
+ }
1131
+ return count;
1132
+ };
1133
+ deletedCounts.motion = await batch(MOTION_EVENTS_COLLECTION, params.motionCutoffMs);
1134
+ deletedCounts.object = await batch(OBJECT_EVENTS_COLLECTION, params.objectCutoffMs);
1135
+ deletedCounts.audio = await batch(AUDIO_EVENTS_COLLECTION, params.audioCutoffMs);
1136
+ return deletedCounts;
1137
+ }
1138
+ }
1139
+ function stripNulls(data) {
1140
+ const out = {};
1141
+ for (const [k, v] of Object.entries(data)) {
1142
+ if (v !== null) out[k] = v;
1143
+ }
1144
+ return out;
1145
+ }
1146
+ class SliceThrottler {
1147
+ opts;
1148
+ lastWrittenAt = /* @__PURE__ */ new Map();
1149
+ lastWritten = /* @__PURE__ */ new Map();
1150
+ pending = /* @__PURE__ */ new Map();
1151
+ constructor(opts) {
1152
+ this.opts = opts;
1153
+ }
1154
+ /**
1155
+ * Record a fresh snapshot for the given device. Triggers an
1156
+ * immediate write when the throttle window has elapsed AND the
1157
+ * content changed; otherwise queues a trailing flush.
1158
+ */
1159
+ push(deviceId, snapshot) {
1160
+ const last = this.lastWritten.get(deviceId);
1161
+ if (this.opts.equalsIgnoringTs(last, snapshot)) {
1162
+ const queued2 = this.pending.get(deviceId);
1163
+ if (queued2?.scheduledTimer) clearTimeout(queued2.scheduledTimer);
1164
+ this.pending.delete(deviceId);
1165
+ return;
1166
+ }
1167
+ const now = Date.now();
1168
+ const lastTs = this.lastWrittenAt.get(deviceId) ?? 0;
1169
+ const elapsed = now - lastTs;
1170
+ if (elapsed >= this.opts.intervalMs) {
1171
+ void this.flushNow(deviceId, snapshot);
1172
+ return;
1173
+ }
1174
+ const queued = this.pending.get(deviceId);
1175
+ const remainingMs = this.opts.intervalMs - elapsed;
1176
+ if (queued) {
1177
+ queued.snapshot = snapshot;
1178
+ } else {
1179
+ const slot = { snapshot, scheduledTimer: null };
1180
+ slot.scheduledTimer = setTimeout(() => {
1181
+ slot.scheduledTimer = null;
1182
+ const current = this.pending.get(deviceId);
1183
+ if (!current) return;
1184
+ this.pending.delete(deviceId);
1185
+ void this.flushNow(deviceId, current.snapshot);
1186
+ }, remainingMs);
1187
+ this.pending.set(deviceId, slot);
1188
+ }
1189
+ }
1190
+ forgetDevice(deviceId) {
1191
+ const queued = this.pending.get(deviceId);
1192
+ if (queued?.scheduledTimer) clearTimeout(queued.scheduledTimer);
1193
+ this.pending.delete(deviceId);
1194
+ this.lastWritten.delete(deviceId);
1195
+ this.lastWrittenAt.delete(deviceId);
1196
+ }
1197
+ /** Drop all pending timers — call from `onShutdown`. */
1198
+ destroy() {
1199
+ for (const queued of this.pending.values()) {
1200
+ if (queued.scheduledTimer) clearTimeout(queued.scheduledTimer);
1201
+ }
1202
+ this.pending.clear();
1203
+ this.lastWritten.clear();
1204
+ this.lastWrittenAt.clear();
1205
+ }
1206
+ async flushNow(deviceId, snapshot) {
1207
+ this.lastWrittenAt.set(deviceId, Date.now());
1208
+ this.lastWritten.set(deviceId, snapshot);
1209
+ await this.opts.write(deviceId, snapshot);
1210
+ }
1211
+ }
1212
+ function snapshotEqualsIgnoringTs(prev, next) {
1213
+ if (!prev) return false;
1214
+ const { ts: _prevTs, ...prevRest } = prev;
1215
+ const { ts: _nextTs, ...nextRest } = next;
1216
+ return JSON.stringify(prevRest) === JSON.stringify(nextRest);
1217
+ }
1218
+ const HISTORY_WINDOW_MS = 60 * 60 * 1e3;
1219
+ const ZONE_ANALYTICS_CAP_NAME = "zone-analytics";
1220
+ const RESOLUTION_MS = {
1221
+ minute: 6e4,
1222
+ "5min": 5 * 6e4,
1223
+ hour: 60 * 6e4
1224
+ };
1225
+ const SLICE_WRITE_INTERVAL_MS$1 = 1e3;
1226
+ class ZoneAnalyticsProvider {
1227
+ constructor(ctx) {
1228
+ this.ctx = ctx;
1229
+ this.sliceThrottle = new SliceThrottler({
1230
+ intervalMs: SLICE_WRITE_INTERVAL_MS$1,
1231
+ equalsIgnoringTs: snapshotEqualsIgnoringTs,
1232
+ write: async (deviceId, snapshot) => {
1233
+ try {
1234
+ const dev = await this.ctx.fetchDevice(deviceId);
1235
+ await dev.deviceState.setCapSlice({
1236
+ capName: ZONE_ANALYTICS_CAP_NAME,
1237
+ slice: snapshot
1238
+ });
1239
+ } catch (err) {
1240
+ this.ctx.logger.debug("zone-analytics slice mirror failed", {
1241
+ tags: { deviceId },
1242
+ meta: { error: err instanceof Error ? err.message : String(err) }
1243
+ });
1244
+ }
1245
+ }
1246
+ });
1247
+ }
1248
+ snapshots = /* @__PURE__ */ new Map();
1249
+ /** Per-device rolling ring of frame samples. Capped by HISTORY_WINDOW_MS
1250
+ * on append; older entries are evicted lazily on each record. */
1251
+ history = /* @__PURE__ */ new Map();
1252
+ /**
1253
+ * Coalesces slice writes to the kernel-side runtime-state mirror.
1254
+ * Skips no-op snapshots (idle camera, zone counts unchanged) and
1255
+ * caps the rate at 1 Hz with a trailing flush so a transient
1256
+ * change isn't held back beyond `SLICE_WRITE_INTERVAL_MS`.
1257
+ */
1258
+ sliceThrottle;
1259
+ /** Stop pending throttle timers — called from addon shutdown. */
1260
+ destroy() {
1261
+ this.sliceThrottle.destroy();
1262
+ }
1263
+ // ── Cap surface ───────────────────────────────────────────────────
1264
+ async getCurrentSnapshot({
1265
+ deviceId
1266
+ }) {
1267
+ return this.snapshots.get(deviceId) ?? null;
1268
+ }
1269
+ async getZoneHistory(input) {
1270
+ return this.bucketize(input.deviceId, input.from, input.to, input.resolution, (snap) => {
1271
+ const zone = snap.zones.find((z) => z.zoneId === input.zoneId);
1272
+ if (!zone) return 0;
1273
+ if (input.className === void 0) return zone.totalObjects;
1274
+ return zone.byClass[input.className] ?? 0;
1275
+ });
1276
+ }
1277
+ async getCameraHistory(input) {
1278
+ return this.bucketize(input.deviceId, input.from, input.to, input.resolution, (snap) => {
1279
+ if (input.className === void 0) return snap.frame.totalObjects;
1280
+ return snap.frame.byClass[input.className] ?? 0;
1281
+ });
1282
+ }
1283
+ async getUnzonedHistory(input) {
1284
+ return this.bucketize(input.deviceId, input.from, input.to, input.resolution, (snap) => {
1285
+ if (input.className === void 0) return snap.unzoned.totalObjects;
1286
+ return snap.unzoned.byClass[input.className] ?? 0;
1287
+ });
1288
+ }
1289
+ // ── Recording surface (called by the analytics frame handler) ─────
1290
+ /**
1291
+ * Compute and record a snapshot from a tracked-detection list.
1292
+ * Called once per inference result by the analytics addon's
1293
+ * `handleInferenceResult`. Mirrors to the `zone-occupancy`
1294
+ * device-state slice for live UI subscribers.
1295
+ */
1296
+ async recordFrame(input) {
1297
+ const snapshot = computeSnapshot(input);
1298
+ this.snapshots.set(input.deviceId, snapshot);
1299
+ this.appendHistory(input.deviceId, snapshot);
1300
+ this.sliceThrottle.push(input.deviceId, snapshot);
1301
+ }
1302
+ /** Drop a device's snapshot + history. Called on device removal. */
1303
+ forgetDevice(deviceId) {
1304
+ this.snapshots.delete(deviceId);
1305
+ this.history.delete(deviceId);
1306
+ this.sliceThrottle.forgetDevice(deviceId);
1307
+ }
1308
+ // ── Internals ─────────────────────────────────────────────────────
1309
+ appendHistory(deviceId, snapshot) {
1310
+ const ring = this.history.get(deviceId) ?? [];
1311
+ const cutoff = snapshot.ts - HISTORY_WINDOW_MS;
1312
+ let trimIdx = 0;
1313
+ while (trimIdx < ring.length && ring[trimIdx].ts < cutoff) trimIdx++;
1314
+ const trimmed = trimIdx > 0 ? ring.slice(trimIdx) : ring;
1315
+ trimmed.push({ ts: snapshot.ts, snapshot });
1316
+ this.history.set(deviceId, trimmed);
1317
+ }
1318
+ bucketize(deviceId, from, to, resolution, extract) {
1319
+ const ring = this.history.get(deviceId);
1320
+ if (!ring || ring.length === 0) return [];
1321
+ const bucketMs = RESOLUTION_MS[resolution];
1322
+ const buckets = /* @__PURE__ */ new Map();
1323
+ for (const sample of ring) {
1324
+ if (sample.ts < from || sample.ts > to) continue;
1325
+ const bucketStart = Math.floor(sample.ts / bucketMs) * bucketMs;
1326
+ const bucket = buckets.get(bucketStart);
1327
+ const value = extract(sample.snapshot);
1328
+ if (bucket) {
1329
+ bucket.sum += value;
1330
+ bucket.count += 1;
1331
+ } else {
1332
+ buckets.set(bucketStart, { sum: value, count: 1 });
1333
+ }
1334
+ }
1335
+ const result = [];
1336
+ for (const [bucketStart, agg] of [...buckets.entries()].sort(([a], [b]) => a - b)) {
1337
+ result.push({
1338
+ ts: bucketStart + bucketMs / 2,
1339
+ count: Math.round(agg.sum / agg.count)
1340
+ });
1341
+ }
1342
+ return result;
1343
+ }
1344
+ }
1345
+ function computeSnapshot(input) {
1346
+ const frameByClass = {};
1347
+ let frameTotal = 0;
1348
+ for (const td of input.tracked) {
1349
+ frameTotal += 1;
1350
+ frameByClass[td.className] = (frameByClass[td.className] ?? 0) + 1;
1351
+ }
1352
+ const zoneAccumulators = /* @__PURE__ */ new Map();
1353
+ for (const z of input.zones) {
1354
+ zoneAccumulators.set(z.id, {
1355
+ name: z.name,
1356
+ total: 0,
1357
+ byClass: {},
1358
+ trackIds: []
1359
+ });
1360
+ }
1361
+ let unzonedTotal = 0;
1362
+ const unzonedByClass = {};
1363
+ for (const td of input.tracked) {
1364
+ if (td.zones.length === 0) {
1365
+ unzonedTotal += 1;
1366
+ unzonedByClass[td.className] = (unzonedByClass[td.className] ?? 0) + 1;
1367
+ continue;
1368
+ }
1369
+ for (const zoneId of td.zones) {
1370
+ const acc = zoneAccumulators.get(zoneId);
1371
+ if (!acc) continue;
1372
+ acc.total += 1;
1373
+ acc.byClass[td.className] = (acc.byClass[td.className] ?? 0) + 1;
1374
+ acc.trackIds.push(td.trackId);
1375
+ }
1376
+ }
1377
+ return {
1378
+ ts: input.timestamp,
1379
+ frameWidth: input.frameWidth,
1380
+ frameHeight: input.frameHeight,
1381
+ zones: [...zoneAccumulators.entries()].map(([zoneId, acc]) => ({
1382
+ zoneId,
1383
+ zoneName: acc.name,
1384
+ totalObjects: acc.total,
1385
+ byClass: acc.byClass,
1386
+ trackIds: acc.trackIds
1387
+ })),
1388
+ frame: {
1389
+ totalObjects: frameTotal,
1390
+ byClass: frameByClass
1391
+ },
1392
+ unzoned: {
1393
+ totalObjects: unzonedTotal,
1394
+ byClass: unzonedByClass
1395
+ }
1396
+ };
1397
+ }
1398
+ const AUDIO_METRICS_CAP_NAME = "audio-metrics";
1399
+ const MAX_HISTORY_POINTS_KEPT = 3600;
1400
+ const MAX_HISTORY_POINTS_RETURNED = 1024;
1401
+ const DEFAULT_HISTORY_WINDOW_SEC = 300;
1402
+ const DEFAULT_HISTORY_SAMPLE_EVERY_MS = 1e3;
1403
+ const SLICE_WRITE_INTERVAL_MS = 1e3;
1404
+ const REPORT_SETTINGS_TTL_MS = 3e4;
1405
+ const DEFAULT_REPORT_SETTINGS = {
1406
+ enabled: true,
1407
+ thresholdDb: 2
1408
+ };
1409
+ function audioMetricsSnapshotEquals(prev, next, thresholdDb) {
1410
+ if (!prev) return false;
1411
+ if (thresholdDb <= 0) return snapshotEqualsIgnoringTs(prev, next);
1412
+ const prevClass = prev.current?.className ?? null;
1413
+ const nextClass = next.current?.className ?? null;
1414
+ if (prevClass !== nextClass) return false;
1415
+ if (prev.byClass.length !== next.byClass.length) return false;
1416
+ for (let i = 0; i < prev.byClass.length; i++) {
1417
+ const a = prev.byClass[i];
1418
+ const b = next.byClass[i];
1419
+ if (a.className !== b.className || a.hits !== b.hits) return false;
1420
+ }
1421
+ if (Math.abs(prev.level.dbfs - next.level.dbfs) >= thresholdDb) return false;
1422
+ if (Math.abs(prev.peakDbfs - next.peakDbfs) >= thresholdDb) return false;
1423
+ if (Math.abs(prev.avgDbfs - next.avgDbfs) >= thresholdDb) return false;
1424
+ return true;
1425
+ }
1426
+ class AudioMetricsProvider {
1427
+ constructor(ctx) {
1428
+ this.ctx = ctx;
1429
+ this.windowMs = (ctx.windowSec ?? 60) * 1e3;
1430
+ this.scoreThreshold = ctx.scoreThreshold ?? 0.4;
1431
+ this.sliceThrottle = new SliceThrottler({
1432
+ intervalMs: SLICE_WRITE_INTERVAL_MS,
1433
+ equalsIgnoringTs: (prev, next) => audioMetricsSnapshotEquals(prev, next, this.currentThresholdDb),
1434
+ write: async (deviceId, snapshot) => {
1435
+ try {
1436
+ const dev = await this.ctx.fetchDevice(deviceId);
1437
+ await dev.deviceState.setCapSlice({
1438
+ capName: AUDIO_METRICS_CAP_NAME,
1439
+ slice: snapshot
1440
+ });
1441
+ } catch (err) {
1442
+ this.ctx.logger.debug("audio-metrics slice mirror failed", {
1443
+ tags: { deviceId },
1444
+ meta: { error: err instanceof Error ? err.message : String(err) }
1445
+ });
1446
+ }
1447
+ }
1448
+ });
1449
+ }
1450
+ snapshots = /* @__PURE__ */ new Map();
1451
+ state = /* @__PURE__ */ new Map();
1452
+ /**
1453
+ * Per-device history ring buffer. Each push appends a compact
1454
+ * `AudioMetricsHistoryPoint` derived from the just-computed
1455
+ * snapshot; the oldest entry is dropped once
1456
+ * `MAX_HISTORY_POINTS_KEPT` is reached. We use a plain array
1457
+ * (not a circular buffer) for readability — the trim cost is
1458
+ * O(1) on `.shift()` for arrays Node/V8 keeps short, and
1459
+ * `getHistory` doesn't have a hot-loop requirement.
1460
+ *
1461
+ * The ring is populated at the same cadence as the
1462
+ * `recordAudioFrame()` calls (1 push per call), but most builds
1463
+ * receive ~10–20 audio chunks per second. That rate is faster
1464
+ * than what `getHistory` reports (default 1Hz spacing); the
1465
+ * downstream subsampling step in `getHistory` averages adjacent
1466
+ * samples back to the requested spacing, so retention covers
1467
+ * roughly `MAX_HISTORY_POINTS_KEPT × pushIntervalMs / 1000`s of
1468
+ * wall-clock time. At 10Hz that's ~6 minutes; at 1Hz ~1 hour.
1469
+ */
1470
+ history = /* @__PURE__ */ new Map();
1471
+ windowMs;
1472
+ scoreThreshold;
1473
+ /**
1474
+ * Coalesces slice writes to the kernel-side runtime-state mirror.
1475
+ * Equality check is audio-metrics-aware: dB jitter under the
1476
+ * per-device threshold is suppressed; class changes always pass.
1477
+ * Trailing-flushes at most once per SLICE_WRITE_INTERVAL_MS.
1478
+ */
1479
+ sliceThrottle;
1480
+ /** Per-device settings cache. Refreshed lazily inside the audio
1481
+ * fast path (REPORT_SETTINGS_TTL_MS). */
1482
+ reportSettings = /* @__PURE__ */ new Map();
1483
+ /** In-flight settings-fetch dedupe per device. */
1484
+ settingsFetches = /* @__PURE__ */ new Map();
1485
+ /** Working snapshot of the equality threshold while the throttler
1486
+ * is running its diff. Set before push(), reset to default after.
1487
+ * Allows the closure passed to SliceThrottler to look up the
1488
+ * per-device threshold without a deeper API change. */
1489
+ currentThresholdDb = DEFAULT_REPORT_SETTINGS.thresholdDb;
1490
+ /**
1491
+ * Resolve per-device report settings with TTL caching. Returns
1492
+ * the cached value synchronously when fresh; on stale or first
1493
+ * access, kicks off an async refresh and returns the previous
1494
+ * value (falling back to defaults). Keeps the audio fast path
1495
+ * non-blocking — the worst case is one slightly-stale tick per
1496
+ * camera per TTL window.
1497
+ */
1498
+ getReportSettings(deviceId) {
1499
+ const cached = this.reportSettings.get(deviceId);
1500
+ const now = Date.now();
1501
+ if (cached && now - cached.resolvedAt < REPORT_SETTINGS_TTL_MS) {
1502
+ return cached.value;
1503
+ }
1504
+ if (this.ctx.resolveReportSettings && !this.settingsFetches.has(deviceId)) {
1505
+ const fetch = this.ctx.resolveReportSettings(deviceId).then((value) => {
1506
+ this.reportSettings.set(deviceId, { value, resolvedAt: Date.now() });
1507
+ }).catch((err) => {
1508
+ this.ctx.logger.debug("audio-metrics: report settings resolve failed", {
1509
+ tags: { deviceId },
1510
+ meta: { error: err instanceof Error ? err.message : String(err) }
1511
+ });
1512
+ this.reportSettings.set(deviceId, { value: DEFAULT_REPORT_SETTINGS, resolvedAt: Date.now() });
1513
+ }).finally(() => {
1514
+ this.settingsFetches.delete(deviceId);
1515
+ });
1516
+ this.settingsFetches.set(deviceId, fetch);
1517
+ }
1518
+ return cached?.value ?? DEFAULT_REPORT_SETTINGS;
1519
+ }
1520
+ /** Stop pending throttle timers — called from addon shutdown. */
1521
+ destroy() {
1522
+ this.sliceThrottle.destroy();
1523
+ }
1524
+ // ── Cap surface ───────────────────────────────────────────────────
1525
+ async getCurrentSnapshot({
1526
+ deviceId
1527
+ }) {
1528
+ return this.snapshots.get(deviceId) ?? null;
1529
+ }
1530
+ async getHistory({
1531
+ deviceId,
1532
+ windowSec,
1533
+ sampleEveryMs
1534
+ }) {
1535
+ const ring = this.history.get(deviceId);
1536
+ if (!ring || ring.length === 0) {
1537
+ return { points: [], effectiveSampleEveryMs: sampleEveryMs ?? DEFAULT_HISTORY_SAMPLE_EVERY_MS, windowMsActual: 0 };
1538
+ }
1539
+ const effectiveWindowSec = windowSec ?? DEFAULT_HISTORY_WINDOW_SEC;
1540
+ const requestedSampleEveryMs = sampleEveryMs ?? DEFAULT_HISTORY_SAMPLE_EVERY_MS;
1541
+ const cutoffTs = Date.now() - effectiveWindowSec * 1e3;
1542
+ const inWindow = [];
1543
+ for (const p of ring) {
1544
+ if (p.ts >= cutoffTs) inWindow.push(p);
1545
+ }
1546
+ if (inWindow.length === 0) {
1547
+ return { points: [], effectiveSampleEveryMs: requestedSampleEveryMs, windowMsActual: 0 };
1548
+ }
1549
+ const naiveBucketCount = Math.max(1, Math.ceil(effectiveWindowSec * 1e3 / requestedSampleEveryMs));
1550
+ const targetBucketCount = Math.min(naiveBucketCount, MAX_HISTORY_POINTS_RETURNED);
1551
+ const downsampled = downsampleHistory(inWindow, targetBucketCount);
1552
+ const first = downsampled[0];
1553
+ const last = downsampled[downsampled.length - 1];
1554
+ const windowMsActual = first && last ? last.ts - first.ts : 0;
1555
+ const effectiveSampleEveryMs = downsampled.length >= 2 ? Math.max(1, Math.round(windowMsActual / Math.max(1, downsampled.length - 1))) : requestedSampleEveryMs;
1556
+ return {
1557
+ points: downsampled,
1558
+ effectiveSampleEveryMs,
1559
+ windowMsActual
1560
+ };
1561
+ }
1562
+ // ── Recording (called by the analytics addon's audio handler) ────
1563
+ /**
1564
+ * Record one audio window into the rolling aggregator and emit a
1565
+ * fresh snapshot. Called once per `pipeline.audio-inference-result`
1566
+ * event by the analytics addon. `topClassification` may be omitted
1567
+ * for silent windows — the level still updates.
1568
+ */
1569
+ async recordAudioFrame(input) {
1570
+ let s = this.state.get(input.deviceId);
1571
+ if (!s) {
1572
+ s = { hits: [], levels: [], lastLevel: null };
1573
+ this.state.set(input.deviceId, s);
1574
+ }
1575
+ const cutoff = input.timestamp - this.windowMs;
1576
+ while (s.hits.length > 0 && s.hits[0].ts < cutoff) s.hits.shift();
1577
+ while (s.levels.length > 0 && s.levels[0].ts < cutoff) s.levels.shift();
1578
+ if (input.level) {
1579
+ s.lastLevel = { ...input.level, ts: input.timestamp };
1580
+ s.levels.push({ ts: input.timestamp, dbfs: input.level.dbfs });
1581
+ }
1582
+ if (input.topClassification && input.topClassification.score >= this.scoreThreshold) {
1583
+ s.hits.push({
1584
+ ts: input.timestamp,
1585
+ className: input.topClassification.className,
1586
+ score: input.topClassification.score
1587
+ });
1588
+ }
1589
+ const snapshot = computeAudioSnapshot(input.timestamp, s, this.windowMs / 1e3, this.scoreThreshold);
1590
+ this.snapshots.set(input.deviceId, snapshot);
1591
+ const point = snapshotToHistoryPoint(snapshot);
1592
+ let ring = this.history.get(input.deviceId);
1593
+ if (!ring) {
1594
+ ring = [];
1595
+ this.history.set(input.deviceId, ring);
1596
+ }
1597
+ ring.push(point);
1598
+ if (ring.length > MAX_HISTORY_POINTS_KEPT) {
1599
+ ring.shift();
1600
+ }
1601
+ const settings = this.getReportSettings(input.deviceId);
1602
+ if (!settings.enabled) return;
1603
+ this.currentThresholdDb = settings.thresholdDb;
1604
+ this.sliceThrottle.push(input.deviceId, snapshot);
1605
+ this.currentThresholdDb = DEFAULT_REPORT_SETTINGS.thresholdDb;
1606
+ }
1607
+ /** Drop a device's audio state. Called on device removal. */
1608
+ forgetDevice(deviceId) {
1609
+ this.snapshots.delete(deviceId);
1610
+ this.state.delete(deviceId);
1611
+ this.history.delete(deviceId);
1612
+ this.sliceThrottle.forgetDevice(deviceId);
1613
+ this.reportSettings.delete(deviceId);
1614
+ }
1615
+ }
1616
+ function snapshotToHistoryPoint(snapshot) {
1617
+ return {
1618
+ ts: snapshot.ts,
1619
+ dbfs: Number.isFinite(snapshot.level.dbfs) ? snapshot.level.dbfs : null,
1620
+ peakDbfs: snapshot.peakDbfs,
1621
+ avgDbfs: snapshot.avgDbfs,
1622
+ topClass: snapshot.current?.className ?? null,
1623
+ topScore: snapshot.current?.score ?? null
1624
+ };
1625
+ }
1626
+ function downsampleHistory(source, targetCount) {
1627
+ if (source.length === 0) return [];
1628
+ if (targetCount >= source.length) return [...source];
1629
+ const out = [];
1630
+ const bucketSize = Math.max(1, Math.floor(source.length / targetCount));
1631
+ for (let bucketStart = 0; bucketStart < source.length; bucketStart += bucketSize) {
1632
+ const bucketEnd = Math.min(source.length, bucketStart + bucketSize);
1633
+ let dbfsSum = 0;
1634
+ let dbfsCount = 0;
1635
+ let peakDbfs = -Infinity;
1636
+ let avgDbfsSum = 0;
1637
+ let peakBucketScore = -Infinity;
1638
+ let peakBucketClass = null;
1639
+ const classCounts = /* @__PURE__ */ new Map();
1640
+ let lastTs = source[bucketStart].ts;
1641
+ for (let i = bucketStart; i < bucketEnd; i++) {
1642
+ const p = source[i];
1643
+ if (p.dbfs !== null) {
1644
+ dbfsSum += p.dbfs;
1645
+ dbfsCount += 1;
1646
+ }
1647
+ if (p.peakDbfs > peakDbfs) peakDbfs = p.peakDbfs;
1648
+ avgDbfsSum += p.avgDbfs;
1649
+ if (p.topClass !== null) {
1650
+ classCounts.set(p.topClass, (classCounts.get(p.topClass) ?? 0) + 1);
1651
+ if (p.topScore !== null && p.topScore > peakBucketScore) {
1652
+ peakBucketScore = p.topScore;
1653
+ peakBucketClass = p.topClass;
1654
+ }
1655
+ }
1656
+ lastTs = p.ts;
1657
+ }
1658
+ let dominantClass = null;
1659
+ let dominantCount = 0;
1660
+ for (const [cls, count] of classCounts) {
1661
+ if (count > dominantCount) {
1662
+ dominantClass = cls;
1663
+ dominantCount = count;
1664
+ }
1665
+ }
1666
+ const topClass = dominantClass ?? peakBucketClass;
1667
+ const topScore = topClass !== null && peakBucketScore !== -Infinity ? peakBucketScore : null;
1668
+ out.push({
1669
+ ts: lastTs,
1670
+ dbfs: dbfsCount > 0 ? dbfsSum / dbfsCount : null,
1671
+ peakDbfs: Number.isFinite(peakDbfs) ? peakDbfs : 0,
1672
+ avgDbfs: avgDbfsSum / Math.max(1, bucketEnd - bucketStart),
1673
+ topClass,
1674
+ topScore
1675
+ });
1676
+ }
1677
+ return out;
1678
+ }
1679
+ function computeAudioSnapshot(ts, s, windowSec, scoreThreshold) {
1680
+ const byClassMap = /* @__PURE__ */ new Map();
1681
+ for (const h of s.hits) {
1682
+ const acc = byClassMap.get(h.className) ?? { hits: 0, sum: 0, peak: 0 };
1683
+ acc.hits += 1;
1684
+ acc.sum += h.score;
1685
+ if (h.score > acc.peak) acc.peak = h.score;
1686
+ byClassMap.set(h.className, acc);
1687
+ }
1688
+ const byClass = [...byClassMap.entries()].map(([className, agg]) => ({
1689
+ className,
1690
+ hits: agg.hits,
1691
+ avgScore: agg.hits > 0 ? agg.sum / agg.hits : 0,
1692
+ peakScore: agg.peak
1693
+ })).sort((a, b) => b.hits - a.hits || b.peakScore - a.peakScore);
1694
+ let peakDbfs = -Infinity;
1695
+ let sumDbfs = 0;
1696
+ for (const l of s.levels) {
1697
+ if (l.dbfs > peakDbfs) peakDbfs = l.dbfs;
1698
+ sumDbfs += l.dbfs;
1699
+ }
1700
+ const avgDbfs = s.levels.length > 0 ? sumDbfs / s.levels.length : 0;
1701
+ const lastHit = s.hits.length > 0 ? s.hits[s.hits.length - 1] : null;
1702
+ const current = lastHit ? { className: lastHit.className, score: lastHit.score, timestamp: lastHit.ts } : null;
1703
+ return {
1704
+ ts,
1705
+ windowSec,
1706
+ level: s.lastLevel ? { rms: s.lastLevel.rms, dbfs: s.lastLevel.dbfs } : { rms: 0, dbfs: -Infinity },
1707
+ peakDbfs: Number.isFinite(peakDbfs) ? peakDbfs : 0,
1708
+ avgDbfs,
1709
+ current,
1710
+ byClass
1711
+ };
1712
+ }
1713
+ const TTL_SWEEP_INTERVAL_MS = 5e3;
1714
+ const RETENTION_SWEEP_INTERVAL_MS = 5 * 6e4;
1715
+ const AUDIO_EVENT_HEARTBEAT_MS = 5e3;
1716
+ const MOTION_EVENT_HEARTBEAT_MS = 5e3;
1717
+ class PipelineAnalyticsAddon extends types.BaseAddon {
1718
+ processors = /* @__PURE__ */ new Map();
1719
+ trackStore = null;
1720
+ mediaStore = null;
1721
+ eventStore = null;
1722
+ bindingCache = null;
1723
+ zoneAnalytics = null;
1724
+ audioMetrics = null;
1725
+ /**
1726
+ * Per-device {@link DeviceProxy} used to read live zones +
1727
+ * detection rules off the kernel runtime-state mirror. Replaces
1728
+ * the manual DeviceStateChanged subscription + per-cap-name caches
1729
+ * — `proxy.state.zones.value` and `proxy.state.zoneRules.value`
1730
+ * stay warm as long as the slice handles are subscribed.
1731
+ */
1732
+ proxies = /* @__PURE__ */ new Map();
1733
+ /** Slice subscription pins for cache warmth + pushed updates into
1734
+ * the per-device FrameProcessor. */
1735
+ proxyUnsubs = /* @__PURE__ */ new Map();
1736
+ unsubInference = null;
1737
+ unsubAudio = null;
1738
+ unsubMotion = null;
1739
+ unsubMotionOnboard = null;
1740
+ unsubBindings = null;
1741
+ unsubDeviceUnreg = null;
1742
+ ttlSweepTimer = null;
1743
+ retentionSweepTimer = null;
1744
+ // Current tracked-state snapshot per active track. Used so
1745
+ // TrackStarted/TrackEnded fire exactly once per lifecycle transition.
1746
+ lastActiveTrackIds = /* @__PURE__ */ new Map();
1747
+ // Throttle state for audio event inserts. Without this every AAC
1748
+ // chunk (~30 Hz) becomes a row, blowing the analytics DB to 1M+
1749
+ // rows in a single day on one camera.
1750
+ lastAudioInsertByDevice = /* @__PURE__ */ new Map();
1751
+ // Same throttle for motion: one row per heartbeat while detected stays
1752
+ // true; immediate insert on off→on transition. Off→off skipped.
1753
+ lastMotionInsertByDevice = /* @__PURE__ */ new Map();
1754
+ shuttingDown = false;
1755
+ constructor() {
1756
+ super({});
1757
+ }
1758
+ async onInitialize() {
1759
+ const api = this.ctx.api;
1760
+ if (!api) throw new Error("pipeline-analytics requires ctx.api (device-manager + settings-store)");
1761
+ await TrackStore.declare(api.settingsStore);
1762
+ await MediaStore.declare(api.settingsStore);
1763
+ await EventStore.declare(api.settingsStore);
1764
+ const logger = this.ctx.logger;
1765
+ const storage = this.ctx.kernel.storage;
1766
+ if (!storage) throw new Error("pipeline-analytics requires ctx.kernel.storage");
1767
+ this.trackStore = new TrackStore({ store: api.settingsStore, logger: logger.child("TrackStore") });
1768
+ this.mediaStore = new MediaStore({
1769
+ storage,
1770
+ store: api.settingsStore,
1771
+ logger: logger.child("MediaStore")
1772
+ });
1773
+ this.eventStore = new EventStore({ store: api.settingsStore, logger: logger.child("EventStore") });
1774
+ this.bindingCache = new BindingCache({ api, logger: logger.child("BindingCache") });
1775
+ this.zoneAnalytics = new ZoneAnalyticsProvider({
1776
+ logger: logger.child("ZoneAnalytics"),
1777
+ fetchDevice: (deviceId) => this.ctx.fetchDevice(deviceId)
1778
+ });
1779
+ this.audioMetrics = new AudioMetricsProvider({
1780
+ logger: logger.child("AudioMetrics"),
1781
+ fetchDevice: (deviceId) => this.ctx.fetchDevice(deviceId),
1782
+ // Per-camera report knobs — operator tunes via the
1783
+ // device-settings panel ("Audio metrics reporting" section).
1784
+ // Cascade is global default → device override; raw read goes
1785
+ // straight through ctx.settings, no kernel round-trip.
1786
+ resolveReportSettings: async (deviceId) => {
1787
+ const raw = await this.ctx?.settings?.readDeviceStore(deviceId) ?? {};
1788
+ const enabled = typeof raw["audioMetricsReportEnabled"] === "boolean" ? raw["audioMetricsReportEnabled"] : true;
1789
+ const thresholdRaw = raw["audioMetricsReportThresholdDb"];
1790
+ const thresholdDb = typeof thresholdRaw === "number" && Number.isFinite(thresholdRaw) ? Math.max(0, Math.min(10, thresholdRaw)) : 2;
1791
+ return { enabled, thresholdDb };
1792
+ }
1793
+ });
1794
+ this.unsubInference = this.ctx.eventBus.subscribe(
1795
+ { category: types.EventCategory.PipelineInferenceResult },
1796
+ (ev) => {
1797
+ void this.handleInferenceResult(ev.data);
1798
+ }
1799
+ );
1800
+ this.unsubAudio = this.ctx.eventBus.subscribe(
1801
+ { category: types.EventCategory.PipelineAudioInferenceResult },
1802
+ (ev) => {
1803
+ void this.handleAudioResult(ev.data);
1804
+ }
1805
+ );
1806
+ this.unsubMotion = this.ctx.eventBus.subscribe(
1807
+ { category: types.EventCategory.MotionAnalysis },
1808
+ (ev) => {
1809
+ const src = ev.source;
1810
+ if (src?.type !== "device" || typeof src.deviceId !== "number") return;
1811
+ void this.handleMotionAnalysis(src.deviceId, ev.data, ev.timestamp instanceof Date ? ev.timestamp.getTime() : Date.now());
1812
+ }
1813
+ );
1814
+ this.unsubMotionOnboard = this.ctx.eventBus.subscribe(
1815
+ { category: types.EventCategory.MotionOnMotionChanged },
1816
+ (ev) => {
1817
+ const data = ev.data;
1818
+ if (data.source === "analyzer") return;
1819
+ const timestamp = typeof data.timestamp === "number" ? data.timestamp : ev.timestamp instanceof Date ? ev.timestamp.getTime() : Date.now();
1820
+ void this.handleOnboardMotion(data.deviceId, data, timestamp);
1821
+ }
1822
+ );
1823
+ this.unsubBindings = this.ctx.eventBus.subscribe(
1824
+ { category: types.EventCategory.DeviceBindingsChanged },
1825
+ (ev) => {
1826
+ const data = ev.data;
1827
+ this.bindingCache?.onBindingsChanged(data);
1828
+ if (data.capName === "pipeline-analytics" && data.reason === "wrapper-deactivated") {
1829
+ this.trackStore?.clearDevice(data.deviceId);
1830
+ this.processors.delete(data.deviceId);
1831
+ this.lastActiveTrackIds.delete(data.deviceId);
1832
+ }
1833
+ }
1834
+ );
1835
+ this.unsubDeviceUnreg = this.ctx.eventBus.subscribe(
1836
+ { category: types.EventCategory.DeviceUnregistered },
1837
+ (ev) => {
1838
+ const { deviceId } = ev.data;
1839
+ this.trackStore?.clearDevice(deviceId);
1840
+ this.processors.delete(deviceId);
1841
+ this.lastActiveTrackIds.delete(deviceId);
1842
+ this.bindingCache?.invalidate(deviceId);
1843
+ this.zoneAnalytics?.forgetDevice(deviceId);
1844
+ this.audioMetrics?.forgetDevice(deviceId);
1845
+ this.releaseProxy(deviceId);
1846
+ }
1847
+ );
1848
+ this.ttlSweepTimer = setInterval(() => {
1849
+ void this.sweepExpiredTracks();
1850
+ }, TTL_SWEEP_INTERVAL_MS);
1851
+ this.retentionSweepTimer = setInterval(() => {
1852
+ void this.sweepRetention();
1853
+ }, RETENTION_SWEEP_INTERVAL_MS);
1854
+ this.ctx.logger.info("pipeline-analytics subscribers installed");
1855
+ const widgetsProvider = {
1856
+ listWidgets: async () => [
1857
+ {
1858
+ stableId: "audio-history-chart",
1859
+ label: "Audio Level History",
1860
+ description: "Sparkline of audio dBFS over a configurable window with class hits.",
1861
+ icon: "activity",
1862
+ remoteName: "addon_pipeline_analytics_widgets",
1863
+ bundle: "remoteEntry.js",
1864
+ hosts: ["device-tab", "dashboard"],
1865
+ requires: { deviceContext: true, integrationContext: false },
1866
+ defaultSize: "lg",
1867
+ allowedSizes: ["md", "lg", "xl"],
1868
+ defaultColumns: 12,
1869
+ defaultRows: 2
1870
+ },
1871
+ {
1872
+ stableId: "audio-metrics-panel",
1873
+ label: "Audio Metrics",
1874
+ description: "Live dBFS + dominant class + per-class summary card.",
1875
+ icon: "mic",
1876
+ remoteName: "addon_pipeline_analytics_widgets",
1877
+ bundle: "remoteEntry.js",
1878
+ hosts: ["device-tab", "dashboard"],
1879
+ requires: { deviceContext: true, integrationContext: false },
1880
+ defaultSize: "md",
1881
+ allowedSizes: ["sm", "md", "lg"],
1882
+ defaultColumns: 6,
1883
+ defaultRows: 2
1884
+ },
1885
+ {
1886
+ stableId: "occupancy-history-chart",
1887
+ label: "Occupancy History",
1888
+ description: "Frame-wide tracked-object count over time.",
1889
+ icon: "users",
1890
+ remoteName: "addon_pipeline_analytics_widgets",
1891
+ bundle: "remoteEntry.js",
1892
+ hosts: ["device-tab", "dashboard"],
1893
+ requires: { deviceContext: true, integrationContext: false },
1894
+ defaultSize: "lg",
1895
+ allowedSizes: ["md", "lg", "xl"],
1896
+ defaultColumns: 12,
1897
+ defaultRows: 2
1898
+ },
1899
+ {
1900
+ stableId: "motion-history-chart",
1901
+ label: "Motion History",
1902
+ description: "Histogram of motion events over a configurable window.",
1903
+ icon: "activity",
1904
+ remoteName: "addon_pipeline_analytics_widgets",
1905
+ bundle: "remoteEntry.js",
1906
+ hosts: ["device-tab", "dashboard"],
1907
+ requires: { deviceContext: true, integrationContext: false },
1908
+ defaultSize: "lg",
1909
+ allowedSizes: ["md", "lg", "xl"],
1910
+ defaultColumns: 12,
1911
+ defaultRows: 2
1912
+ },
1913
+ {
1914
+ stableId: "detection-history-chart",
1915
+ label: "Detection History",
1916
+ description: "Stacked-by-class histogram of object detections.",
1917
+ icon: "eye",
1918
+ remoteName: "addon_pipeline_analytics_widgets",
1919
+ bundle: "remoteEntry.js",
1920
+ hosts: ["device-tab", "dashboard"],
1921
+ requires: { deviceContext: true, integrationContext: false },
1922
+ defaultSize: "lg",
1923
+ allowedSizes: ["md", "lg", "xl"],
1924
+ defaultColumns: 12,
1925
+ defaultRows: 2
1926
+ },
1927
+ {
1928
+ stableId: "live-occupancy-panel",
1929
+ label: "Live Occupancy",
1930
+ description: "Per-zone breakdown + frame-wide aggregate (no polling).",
1931
+ icon: "layers",
1932
+ remoteName: "addon_pipeline_analytics_widgets",
1933
+ bundle: "remoteEntry.js",
1934
+ hosts: ["device-tab", "dashboard"],
1935
+ requires: { deviceContext: true, integrationContext: false },
1936
+ defaultSize: "md",
1937
+ allowedSizes: ["sm", "md", "lg"],
1938
+ defaultColumns: 6,
1939
+ defaultRows: 2
1940
+ }
1941
+ ]
1942
+ };
1943
+ return [
1944
+ {
1945
+ capability: types.pipelineAnalyticsCapability,
1946
+ provider: this,
1947
+ kind: "wrapper",
1948
+ defaultActive: true
1949
+ },
1950
+ {
1951
+ capability: types.zoneAnalyticsCapability,
1952
+ provider: this.zoneAnalytics
1953
+ },
1954
+ {
1955
+ capability: types.audioMetricsCapability,
1956
+ provider: this.audioMetrics
1957
+ },
1958
+ {
1959
+ capability: types.addonWidgetsSourceCapability,
1960
+ provider: widgetsProvider
1961
+ }
1962
+ ];
1963
+ }
1964
+ async onShutdown() {
1965
+ this.shuttingDown = true;
1966
+ this.unsubInference?.();
1967
+ this.unsubInference = null;
1968
+ this.unsubAudio?.();
1969
+ this.unsubAudio = null;
1970
+ this.unsubMotion?.();
1971
+ this.unsubMotion = null;
1972
+ this.unsubMotionOnboard?.();
1973
+ this.unsubMotionOnboard = null;
1974
+ this.unsubBindings?.();
1975
+ this.unsubBindings = null;
1976
+ this.unsubDeviceUnreg?.();
1977
+ this.unsubDeviceUnreg = null;
1978
+ for (const id of this.proxies.keys()) this.releaseProxy(id);
1979
+ if (this.ttlSweepTimer) {
1980
+ clearInterval(this.ttlSweepTimer);
1981
+ this.ttlSweepTimer = null;
1982
+ }
1983
+ if (this.retentionSweepTimer) {
1984
+ clearInterval(this.retentionSweepTimer);
1985
+ this.retentionSweepTimer = null;
1986
+ }
1987
+ this.zoneAnalytics?.destroy();
1988
+ this.audioMetrics?.destroy();
1989
+ this.processors.clear();
1990
+ this.lastActiveTrackIds.clear();
1991
+ this.trackStore?.clearAll();
1992
+ this.bindingCache?.clearAll();
1993
+ }
1994
+ // ── Subscriber handlers ──────────────────────────────────────────────
1995
+ async handleInferenceResult(data) {
1996
+ if (this.shuttingDown) return;
1997
+ const { deviceId, frame } = data;
1998
+ const active = await this.bindingCache.isActive(deviceId);
1999
+ if (!active) return;
2000
+ const processor = this.getOrCreateProcessor(deviceId);
2001
+ const proxy = await this.ensureProxy(deviceId);
2002
+ const liveZones = proxy?.state.zones.value?.zones ?? [];
2003
+ const liveRules = proxy?.state.zoneRules.value?.detection ?? [];
2004
+ processor.setZones(liveZones);
2005
+ processor.setDetectionRules(liveRules);
2006
+ const result = processor.process({ timestamp: frame.timestamp, frame });
2007
+ void this.zoneAnalytics?.recordFrame({
2008
+ deviceId,
2009
+ timestamp: result.timestamp,
2010
+ frameWidth: result.frameWidth,
2011
+ frameHeight: result.frameHeight,
2012
+ tracked: result.tracked,
2013
+ zones: liveZones
2014
+ });
2015
+ const currentTrackIds = /* @__PURE__ */ new Set();
2016
+ for (const t of result.tracked) {
2017
+ currentTrackIds.add(t.trackId);
2018
+ const center = {
2019
+ x: t.bbox.x + t.bbox.w / 2,
2020
+ y: t.bbox.y + t.bbox.h / 2
2021
+ };
2022
+ const raw = result.rawTrackedDetections.find((r) => r.trackId === t.trackId);
2023
+ this.trackStore.upsert({
2024
+ trackId: t.trackId,
2025
+ deviceId,
2026
+ className: t.className,
2027
+ ...raw?.originalClass && raw.originalClass !== t.className ? { label: raw.originalClass } : {},
2028
+ timestamp: result.timestamp,
2029
+ position: {
2030
+ x: center.x,
2031
+ y: center.y,
2032
+ timestamp: result.timestamp,
2033
+ bbox: { ...t.bbox }
2034
+ },
2035
+ zones: t.zones,
2036
+ state: t.state
2037
+ });
2038
+ }
2039
+ const prevIds = this.lastActiveTrackIds.get(deviceId) ?? /* @__PURE__ */ new Set();
2040
+ for (const id of currentTrackIds) {
2041
+ if (!prevIds.has(id)) {
2042
+ const t = result.tracked.find((x) => x.trackId === id);
2043
+ if (t) {
2044
+ this.ctx.eventBus.emit({
2045
+ id: `pa-${node_crypto.randomUUID()}`,
2046
+ timestamp: new Date(result.timestamp),
2047
+ source: { type: "addon", id: "pipeline-analytics", addonId: "pipeline-analytics" },
2048
+ category: types.EventCategory.PipelineAnalyticsTrackStarted,
2049
+ data: { deviceId, trackId: id, className: t.className }
2050
+ });
2051
+ }
2052
+ }
2053
+ }
2054
+ this.lastActiveTrackIds.set(deviceId, currentTrackIds);
2055
+ await Promise.all(result.objectEvents.map((e) => this.eventStore.insertObject(e)));
2056
+ for (const e of result.objectEvents) {
2057
+ this.ctx.eventBus.emit({
2058
+ id: `pa-${e.id}`,
2059
+ timestamp: new Date(e.timestamp),
2060
+ source: { type: "addon", id: "pipeline-analytics", addonId: "pipeline-analytics" },
2061
+ category: types.EventCategory.PipelineAnalyticsDetectionEvent,
2062
+ data: { deviceId, kind: "object", eventId: e.id, timestamp: e.timestamp }
2063
+ });
2064
+ }
2065
+ this.ctx.eventBus.emit({
2066
+ id: `pa-${node_crypto.randomUUID()}`,
2067
+ timestamp: new Date(result.timestamp),
2068
+ source: { type: "addon", id: "pipeline-analytics", addonId: "pipeline-analytics" },
2069
+ category: types.EventCategory.PipelineAnalyticsFrameTracked,
2070
+ data: {
2071
+ deviceId,
2072
+ timestamp: result.timestamp,
2073
+ frameWidth: result.frameWidth,
2074
+ frameHeight: result.frameHeight,
2075
+ detections: result.tracked
2076
+ }
2077
+ });
2078
+ }
2079
+ async handleAudioResult(data) {
2080
+ if (this.shuttingDown) return;
2081
+ const { deviceId, frame } = data;
2082
+ const active = await this.bindingCache.isActive(deviceId);
2083
+ if (!active) return;
2084
+ const level = frame.level;
2085
+ const timestamp = frame.timestamp ?? Date.now();
2086
+ const topClassification = frame.detections && frame.detections.length > 0 ? frame.detections[0] : void 0;
2087
+ void this.audioMetrics?.recordAudioFrame({
2088
+ deviceId,
2089
+ timestamp,
2090
+ ...level ? { level } : {},
2091
+ ...topClassification ? {
2092
+ topClassification: {
2093
+ className: topClassification.macroClass,
2094
+ score: topClassification.score
2095
+ }
2096
+ } : {}
2097
+ });
2098
+ const className = topClassification?.macroClass;
2099
+ const scoreFloor = 0.4;
2100
+ const hasMeaningfulClassification = className !== void 0 && (topClassification?.score ?? 0) >= scoreFloor;
2101
+ const dbfsFloor = -55;
2102
+ const aboveSilence = (level?.dbfs ?? -Infinity) > dbfsFloor;
2103
+ if (!hasMeaningfulClassification && !aboveSilence) return;
2104
+ const last = this.lastAudioInsertByDevice.get(deviceId);
2105
+ const sameClass = last?.className === className;
2106
+ const heartbeatDue = !last || timestamp - last.atMs >= AUDIO_EVENT_HEARTBEAT_MS;
2107
+ if (sameClass && !heartbeatDue) return;
2108
+ const ev = {
2109
+ id: node_crypto.randomUUID(),
2110
+ deviceId,
2111
+ timestamp,
2112
+ kind: "audio",
2113
+ rms: level?.rms ?? 0,
2114
+ dbfs: level?.dbfs ?? 0,
2115
+ ...topClassification ? {
2116
+ classification: {
2117
+ className: topClassification.macroClass,
2118
+ ...topClassification.debug?.originalClass !== void 0 ? { originalClass: topClassification.debug.originalClass } : {},
2119
+ score: topClassification.score
2120
+ }
2121
+ } : {}
2122
+ };
2123
+ this.lastAudioInsertByDevice.set(deviceId, { className, atMs: timestamp });
2124
+ await this.eventStore.insertAudio(ev);
2125
+ this.ctx.eventBus.emit({
2126
+ id: `pa-${ev.id}`,
2127
+ timestamp: new Date(ev.timestamp),
2128
+ source: { type: "addon", id: "pipeline-analytics", addonId: "pipeline-analytics" },
2129
+ category: types.EventCategory.PipelineAnalyticsDetectionEvent,
2130
+ data: { deviceId, kind: "audio", eventId: ev.id, timestamp: ev.timestamp }
2131
+ });
2132
+ }
2133
+ /**
2134
+ * Persist motion-analysis bus events. Mirrors `handleAudioResult`'s
2135
+ * coalescing strategy: emit one row on off→on transition, then one
2136
+ * per `MOTION_EVENT_HEARTBEAT_MS` while motion stays detected. No
2137
+ * row on off→off (silence) or while heartbeat hasn't elapsed.
2138
+ *
2139
+ * Gated on the same `pipeline-analytics` wrapper binding as the
2140
+ * inference + audio handlers so toggling the wrapper off for a
2141
+ * camera stops every kind of event persistence at once.
2142
+ */
2143
+ async handleMotionAnalysis(deviceId, payload, timestamp) {
2144
+ if (this.shuttingDown) return;
2145
+ const active = await this.bindingCache.isActive(deviceId);
2146
+ if (!active) return;
2147
+ const detected = payload.detected === true;
2148
+ const last = this.lastMotionInsertByDevice.get(deviceId);
2149
+ const heartbeatDue = !last || timestamp - last.atMs >= MOTION_EVENT_HEARTBEAT_MS;
2150
+ const transitionedOn = detected && (last === void 0 || last.detected === false);
2151
+ if (!detected) return;
2152
+ if (!transitionedOn && !heartbeatDue) return;
2153
+ const ev = {
2154
+ id: node_crypto.randomUUID(),
2155
+ deviceId,
2156
+ timestamp,
2157
+ kind: "motion",
2158
+ regionCount: payload.regionCount,
2159
+ regions: payload.regions.map((r) => ({
2160
+ bbox: { x: r.bbox.x, y: r.bbox.y, w: r.bbox.w, h: r.bbox.h },
2161
+ pixelCount: r.pixelCount,
2162
+ intensity: r.intensity
2163
+ })),
2164
+ frameWidth: payload.frameWidth,
2165
+ frameHeight: payload.frameHeight
2166
+ };
2167
+ this.lastMotionInsertByDevice.set(deviceId, { detected, atMs: timestamp });
2168
+ await this.eventStore.insertMotion(ev);
2169
+ this.ctx.eventBus.emit({
2170
+ id: `pa-${ev.id}`,
2171
+ timestamp: new Date(ev.timestamp),
2172
+ source: { type: "addon", id: "pipeline-analytics", addonId: "pipeline-analytics" },
2173
+ category: types.EventCategory.PipelineAnalyticsDetectionEvent,
2174
+ data: { deviceId, kind: "motion", eventId: ev.id, timestamp: ev.timestamp }
2175
+ });
2176
+ }
2177
+ /**
2178
+ * Persist firmware-driven (onboard) motion events. Mirrors
2179
+ * {@link handleMotionAnalysis}'s coalescing — one row on off→on
2180
+ * transition, then one per `MOTION_EVENT_HEARTBEAT_MS` while motion
2181
+ * stays detected — but reads from the {@link MotionOnMotionChangedPayload}
2182
+ * shape which lacks frame dimensions and pixel-level region details
2183
+ * (firmware reports a binary detected flag plus, on rare devices, a
2184
+ * coarse bbox via the optional `regions` field).
2185
+ *
2186
+ * The same `lastMotionInsertByDevice` map is shared with the analyzer
2187
+ * path so a camera that briefly switches `motionSources` between
2188
+ * `onboard` and `analyzer` gets consistent throttling — both paths
2189
+ * are mutually exclusive at the event-emit layer (the runner only
2190
+ * fires `MotionAnalysis` when its analyzer runs; onboard providers
2191
+ * never fire `MotionAnalysis`), so there's no double-count risk.
2192
+ */
2193
+ async handleOnboardMotion(deviceId, payload, timestamp) {
2194
+ if (this.shuttingDown) return;
2195
+ const active = await this.bindingCache.isActive(deviceId);
2196
+ if (!active) return;
2197
+ const detected = payload.detected === true;
2198
+ const last = this.lastMotionInsertByDevice.get(deviceId);
2199
+ const heartbeatDue = !last || timestamp - last.atMs >= MOTION_EVENT_HEARTBEAT_MS;
2200
+ const transitionedOn = detected && (last === void 0 || last.detected === false);
2201
+ if (!detected) return;
2202
+ if (!transitionedOn && !heartbeatDue) return;
2203
+ const regions = payload.regions ?? [];
2204
+ const ev = {
2205
+ id: node_crypto.randomUUID(),
2206
+ deviceId,
2207
+ timestamp,
2208
+ kind: "motion",
2209
+ regionCount: regions.length,
2210
+ regions: regions.map((r) => ({
2211
+ bbox: { x: r.bbox.x, y: r.bbox.y, w: r.bbox.w, h: r.bbox.h },
2212
+ pixelCount: r.pixelCount,
2213
+ intensity: r.intensity
2214
+ })),
2215
+ frameWidth: 0,
2216
+ frameHeight: 0
2217
+ };
2218
+ this.lastMotionInsertByDevice.set(deviceId, { detected, atMs: timestamp });
2219
+ await this.eventStore.insertMotion(ev);
2220
+ this.ctx.eventBus.emit({
2221
+ id: `pa-${ev.id}`,
2222
+ timestamp: new Date(ev.timestamp),
2223
+ source: { type: "addon", id: "pipeline-analytics", addonId: "pipeline-analytics" },
2224
+ category: types.EventCategory.PipelineAnalyticsDetectionEvent,
2225
+ data: { deviceId, kind: "motion", eventId: ev.id, timestamp: ev.timestamp }
2226
+ });
2227
+ }
2228
+ // ── Housekeeping ─────────────────────────────────────────────────────
2229
+ async sweepExpiredTracks() {
2230
+ if (this.shuttingDown || !this.trackStore) return;
2231
+ try {
2232
+ const expired = await this.trackStore.expireStale(Date.now());
2233
+ for (const t of expired) {
2234
+ const duration = t.lastSeen - t.firstSeen;
2235
+ this.ctx.eventBus.emit({
2236
+ id: `pa-end-${t.trackId}`,
2237
+ timestamp: new Date(t.lastSeen),
2238
+ source: { type: "addon", id: "pipeline-analytics", addonId: "pipeline-analytics" },
2239
+ category: types.EventCategory.PipelineAnalyticsTrackEnded,
2240
+ data: {
2241
+ deviceId: t.deviceId,
2242
+ trackId: t.trackId,
2243
+ className: t.className,
2244
+ durationMs: duration
2245
+ }
2246
+ });
2247
+ }
2248
+ } catch (err) {
2249
+ if (this.shuttingDown) return;
2250
+ this.ctx.logger.debug("sweepExpiredTracks failed", { meta: { error: String(err) } });
2251
+ }
2252
+ }
2253
+ async sweepRetention() {
2254
+ if (this.shuttingDown || !this.eventStore || !this.mediaStore) return;
2255
+ const now = Date.now();
2256
+ const day = 24 * 60 * 60 * 1e3;
2257
+ try {
2258
+ await this.eventStore.evictBefore({
2259
+ motionCutoffMs: now - 14 * day,
2260
+ objectCutoffMs: now - 30 * day,
2261
+ audioCutoffMs: now - 7 * day
2262
+ });
2263
+ await this.mediaStore.evictBefore(now - 14 * day);
2264
+ } catch (err) {
2265
+ this.ctx.logger.debug("sweepRetention failed", { meta: { error: String(err) } });
2266
+ }
2267
+ }
2268
+ getOrCreateProcessor(deviceId) {
2269
+ let p = this.processors.get(deviceId);
2270
+ if (!p) {
2271
+ p = new FrameProcessor(deviceId);
2272
+ this.processors.set(deviceId, p);
2273
+ }
2274
+ return p;
2275
+ }
2276
+ /**
2277
+ * Resolve and cache a {@link DeviceProxy} for a device. Pins the
2278
+ * `state.zones` + `state.zoneRules` slice handles so `.value` stays
2279
+ * warm via the kernel runtime-state mirror. Slice subscribers also
2280
+ * forward updates to the per-device FrameProcessor so a rule change
2281
+ * applies to the very next frame even when frames stop briefly
2282
+ * (e.g. during binding flips).
2283
+ */
2284
+ async ensureProxy(deviceId) {
2285
+ const cached = this.proxies.get(deviceId);
2286
+ if (cached) return cached;
2287
+ try {
2288
+ const proxy = await this.ctx.api.deviceManager ? await this.ctx.fetchDevice(deviceId) : null;
2289
+ if (!proxy) return null;
2290
+ this.proxies.set(deviceId, proxy);
2291
+ const unsubs = [
2292
+ proxy.state.zones.subscribe((slice) => {
2293
+ const zones = slice?.zones ?? [];
2294
+ this.processors.get(deviceId)?.setZones(zones);
2295
+ }),
2296
+ proxy.state.zoneRules.subscribe((slice) => {
2297
+ const rules = slice?.detection ?? [];
2298
+ this.processors.get(deviceId)?.setDetectionRules(rules);
2299
+ })
2300
+ ];
2301
+ this.proxyUnsubs.set(deviceId, unsubs);
2302
+ return proxy;
2303
+ } catch (err) {
2304
+ this.ctx.logger.debug("analytics ensureProxy failed", {
2305
+ tags: { deviceId },
2306
+ meta: { error: err instanceof Error ? err.message : String(err) }
2307
+ });
2308
+ return null;
2309
+ }
2310
+ }
2311
+ releaseProxy(deviceId) {
2312
+ const unsubs = this.proxyUnsubs.get(deviceId);
2313
+ if (unsubs) {
2314
+ for (const u of unsubs) {
2315
+ try {
2316
+ u();
2317
+ } catch {
2318
+ }
2319
+ }
2320
+ }
2321
+ this.proxyUnsubs.delete(deviceId);
2322
+ this.proxies.delete(deviceId);
2323
+ }
2324
+ // ── Cap query methods ────────────────────────────────────────────────
2325
+ async getActiveTracks(input) {
2326
+ return this.trackStore?.getActive(input.deviceId) ?? [];
2327
+ }
2328
+ async getTrack(input) {
2329
+ const active = this.trackStore?.getActiveByTrack(input.trackId);
2330
+ if (active) return active;
2331
+ return await this.trackStore?.getPersistedByTrackId(input.trackId) ?? null;
2332
+ }
2333
+ async listTracks(input) {
2334
+ return this.trackStore?.queryHistorical(input) ?? [];
2335
+ }
2336
+ async clearTracks(input) {
2337
+ this.trackStore?.clearDevice(input.deviceId);
2338
+ this.lastActiveTrackIds.delete(input.deviceId);
2339
+ }
2340
+ async getMotionEvents(input) {
2341
+ return this.eventStore?.queryMotion(input) ?? [];
2342
+ }
2343
+ async getObjectEvents(input) {
2344
+ return this.eventStore?.queryObject(input) ?? [];
2345
+ }
2346
+ async getAudioEvents(input) {
2347
+ return this.eventStore?.queryAudio(input) ?? [];
2348
+ }
2349
+ async getEventMedia(input) {
2350
+ return this.mediaStore?.listByOwner("event", input.eventId) ?? [];
2351
+ }
2352
+ async getTrackMedia(input) {
2353
+ return this.mediaStore?.listByOwner("track", input.trackId) ?? [];
2354
+ }
2355
+ // ── Global + per-device settings (P8) ─────────────────────────────
2356
+ //
2357
+ // Cascade: global defaults live on this addon's global settings
2358
+ // (getGlobalSettings); per-device overrides live on the device
2359
+ // settings aggregator (getDeviceSettingsContribution → BindingsTab's
2360
+ // device panel). A missing device override falls back to the global
2361
+ // default — same pattern pipeline-orchestrator uses.
2362
+ globalSettingsSchema() {
2363
+ return this.schema({
2364
+ sections: [
2365
+ {
2366
+ id: "tracking-core",
2367
+ title: "Tracking",
2368
+ description: "Default track lifecycle + history knobs. Per-device overrides on each camera.",
2369
+ columns: 2,
2370
+ fields: [
2371
+ {
2372
+ type: "slider",
2373
+ key: "trackTtlMs",
2374
+ label: "Track TTL",
2375
+ description: "Milliseconds without a sighting before a track is finalized and promoted to history.",
2376
+ min: 5e3,
2377
+ max: 12e4,
2378
+ step: 1e3,
2379
+ default: 3e4,
2380
+ showValue: true,
2381
+ unit: "s",
2382
+ displayScale: 1e3
2383
+ },
2384
+ {
2385
+ type: "slider",
2386
+ key: "maxPositionHistory",
2387
+ label: "Max positions per track",
2388
+ description: "Positions retained in memory; older entries get first-half downsampled.",
2389
+ min: 50,
2390
+ max: 1e3,
2391
+ step: 50,
2392
+ default: 300,
2393
+ showValue: true
2394
+ }
2395
+ ]
2396
+ },
2397
+ {
2398
+ id: "media-policy",
2399
+ title: "Snapshots & media",
2400
+ columns: 2,
2401
+ fields: [
2402
+ {
2403
+ type: "boolean",
2404
+ key: "saveThumbnails",
2405
+ label: "Save track thumbnails",
2406
+ description: "Periodic snapshot crops attached to tracks for timeline review.",
2407
+ default: true
2408
+ },
2409
+ {
2410
+ type: "slider",
2411
+ key: "snapshotIntervalMs",
2412
+ label: "Snapshot interval",
2413
+ description: "How often a snapshot is persisted per active track.",
2414
+ min: 500,
2415
+ max: 6e4,
2416
+ step: 500,
2417
+ default: 2e3,
2418
+ showValue: true,
2419
+ unit: "s",
2420
+ displayScale: 1e3
2421
+ },
2422
+ {
2423
+ type: "select",
2424
+ key: "mediaAttachPolicy",
2425
+ label: "Attach media to events",
2426
+ description: "Which events get a persisted crop.",
2427
+ default: "motion",
2428
+ options: [
2429
+ { value: "always", label: "Every event" },
2430
+ { value: "motion", label: "Only while motion is active" },
2431
+ { value: "never", label: "Never" }
2432
+ ]
2433
+ }
2434
+ ]
2435
+ },
2436
+ {
2437
+ id: "audio-metrics-report",
2438
+ title: "Audio metrics reporting",
2439
+ description: "Coalesce audio-metrics state updates so the events tab + bus aren't flooded by per-second dBFS jitter on idle scenes. Per-camera override.",
2440
+ columns: 2,
2441
+ fields: [
2442
+ {
2443
+ type: "boolean",
2444
+ key: "audioMetricsReportEnabled",
2445
+ label: "Mirror to device-state",
2446
+ description: "When off, the audio classifier still runs (the live AUDIO METRICS panel reads through the cap method), but the periodic state-changed events are suppressed. Use for cameras where audio is informational only.",
2447
+ default: true
2448
+ },
2449
+ {
2450
+ type: "slider",
2451
+ key: "audioMetricsReportThresholdDb",
2452
+ label: "Report threshold",
2453
+ description: "Minimum dBFS delta between consecutive snapshots to trigger an emit. 2dB means quiet noise jitter (±1dB) is suppressed; class changes always emit regardless. Lower = more updates.",
2454
+ min: 0,
2455
+ max: 10,
2456
+ step: 1,
2457
+ default: 2,
2458
+ showValue: true,
2459
+ unit: "dB"
2460
+ }
2461
+ ]
2462
+ },
2463
+ {
2464
+ id: "retention",
2465
+ title: "Retention",
2466
+ description: "How long each event kind is kept in the SQL store. Media files follow the minimum of these.",
2467
+ columns: 3,
2468
+ fields: [
2469
+ {
2470
+ type: "number",
2471
+ key: "retentionMotionDays",
2472
+ label: "Motion events",
2473
+ min: 1,
2474
+ max: 365,
2475
+ step: 1,
2476
+ default: 14,
2477
+ unit: "days"
2478
+ },
2479
+ {
2480
+ type: "number",
2481
+ key: "retentionObjectDays",
2482
+ label: "Object events",
2483
+ min: 1,
2484
+ max: 365,
2485
+ step: 1,
2486
+ default: 30,
2487
+ unit: "days"
2488
+ },
2489
+ {
2490
+ type: "number",
2491
+ key: "retentionAudioDays",
2492
+ label: "Audio events",
2493
+ min: 1,
2494
+ max: 365,
2495
+ step: 1,
2496
+ default: 7,
2497
+ unit: "days"
2498
+ }
2499
+ ]
2500
+ }
2501
+ ]
2502
+ });
2503
+ }
2504
+ async getDeviceSettingsContribution(input) {
2505
+ if (!await this.isCameraDevice(input.deviceId)) return null;
2506
+ const schema = this.globalSettingsSchema();
2507
+ const raw = await this.ctx?.settings?.readDeviceStore(input.deviceId) ?? {};
2508
+ const baseSections = schema ? types.hydrateSchema(
2509
+ {
2510
+ ...schema,
2511
+ sections: schema.sections.map((s) => ({ ...s, tab: s.tab ?? "Analytics" }))
2512
+ },
2513
+ raw
2514
+ ).sections : [];
2515
+ const liveStatsSection = {
2516
+ id: "live-stats",
2517
+ title: "Live Stats",
2518
+ tab: "live-stats",
2519
+ location: "top-tab",
2520
+ columns: 1,
2521
+ order: 0,
2522
+ fields: [
2523
+ {
2524
+ type: "live-stats",
2525
+ key: "liveStats",
2526
+ label: "Live Stats",
2527
+ span: 1,
2528
+ // Renderer resolves `deviceId` via `useDeviceContext`; the
2529
+ // hydrated value is unused — kept for the form runtime
2530
+ // which requires a value field on every entry.
2531
+ value: void 0
2532
+ }
2533
+ ]
2534
+ };
2535
+ return { sections: [...baseSections, liveStatsSection] };
2536
+ }
2537
+ async getDeviceLiveContribution(_input) {
2538
+ return null;
2539
+ }
2540
+ async applyDeviceSettingsPatch(input) {
2541
+ await this.updateDeviceSettings(input.deviceId, input.patch);
2542
+ return { success: true };
2543
+ }
2544
+ /**
2545
+ * Best-effort camera-type check. Used to short-circuit the settings
2546
+ * contribution on non-camera devices. Returns `true` on lookup
2547
+ * failure so a transient device-manager hiccup never silently hides
2548
+ * legitimate camera sections.
2549
+ */
2550
+ async isCameraDevice(deviceId) {
2551
+ const api = this.ctx?.api;
2552
+ if (!api) return true;
2553
+ try {
2554
+ const dev = await api.deviceManager.getDevice.query({ deviceId });
2555
+ if (!dev) return true;
2556
+ return dev.type === types.DeviceType.Camera;
2557
+ } catch {
2558
+ return true;
2559
+ }
2560
+ }
2561
+ }
2562
+ module.exports = PipelineAnalyticsAddon;
2563
+ //# sourceMappingURL=index.js.map