@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.
- package/dist/@mf-types/compiled-types/widgets/AudioHistoryChart.d.ts +5 -0
- package/dist/@mf-types/compiled-types/widgets/AudioHistoryChart.d.ts.map +1 -0
- package/dist/@mf-types/compiled-types/widgets/AudioMetricsPanel.d.ts +11 -0
- package/dist/@mf-types/compiled-types/widgets/AudioMetricsPanel.d.ts.map +1 -0
- package/dist/@mf-types/compiled-types/widgets/DetectionHistoryChart.d.ts +5 -0
- package/dist/@mf-types/compiled-types/widgets/DetectionHistoryChart.d.ts.map +1 -0
- package/dist/@mf-types/compiled-types/widgets/MotionHistoryChart.d.ts +5 -0
- package/dist/@mf-types/compiled-types/widgets/MotionHistoryChart.d.ts.map +1 -0
- package/dist/@mf-types/compiled-types/widgets/OccupancyHistoryChart.d.ts +5 -0
- package/dist/@mf-types/compiled-types/widgets/OccupancyHistoryChart.d.ts.map +1 -0
- package/dist/@mf-types/compiled-types/widgets/OccupancyPanel.d.ts +11 -0
- package/dist/@mf-types/compiled-types/widgets/OccupancyPanel.d.ts.map +1 -0
- package/dist/@mf-types/compiled-types/widgets/chart-utils.d.ts +98 -0
- package/dist/@mf-types/compiled-types/widgets/chart-utils.d.ts.map +1 -0
- package/dist/@mf-types/compiled-types/widgets/index.d.ts +28 -0
- package/dist/@mf-types/compiled-types/widgets/index.d.ts.map +1 -0
- package/dist/@mf-types/widgets.d.ts +2 -0
- package/dist/@mf-types.d.ts +3 -0
- package/dist/@mf-types.zip +0 -0
- package/dist/__mfe_internal__addon_pipeline_analytics_widgets__loadShare___mf_0_camstack_mf_1_sdk__loadShare__.mjs-CCBTZBOa.mjs +12 -0
- package/dist/__mfe_internal__addon_pipeline_analytics_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.mjs-OesvKBZV.mjs +16 -0
- package/dist/__mfe_internal__addon_pipeline_analytics_widgets__loadShare___mf_0_camstack_mf_1_ui_mf_2_library__loadShare__.mjs-D0mniK1l.mjs +15 -0
- package/dist/__mfe_internal__addon_pipeline_analytics_widgets__loadShare___mf_0_tanstack_mf_1_react_mf_2_query__loadShare__.mjs-DoWbefqS.mjs +104 -0
- package/dist/__mfe_internal__addon_pipeline_analytics_widgets__loadShare___mf_0_trpc_mf_1_client__loadShare__.mjs-52bfkwC8.mjs +85 -0
- package/dist/__mfe_internal__addon_pipeline_analytics_widgets__loadShare___mf_0_trpc_mf_1_react_mf_2_query__loadShare__.mjs-CVrnrGED.mjs +62 -0
- package/dist/__mfe_internal__addon_pipeline_analytics_widgets__loadShare__react__loadShare__.mjs-DuO9h7li.mjs +85 -0
- package/dist/__mfe_internal__addon_pipeline_analytics_widgets__loadShare__react__loadShare__.mjs_commonjs-proxy-CmqNjq44.mjs +29 -0
- package/dist/__mfe_internal__addon_pipeline_analytics_widgets__loadShare__react_mf_1_jsx_mf_2_runtime__loadShare__.mjs-BsyrX6NO.mjs +36 -0
- package/dist/__mfe_internal__addon_pipeline_analytics_widgets__loadShare__react_mf_2_dom__loadShare__.mjs-Dp8hqYOB.mjs +45 -0
- package/dist/__mfe_internal__addon_pipeline_analytics_widgets__loadShare__react_mf_2_dom__loadShare__.mjs_commonjs-proxy-CA8cCIEl.mjs +6 -0
- package/dist/__mfe_internal__addon_pipeline_analytics_widgets__loadShare__react_mf_2_dom_mf_1_client__loadShare__.mjs-BZjEt71l.mjs +34 -0
- package/dist/_stub.js +1397 -0
- package/dist/_virtual_mf-localSharedImportMap___mfe_internal__addon_pipeline_analytics_widgets-Cm7MAUA1.mjs +157 -0
- package/dist/client-DdXDZxzK.mjs +10063 -0
- package/dist/getErrorShape-BPSzUA7W-TlK8ipWe.mjs +211 -0
- package/dist/hostInit-WKMmag4S.mjs +168 -0
- package/dist/index-B4OKsa9p.mjs +2603 -0
- package/dist/index-C3iAUQqS.mjs +533 -0
- package/dist/index-D0dNM7_R.mjs +2892 -0
- package/dist/index-DKqbmJDl.mjs +2464 -0
- package/dist/index-DnFVXz0U.mjs +14162 -0
- package/dist/index-DyYvUfc7.mjs +725 -0
- package/dist/index-Oq45bZIA.mjs +17936 -0
- package/dist/index-k0CA0h_r.mjs +185 -0
- package/dist/index-kIgjN-uq.mjs +435 -0
- package/dist/index-xncRG7-x.mjs +2713 -0
- package/dist/index.d.mts +190 -0
- package/dist/index.d.ts +190 -0
- package/dist/index.js +2623 -0
- package/dist/index.js.map +1 -0
- package/dist/index.mjs +2602 -0
- package/dist/index.mjs.map +1 -0
- package/dist/jsx-runtime-4ro1c69i.mjs +55 -0
- package/dist/remoteEntry.js +85 -0
- package/dist/virtualExposes-8FzWTdq3.mjs +42 -0
- 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
|