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