@camstack/addon-post-analysis 1.2.17 → 1.2.19
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/{dist-ggFRVg94.mjs → dist-C41w6Xvl.mjs} +366 -75
- package/dist/{dist-BR-Tbrvb.js → dist-CjwPOJKc.js} +383 -74
- package/dist/embedding-encoder/index.js +1 -1
- package/dist/embedding-encoder/index.mjs +3 -1
- package/dist/{node-BuPMb-ti.mjs → node-BmVBZiOw.mjs} +11 -10
- package/dist/{node-DVltm5ai.js → node-Dwh-F2Zf.js} +2 -1
- package/dist/pipeline-analytics/_stub.js +1 -1
- package/dist/pipeline-analytics/{_virtual_mf-localSharedImportMap___mfe_internal__addon_pipeline_analytics_widgets-rep6ZaCi.mjs → _virtual_mf-localSharedImportMap___mfe_internal__addon_pipeline_analytics_widgets-C0HgiJXz.mjs} +2 -2
- package/dist/pipeline-analytics/{_virtual_mf___mfe_internal__addon_pipeline_analytics_widgets__loadShare___mf_0_camstack_mf_1_ui_mf_2_library__loadShare__.js-Cd6cUNQT.mjs → _virtual_mf___mfe_internal__addon_pipeline_analytics_widgets__loadShare___mf_0_camstack_mf_1_ui_mf_2_library__loadShare__.js-54-w8Eqz.mjs} +1 -1
- package/dist/pipeline-analytics/{hostInit-Bxinx42V.mjs → hostInit-DisaD6Xu.mjs} +2 -2
- package/dist/pipeline-analytics/index.js +1875 -179
- package/dist/pipeline-analytics/index.mjs +1875 -181
- package/dist/pipeline-analytics/remoteEntry.js +1 -1
- package/package.json +1 -1
|
@@ -2,10 +2,351 @@ Object.defineProperties(exports, {
|
|
|
2
2
|
__esModule: { value: true },
|
|
3
3
|
[Symbol.toStringTag]: { value: "Module" }
|
|
4
4
|
});
|
|
5
|
-
const require_dist = require("../dist-
|
|
5
|
+
const require_dist = require("../dist-CjwPOJKc.js");
|
|
6
|
+
let node_fs = require("node:fs");
|
|
7
|
+
let node_path = require("node:path");
|
|
8
|
+
node_path = require_dist.__toESM(node_path);
|
|
6
9
|
let node_crypto = require("node:crypto");
|
|
7
10
|
let sharp = require("sharp");
|
|
8
11
|
sharp = require_dist.__toESM(sharp);
|
|
12
|
+
//#region src/notification-center/artifact-url.ts
|
|
13
|
+
/**
|
|
14
|
+
* Signed, externally-reachable URLs for notification artifacts.
|
|
15
|
+
*
|
|
16
|
+
* WHY this exists: the dispatcher only ever produced attachment BYTES, and the
|
|
17
|
+
* degrade engine drops a bytes-only attachment for a `mode:'url'` target
|
|
18
|
+
* (`attachment:noUrl`) — so WhatsApp and gotify received no media at all,
|
|
19
|
+
* silently. A URL also lets an oversized attachment degrade to a link instead
|
|
20
|
+
* of being dropped on `maxBytes`.
|
|
21
|
+
*
|
|
22
|
+
* Two decisions the shape depends on:
|
|
23
|
+
*
|
|
24
|
+
* - **Signed, not authenticated.** The fetcher is a notifier BACKEND (or the
|
|
25
|
+
* recipient's phone) — it holds no session and no admin token. The route is
|
|
26
|
+
* therefore `access:'public'` and the authority is the signature: an
|
|
27
|
+
* unguessable HMAC over `(id, exp)` with a per-install secret, expiring on
|
|
28
|
+
* its own. Nothing else about the install is reachable through it.
|
|
29
|
+
* - **The base URL is CHOSEN, never assumed.** A notification carrying
|
|
30
|
+
* `http://127.0.0.1:...` is useless (the Alexa `hubUrl` bug, again). The
|
|
31
|
+
* ranked endpoint list the `local-network` cap already computes — public
|
|
32
|
+
* tunnel > mesh > LAN, loopback last — is the authority, and loopback is
|
|
33
|
+
* refused outright rather than shipped as a broken link.
|
|
34
|
+
*/
|
|
35
|
+
/**
|
|
36
|
+
* Pick the base URL an artifact link should use: the LOWEST-priority-number
|
|
37
|
+
* endpoint that is not loopback (the list is already ranked public > mesh >
|
|
38
|
+
* LAN > loopback). `null` when only loopback is available — a link nobody
|
|
39
|
+
* outside the host could open is worse than no link, because the notification
|
|
40
|
+
* would arrive advertising media that 404s.
|
|
41
|
+
*/
|
|
42
|
+
function pickArtifactBaseUrl(endpoints) {
|
|
43
|
+
return endpoints.filter((e) => e.kind !== "loopback").toSorted((a, b) => a.priority - b.priority)[0]?.baseUrl ?? null;
|
|
44
|
+
}
|
|
45
|
+
/** Sign `(id, exp)` — the exact string the verifier recomputes. */
|
|
46
|
+
function signArtifact(secret, id, expMs) {
|
|
47
|
+
return (0, node_crypto.createHmac)("sha256", secret).update(`${id}:${expMs}`).digest("hex");
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Build the fully-qualified, signed URL for an artifact.
|
|
51
|
+
* `baseUrl` comes from {@link pickArtifactBaseUrl}, `routePrefix` from the
|
|
52
|
+
* data-plane handle (`/addon/<addonId>/nc-artifact`).
|
|
53
|
+
*/
|
|
54
|
+
function buildArtifactUrl(input) {
|
|
55
|
+
const sig = signArtifact(input.secret, input.id, input.expMs);
|
|
56
|
+
return `${input.baseUrl.endsWith("/") ? input.baseUrl.slice(0, -1) : input.baseUrl}${input.routePrefix.startsWith("/") ? input.routePrefix : `/${input.routePrefix}`}/${encodeURIComponent(input.id)}?exp=${input.expMs}&sig=${sig}`;
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Verify a request's `(id, exp, sig)`. Constant-time on the signature so a
|
|
60
|
+
* public route cannot be probed for it byte by byte, and expiry-checked before
|
|
61
|
+
* the compare so an expired link is rejected even with a valid signature.
|
|
62
|
+
*/
|
|
63
|
+
function verifyArtifactSignature(input) {
|
|
64
|
+
if (!Number.isFinite(input.exp) || input.exp <= input.nowMs) return false;
|
|
65
|
+
const expected = signArtifact(input.secret, input.id, input.exp);
|
|
66
|
+
const a = Buffer.from(expected, "utf8");
|
|
67
|
+
const b = Buffer.from(input.sig, "utf8");
|
|
68
|
+
if (a.length !== b.length) return false;
|
|
69
|
+
return (0, node_crypto.timingSafeEqual)(a, b);
|
|
70
|
+
}
|
|
71
|
+
//#endregion
|
|
72
|
+
//#region src/notification-center/artifact-plane.ts
|
|
73
|
+
/**
|
|
74
|
+
* The hub's default HTTPS API port — the same constant `derive-hub-url.ts`
|
|
75
|
+
* documents. Only used for the LAN candidates; an operator who runs the hub on
|
|
76
|
+
* another port sets `CAMSTACK_HUB_PUBLIC_URL`, which wins outright.
|
|
77
|
+
*/
|
|
78
|
+
var HUB_API_PORT = 4443;
|
|
79
|
+
/**
|
|
80
|
+
* Ranking of the public sources. All NEGATIVE: `local-network` gives its
|
|
81
|
+
* preferred LAN interface priority `0`, so a public candidate has to sit below
|
|
82
|
+
* zero to outrank it.
|
|
83
|
+
*/
|
|
84
|
+
var PRIORITY_MARKED = -400;
|
|
85
|
+
var PRIORITY_CONFIGURED = -300;
|
|
86
|
+
var PRIORITY_CONNECTED = -200;
|
|
87
|
+
var PRIORITY_EXTERNAL_LIST = -100;
|
|
88
|
+
/**
|
|
89
|
+
* Build the ranked candidate list, in the operator's own order: an explicitly
|
|
90
|
+
* configured public origin, then the connected external ingress (tunnels),
|
|
91
|
+
* then the LAN addresses. Every source is best-effort — one that throws
|
|
92
|
+
* contributes nothing rather than costing the whole list.
|
|
93
|
+
*/
|
|
94
|
+
async function collectArtifactEndpoints(sources) {
|
|
95
|
+
const out = [];
|
|
96
|
+
const marked = sources.markedBaseUrl;
|
|
97
|
+
if (marked !== void 0 && marked !== "") out.push({
|
|
98
|
+
baseUrl: marked,
|
|
99
|
+
kind: "public",
|
|
100
|
+
priority: PRIORITY_MARKED
|
|
101
|
+
});
|
|
102
|
+
const configured = sources.configuredPublicUrl;
|
|
103
|
+
if (configured !== void 0 && configured !== "" && !/127\.0\.0\.1|localhost/.test(configured)) out.push({
|
|
104
|
+
baseUrl: configured,
|
|
105
|
+
kind: "public",
|
|
106
|
+
priority: PRIORITY_CONFIGURED
|
|
107
|
+
});
|
|
108
|
+
try {
|
|
109
|
+
const connected = await sources.getConnected();
|
|
110
|
+
if (connected !== null && connected.protocol === "https") out.push({
|
|
111
|
+
baseUrl: connected.url,
|
|
112
|
+
kind: "public",
|
|
113
|
+
priority: PRIORITY_CONNECTED
|
|
114
|
+
});
|
|
115
|
+
} catch (err) {
|
|
116
|
+
sources.logger.debug("connected endpoint lookup failed", { meta: { error: String(err) } });
|
|
117
|
+
}
|
|
118
|
+
try {
|
|
119
|
+
const external = await sources.listExternal();
|
|
120
|
+
for (const [i, e] of external.entries()) {
|
|
121
|
+
if (e.protocol !== "https") continue;
|
|
122
|
+
out.push({
|
|
123
|
+
baseUrl: e.url,
|
|
124
|
+
kind: "public",
|
|
125
|
+
priority: PRIORITY_EXTERNAL_LIST + i
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
} catch (err) {
|
|
129
|
+
sources.logger.debug("external endpoint lookup failed", { meta: { error: String(err) } });
|
|
130
|
+
}
|
|
131
|
+
try {
|
|
132
|
+
out.push(...await sources.listLan(HUB_API_PORT));
|
|
133
|
+
} catch (err) {
|
|
134
|
+
sources.logger.debug("LAN endpoint lookup failed", { meta: { error: String(err) } });
|
|
135
|
+
}
|
|
136
|
+
return out;
|
|
137
|
+
}
|
|
138
|
+
var DEFAULT_TTL_MS = 24 * 36e5;
|
|
139
|
+
var DEFAULT_ENDPOINT_CACHE_MS = 6e4;
|
|
140
|
+
var NcArtifactPlane = class {
|
|
141
|
+
deps;
|
|
142
|
+
now;
|
|
143
|
+
cachedBaseUrl = null;
|
|
144
|
+
/** Last base URL announced in the log — so the line appears on CHANGE only. */
|
|
145
|
+
lastLoggedBaseUrl = null;
|
|
146
|
+
cachedAt = 0;
|
|
147
|
+
constructor(deps) {
|
|
148
|
+
this.deps = deps;
|
|
149
|
+
this.now = deps.now ?? (() => Date.now());
|
|
150
|
+
}
|
|
151
|
+
/**
|
|
152
|
+
* Store bytes and mint a signed, externally-reachable URL for them.
|
|
153
|
+
* Returns `null` when no usable base URL exists (loopback only) — the caller
|
|
154
|
+
* then ships bytes alone, exactly as before. Never throws: an artifact URL is
|
|
155
|
+
* an ENHANCEMENT, and failing to mint one must not cost the notification.
|
|
156
|
+
*/
|
|
157
|
+
async publish(bytes, mime) {
|
|
158
|
+
try {
|
|
159
|
+
const baseUrl = await this.resolveBaseUrl();
|
|
160
|
+
if (baseUrl === null) return null;
|
|
161
|
+
const artifact = await this.deps.store.put(bytes, mime);
|
|
162
|
+
return buildArtifactUrl({
|
|
163
|
+
baseUrl,
|
|
164
|
+
routePrefix: this.deps.routePrefix,
|
|
165
|
+
id: artifact.id,
|
|
166
|
+
secret: this.deps.secret,
|
|
167
|
+
expMs: this.now() + (this.deps.ttlMs ?? DEFAULT_TTL_MS)
|
|
168
|
+
});
|
|
169
|
+
} catch (err) {
|
|
170
|
+
this.deps.logger.debug("artifact publish failed — shipping bytes only", { meta: { error: String(err) } });
|
|
171
|
+
return null;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
/** The data-plane handler for `<prefix>/<id>?exp=&sig=`. */
|
|
175
|
+
handler = async (req, res) => {
|
|
176
|
+
const url = new URL(req.url ?? "/", "http://placeholder");
|
|
177
|
+
const id = decodeURIComponent(url.pathname.split("/").filter(Boolean).pop() ?? "");
|
|
178
|
+
if (!verifyArtifactSignature({
|
|
179
|
+
secret: this.deps.secret,
|
|
180
|
+
id,
|
|
181
|
+
exp: Number(url.searchParams.get("exp")),
|
|
182
|
+
sig: url.searchParams.get("sig") ?? "",
|
|
183
|
+
nowMs: this.now()
|
|
184
|
+
})) {
|
|
185
|
+
res.writeHead(404, { "content-type": "text/plain" });
|
|
186
|
+
res.end("not found");
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
const found = await this.deps.store.read(id);
|
|
190
|
+
if (found === null) {
|
|
191
|
+
res.writeHead(404, { "content-type": "text/plain" });
|
|
192
|
+
res.end("not found");
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
res.writeHead(200, {
|
|
196
|
+
"content-type": found.mime,
|
|
197
|
+
"content-length": String(found.bytes.byteLength),
|
|
198
|
+
"cache-control": "private, max-age=3600"
|
|
199
|
+
});
|
|
200
|
+
res.end(found.bytes);
|
|
201
|
+
};
|
|
202
|
+
/** The chosen base URL, cached briefly (the interface list barely moves). */
|
|
203
|
+
async resolveBaseUrl() {
|
|
204
|
+
const ttl = this.deps.endpointCacheMs ?? DEFAULT_ENDPOINT_CACHE_MS;
|
|
205
|
+
if (this.cachedBaseUrl !== null && this.now() - this.cachedAt < ttl) return this.cachedBaseUrl;
|
|
206
|
+
const picked = pickArtifactBaseUrl(await this.deps.listEndpoints());
|
|
207
|
+
if (picked === null) {
|
|
208
|
+
this.deps.logger.debug("no externally-reachable endpoint — artifacts ship as bytes only");
|
|
209
|
+
return null;
|
|
210
|
+
}
|
|
211
|
+
if (picked !== this.lastLoggedBaseUrl) {
|
|
212
|
+
this.deps.logger.info("artifact links will use", { meta: { baseUrl: picked } });
|
|
213
|
+
this.lastLoggedBaseUrl = picked;
|
|
214
|
+
}
|
|
215
|
+
this.cachedBaseUrl = picked;
|
|
216
|
+
this.cachedAt = this.now();
|
|
217
|
+
return picked;
|
|
218
|
+
}
|
|
219
|
+
};
|
|
220
|
+
//#endregion
|
|
221
|
+
//#region src/notification-center/artifact-store.ts
|
|
222
|
+
/**
|
|
223
|
+
* NcArtifactStore — the bytes behind a signed notification-artifact URL.
|
|
224
|
+
*
|
|
225
|
+
* A notification attachment is transient by nature: it exists to be fetched
|
|
226
|
+
* once, by a notifier backend or the recipient's phone, within minutes of the
|
|
227
|
+
* event. So this is a BOUNDED SCRATCH, not a media library — the durable copy
|
|
228
|
+
* of an event's media already lives in the media store, and duplicating it here
|
|
229
|
+
* permanently would grow without limit for no benefit.
|
|
230
|
+
*
|
|
231
|
+
* Two bounds, both enforced on every write (age first, then count): whichever
|
|
232
|
+
* bites first, the oldest artifacts go. A sweep on boot clears whatever a crash
|
|
233
|
+
* left behind — the directory is disposable by construction, so a lost file is
|
|
234
|
+
* a 404 on one link, never a corrupt state.
|
|
235
|
+
*/
|
|
236
|
+
var DEFAULT_MAX_AGE_MS = 6 * 36e5;
|
|
237
|
+
var DEFAULT_MAX_ENTRIES = 500;
|
|
238
|
+
/** `image/jpeg` → `jpg`, so a fetched link has a name a client will accept. */
|
|
239
|
+
function extensionFor(mime) {
|
|
240
|
+
if (mime === "image/jpeg") return "jpg";
|
|
241
|
+
if (mime === "image/gif") return "gif";
|
|
242
|
+
if (mime === "video/mp4") return "mp4";
|
|
243
|
+
if (mime === "image/png") return "png";
|
|
244
|
+
return "bin";
|
|
245
|
+
}
|
|
246
|
+
var NcArtifactStore = class {
|
|
247
|
+
options;
|
|
248
|
+
now;
|
|
249
|
+
maxAgeMs;
|
|
250
|
+
maxEntries;
|
|
251
|
+
/** id → mime, so the route can answer with the right content-type without
|
|
252
|
+
* re-deriving it from the extension on every request. */
|
|
253
|
+
mimeById = /* @__PURE__ */ new Map();
|
|
254
|
+
constructor(options) {
|
|
255
|
+
this.options = options;
|
|
256
|
+
this.now = options.now ?? (() => Date.now());
|
|
257
|
+
this.maxAgeMs = options.maxAgeMs ?? DEFAULT_MAX_AGE_MS;
|
|
258
|
+
this.maxEntries = options.maxEntries ?? DEFAULT_MAX_ENTRIES;
|
|
259
|
+
}
|
|
260
|
+
/** Create the directory and sweep whatever a previous run left behind. */
|
|
261
|
+
async start() {
|
|
262
|
+
await node_fs.promises.mkdir(this.options.dir, { recursive: true });
|
|
263
|
+
await this.sweep();
|
|
264
|
+
}
|
|
265
|
+
/** Persist bytes and return the artifact handle (never throws on sweep). */
|
|
266
|
+
async put(bytes, mime) {
|
|
267
|
+
const id = `${this.now()}-${(0, node_crypto.randomUUID)()}`;
|
|
268
|
+
const file = node_path.default.join(this.options.dir, `${id}.${extensionFor(mime)}`);
|
|
269
|
+
await node_fs.promises.mkdir(this.options.dir, { recursive: true });
|
|
270
|
+
await node_fs.promises.writeFile(file, bytes);
|
|
271
|
+
this.mimeById.set(id, mime);
|
|
272
|
+
await this.sweep();
|
|
273
|
+
return {
|
|
274
|
+
id,
|
|
275
|
+
mime,
|
|
276
|
+
bytes: bytes.byteLength
|
|
277
|
+
};
|
|
278
|
+
}
|
|
279
|
+
/** Read an artifact's bytes + mime, or null when it is gone (expired/swept). */
|
|
280
|
+
async read(id) {
|
|
281
|
+
if (!/^[0-9]+-[0-9a-f-]{36}$/i.test(id)) return null;
|
|
282
|
+
const found = (await this.list()).find((e) => e.id === id);
|
|
283
|
+
if (!found) return null;
|
|
284
|
+
try {
|
|
285
|
+
return {
|
|
286
|
+
bytes: await node_fs.promises.readFile(found.file),
|
|
287
|
+
mime: this.mimeById.get(id) ?? mimeFromExtension(found.file)
|
|
288
|
+
};
|
|
289
|
+
} catch {
|
|
290
|
+
return null;
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
/** Delete every artifact (operator cleanup / shutdown). */
|
|
294
|
+
async clear() {
|
|
295
|
+
await node_fs.promises.rm(this.options.dir, {
|
|
296
|
+
recursive: true,
|
|
297
|
+
force: true
|
|
298
|
+
}).catch(() => {});
|
|
299
|
+
this.mimeById.clear();
|
|
300
|
+
}
|
|
301
|
+
/** Age- then count-bounded prune. Best-effort: a failed unlink is logged once. */
|
|
302
|
+
async sweep() {
|
|
303
|
+
const entries = await this.list();
|
|
304
|
+
const cutoff = this.now() - this.maxAgeMs;
|
|
305
|
+
const doomed = entries.filter((e) => e.mtimeMs < cutoff);
|
|
306
|
+
const survivors = entries.filter((e) => e.mtimeMs >= cutoff).toSorted((a, b) => b.mtimeMs - a.mtimeMs);
|
|
307
|
+
doomed.push(...survivors.slice(this.maxEntries));
|
|
308
|
+
for (const e of doomed) try {
|
|
309
|
+
await node_fs.promises.rm(e.file, { force: true });
|
|
310
|
+
this.mimeById.delete(e.id);
|
|
311
|
+
} catch (err) {
|
|
312
|
+
this.options.logger.debug("artifact sweep failed to unlink", { meta: {
|
|
313
|
+
file: e.file,
|
|
314
|
+
error: String(err)
|
|
315
|
+
} });
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
async list() {
|
|
319
|
+
let names;
|
|
320
|
+
try {
|
|
321
|
+
names = await node_fs.promises.readdir(this.options.dir);
|
|
322
|
+
} catch {
|
|
323
|
+
return [];
|
|
324
|
+
}
|
|
325
|
+
const out = [];
|
|
326
|
+
for (const name of names) {
|
|
327
|
+
const file = node_path.default.join(this.options.dir, name);
|
|
328
|
+
try {
|
|
329
|
+
const st = await node_fs.promises.stat(file);
|
|
330
|
+
if (!st.isFile()) continue;
|
|
331
|
+
out.push({
|
|
332
|
+
id: name.slice(0, name.lastIndexOf(".")),
|
|
333
|
+
file,
|
|
334
|
+
mtimeMs: st.mtimeMs
|
|
335
|
+
});
|
|
336
|
+
} catch {}
|
|
337
|
+
}
|
|
338
|
+
return out;
|
|
339
|
+
}
|
|
340
|
+
};
|
|
341
|
+
function mimeFromExtension(file) {
|
|
342
|
+
const ext = file.slice(file.lastIndexOf(".") + 1).toLowerCase();
|
|
343
|
+
if (ext === "jpg") return "image/jpeg";
|
|
344
|
+
if (ext === "gif") return "image/gif";
|
|
345
|
+
if (ext === "mp4") return "video/mp4";
|
|
346
|
+
if (ext === "png") return "image/png";
|
|
347
|
+
return "application/octet-stream";
|
|
348
|
+
}
|
|
349
|
+
//#endregion
|
|
9
350
|
//#region src/pipeline-analytics/videoclips-provider.ts
|
|
10
351
|
var SOURCE = "analytics";
|
|
11
352
|
function clipIdFor(eventId, startMs, endMs) {
|
|
@@ -1961,16 +2302,28 @@ function resolveRuleThreshold(rule) {
|
|
|
1961
2302
|
*/
|
|
1962
2303
|
var ZoneEngine = class {
|
|
1963
2304
|
/**
|
|
1964
|
-
* Annotate a single detection with its zone memberships
|
|
1965
|
-
*
|
|
1966
|
-
*
|
|
2305
|
+
* Annotate a single detection with its zone memberships — every zone whose
|
|
2306
|
+
* polygon the detection overlaps by MORE than `minOverlap` (0–1 fraction of
|
|
2307
|
+
* the detection's own area).
|
|
2308
|
+
*
|
|
2309
|
+
* The previous version of this comment claimed memberships were returned
|
|
2310
|
+
* "above any active rule's threshold"; they were not — the threshold was
|
|
2311
|
+
* hardcoded to {@link MEMBERSHIP_MIN_OVERLAP} (zero) and no rule was ever
|
|
2312
|
+
* consulted. Read the parameter, not this paragraph.
|
|
2313
|
+
*
|
|
2314
|
+
* `minOverlap` matters because membership is what lands on an event as
|
|
2315
|
+
* `zones`, and a notification rule's `zones` condition is a plain set test
|
|
2316
|
+
* over that field — so this, not the zone-RULE threshold, is what decides
|
|
2317
|
+
* whether a zone-scoped notification fires. At the default of 0 a subject
|
|
2318
|
+
* clipping a zone by one pixel counts as inside it (measured 2026-07-30: a
|
|
2319
|
+
* dog overlapping `Aiuola` by 4.8% would have been stamped as in it).
|
|
1967
2320
|
*/
|
|
1968
|
-
annotateDetection(bbox, zones, frameWidth, frameHeight, mask, maskWidth, maskHeight) {
|
|
2321
|
+
annotateDetection(bbox, zones, frameWidth, frameHeight, mask, maskWidth, maskHeight, minOverlap = MEMBERSHIP_MIN_OVERLAP) {
|
|
1969
2322
|
const memberships = [];
|
|
1970
2323
|
for (const zone of zones) {
|
|
1971
2324
|
const pixelPolygon = zone.polygon.map((p) => normalizeToPixel(p, frameWidth, frameHeight));
|
|
1972
2325
|
const overlap = mask && maskWidth && maskHeight ? maskPolygonOverlap(mask, maskWidth, maskHeight, bbox, pixelPolygon, frameWidth, frameHeight) : bboxPolygonOverlap(bbox, pixelPolygon);
|
|
1973
|
-
if (overlap >
|
|
2326
|
+
if (overlap > minOverlap) memberships.push({
|
|
1974
2327
|
zoneId: zone.id,
|
|
1975
2328
|
zoneName: zone.name,
|
|
1976
2329
|
overlap
|
|
@@ -2070,6 +2423,10 @@ var FrameProcessor = class {
|
|
|
2070
2423
|
* runners that haven't picked up the new gating yet.
|
|
2071
2424
|
*/
|
|
2072
2425
|
detectionRules;
|
|
2426
|
+
/** See {@link setZoneMembershipMinOverlap}. 0 = any positive overlap. */
|
|
2427
|
+
zoneMembershipMinOverlap;
|
|
2428
|
+
/** See {@link getLastZoneOverlaps}. */
|
|
2429
|
+
lastZoneOverlaps;
|
|
2073
2430
|
zoneEngine = new ZoneEngine();
|
|
2074
2431
|
/** Optional stationary-object gate (parked-object suppression). Null until
|
|
2075
2432
|
* the addon wires it via {@link setStationaryGate}. */
|
|
@@ -2082,10 +2439,32 @@ var FrameProcessor = class {
|
|
|
2082
2439
|
this.eventEmitter = new DetectionEventEmitter(emitterConfig);
|
|
2083
2440
|
this.zones = [];
|
|
2084
2441
|
this.detectionRules = [];
|
|
2442
|
+
this.zoneMembershipMinOverlap = 0;
|
|
2443
|
+
this.lastZoneOverlaps = /* @__PURE__ */ new Map();
|
|
2085
2444
|
}
|
|
2086
2445
|
setZones(zones) {
|
|
2087
2446
|
this.zones = zones;
|
|
2088
2447
|
}
|
|
2448
|
+
/**
|
|
2449
|
+
* How much of a detection's box must lie inside a zone for the zone to be
|
|
2450
|
+
* stamped onto it (0–1 fraction of the box's own area).
|
|
2451
|
+
*
|
|
2452
|
+
* DEFAULT 0 — byte-identical to the behaviour before 2026-07-31, where any
|
|
2453
|
+
* positive overlap counted. Raising it is an operator decision and needs
|
|
2454
|
+
* evidence: a bar set blind removes notifications silently, which is the
|
|
2455
|
+
* failure mode this whole area keeps producing. {@link lastZoneOverlaps}
|
|
2456
|
+
* exists so the distribution can be read before a number is picked.
|
|
2457
|
+
*/
|
|
2458
|
+
setZoneMembershipMinOverlap(minOverlap) {
|
|
2459
|
+
this.zoneMembershipMinOverlap = Number.isFinite(minOverlap) && minOverlap >= 0 && minOverlap <= 1 ? minOverlap : 0;
|
|
2460
|
+
}
|
|
2461
|
+
/** Per-track zone memberships WITH their overlap fractions, from the most
|
|
2462
|
+
* recent frame. The engine computes these and the pipeline previously
|
|
2463
|
+
* discarded everything but the ids — which is why no amount of production
|
|
2464
|
+
* data could say how far inside the zone a notifying subject actually was. */
|
|
2465
|
+
getLastZoneOverlaps() {
|
|
2466
|
+
return this.lastZoneOverlaps;
|
|
2467
|
+
}
|
|
2089
2468
|
setDetectionRules(rules) {
|
|
2090
2469
|
this.detectionRules = rules;
|
|
2091
2470
|
}
|
|
@@ -2196,11 +2575,14 @@ var FrameProcessor = class {
|
|
|
2196
2575
|
const objectStates = this.stateAnalyzer.analyze(trackedDetections, timestamp);
|
|
2197
2576
|
const rawEvents = this.eventEmitter.emit(trackedDetections, objectStates, [], [], String(this.deviceId));
|
|
2198
2577
|
const zonesByTrack = /* @__PURE__ */ new Map();
|
|
2578
|
+
const overlapsByTrack = /* @__PURE__ */ new Map();
|
|
2199
2579
|
for (const td of trackedDetections) {
|
|
2200
2580
|
const m = maskByBbox.get(td.bbox);
|
|
2201
|
-
const memberships = this.zoneEngine.annotateDetection(td.bbox, this.zones, frameWidth, frameHeight, m?.mask, m?.width, m?.height);
|
|
2581
|
+
const memberships = this.zoneEngine.annotateDetection(td.bbox, this.zones, frameWidth, frameHeight, m?.mask, m?.width, m?.height, this.zoneMembershipMinOverlap);
|
|
2202
2582
|
zonesByTrack.set(td.trackId, memberships.map((m2) => m2.zoneId));
|
|
2583
|
+
if (memberships.length > 0) overlapsByTrack.set(td.trackId, memberships);
|
|
2203
2584
|
}
|
|
2585
|
+
this.lastZoneOverlaps = overlapsByTrack;
|
|
2204
2586
|
const tracked = trackedDetections.map((td) => {
|
|
2205
2587
|
const state = mapObjectStateToTrackState(objectStates.find((o) => o.trackId === td.trackId)?.state);
|
|
2206
2588
|
const label = resolveDetectionLabel({
|
|
@@ -4162,6 +4544,42 @@ function attachmentKindPreference(policy, ownerKind) {
|
|
|
4162
4544
|
];
|
|
4163
4545
|
}
|
|
4164
4546
|
/**
|
|
4547
|
+
* The kind ladder for an EXPLICIT frame choice. Strict by design — each option
|
|
4548
|
+
* stays inside its own family, because a rule that asked for the clean scene
|
|
4549
|
+
* and received the boxed one (or vice versa) is not "degrading gracefully", it
|
|
4550
|
+
* is answering a different question.
|
|
4551
|
+
*
|
|
4552
|
+
* `boxed` is the one exception: with no annotated frame stored, the CLEAN
|
|
4553
|
+
* scene is the honest fallback (same picture, no annotation) — never a subject
|
|
4554
|
+
* crop, which shows something else entirely.
|
|
4555
|
+
*/
|
|
4556
|
+
function framePreference(frame, ownerKind) {
|
|
4557
|
+
if (frame === "cropped") return ownerKind === "track" ? [
|
|
4558
|
+
"thumbnail",
|
|
4559
|
+
"thumbnailSmall",
|
|
4560
|
+
"crop"
|
|
4561
|
+
] : [
|
|
4562
|
+
"crop",
|
|
4563
|
+
"thumbnail",
|
|
4564
|
+
"thumbnailSmall"
|
|
4565
|
+
];
|
|
4566
|
+
if (frame === "boxed") return [
|
|
4567
|
+
"fullFrameBoxed",
|
|
4568
|
+
"keyFrame",
|
|
4569
|
+
"keyFrameSmall",
|
|
4570
|
+
"fullFrame"
|
|
4571
|
+
];
|
|
4572
|
+
return ownerKind === "track" ? [
|
|
4573
|
+
"keyFrame",
|
|
4574
|
+
"keyFrameSmall",
|
|
4575
|
+
"firstFrame"
|
|
4576
|
+
] : [
|
|
4577
|
+
"fullFrame",
|
|
4578
|
+
"keyFrame",
|
|
4579
|
+
"keyFrameSmall"
|
|
4580
|
+
];
|
|
4581
|
+
}
|
|
4582
|
+
/**
|
|
4165
4583
|
* Derive the `best-matching` media signal from a matched rule's condition
|
|
4166
4584
|
* summary ({@link NcEvaluation.matchedOn}). Identity takes priority over plate
|
|
4167
4585
|
* (D-3 ordering: a face rule that ALSO plate-matched attaches the face crop).
|
|
@@ -4190,6 +4608,20 @@ function bestMatchingKindPreference(signal, ownerKind) {
|
|
|
4190
4608
|
//#endregion
|
|
4191
4609
|
//#region src/notification-center/dispatcher.ts
|
|
4192
4610
|
var DEFAULT_TARGET_CACHE_TTL_MS = 6e4;
|
|
4611
|
+
/** GIF and MP4 are the same cut in two containers — one render request each. */
|
|
4612
|
+
var FOOTAGE_FORMATS = [{
|
|
4613
|
+
flag: "mediaGif",
|
|
4614
|
+
format: "gif",
|
|
4615
|
+
mediaType: "gif",
|
|
4616
|
+
mime: "image/gif",
|
|
4617
|
+
name: "event.gif"
|
|
4618
|
+
}, {
|
|
4619
|
+
flag: "mediaClip",
|
|
4620
|
+
format: "mp4",
|
|
4621
|
+
mediaType: "video",
|
|
4622
|
+
mime: "video/mp4",
|
|
4623
|
+
name: "event.mp4"
|
|
4624
|
+
}];
|
|
4193
4625
|
var NcDispatcher = class {
|
|
4194
4626
|
deps;
|
|
4195
4627
|
targetCache = null;
|
|
@@ -4244,7 +4676,9 @@ var NcDispatcher = class {
|
|
|
4244
4676
|
ruleId: entry.ruleId,
|
|
4245
4677
|
target: target.name,
|
|
4246
4678
|
kind: target.kind,
|
|
4247
|
-
recordKind: entry.recordKind
|
|
4679
|
+
recordKind: entry.recordKind,
|
|
4680
|
+
eventId: entry.recordId,
|
|
4681
|
+
...entry.trackId !== void 0 ? { trackId: entry.trackId } : {}
|
|
4248
4682
|
}
|
|
4249
4683
|
});
|
|
4250
4684
|
return { ok: true };
|
|
@@ -4259,14 +4693,19 @@ var NcDispatcher = class {
|
|
|
4259
4693
|
async resolveTarget(targetId) {
|
|
4260
4694
|
const cached = this.cachedTarget(targetId);
|
|
4261
4695
|
if (cached !== null) return cached;
|
|
4696
|
+
let targets;
|
|
4262
4697
|
try {
|
|
4263
|
-
|
|
4264
|
-
this.targetCache = new Map(targets.map((t) => [t.id, t]));
|
|
4265
|
-
this.targetCacheAt = this.now();
|
|
4698
|
+
targets = await this.deps.listTargets();
|
|
4266
4699
|
} catch (err) {
|
|
4267
4700
|
this.deps.logger.warn("target catalog refresh failed", { meta: { error: String(err) } });
|
|
4268
4701
|
throw err instanceof Error ? err : new Error(String(err));
|
|
4269
4702
|
}
|
|
4703
|
+
if (targets.length === 0) {
|
|
4704
|
+
this.deps.logger.warn("target catalog came back EMPTY — treating as transient", { meta: { targetId } });
|
|
4705
|
+
throw new Error(`target catalog empty (transient) while resolving ${targetId}`);
|
|
4706
|
+
}
|
|
4707
|
+
this.targetCache = new Map(targets.map((t) => [t.id, t]));
|
|
4708
|
+
this.targetCacheAt = this.now();
|
|
4270
4709
|
return this.targetCache.get(targetId) ?? null;
|
|
4271
4710
|
}
|
|
4272
4711
|
cachedTarget(targetId) {
|
|
@@ -4277,10 +4716,11 @@ var NcDispatcher = class {
|
|
|
4277
4716
|
async buildNotification(entry) {
|
|
4278
4717
|
const subject = entry.payload.subject;
|
|
4279
4718
|
const deviceName = await this.deps.getDeviceName(subject.deviceId).catch(() => null) ?? `camera ${subject.deviceId}`;
|
|
4280
|
-
const
|
|
4719
|
+
const zoneLabels = await resolveZoneLabels(this.deps.getZoneNames, subject.deviceId, subject.zones);
|
|
4720
|
+
const vars = buildTemplateVars(entry, deviceName, zoneLabels);
|
|
4281
4721
|
const title = renderTemplate(entry.payload.template?.title, vars) ?? entry.payload.ruleName;
|
|
4282
|
-
const body = renderTemplate(entry.payload.template?.body, vars) ?? defaultBody(entry, deviceName);
|
|
4283
|
-
const
|
|
4722
|
+
const body = renderTemplate(entry.payload.template?.body, vars) ?? defaultBody(entry, deviceName, zoneLabels);
|
|
4723
|
+
const attachments = await this.withArtifactUrls(await this.resolveAttachments(entry));
|
|
4284
4724
|
const params = pickParams(entry.payload.params);
|
|
4285
4725
|
return {
|
|
4286
4726
|
body,
|
|
@@ -4290,7 +4730,7 @@ var NcDispatcher = class {
|
|
|
4290
4730
|
tag: entry.ruleId,
|
|
4291
4731
|
deviceId: subject.deviceId,
|
|
4292
4732
|
...subject.eventId !== void 0 ? { eventId: subject.eventId } : {},
|
|
4293
|
-
...
|
|
4733
|
+
...attachments.length > 0 ? { attachments } : {}
|
|
4294
4734
|
};
|
|
4295
4735
|
}
|
|
4296
4736
|
/**
|
|
@@ -4302,9 +4742,142 @@ var NcDispatcher = class {
|
|
|
4302
4742
|
* explains the fired condition (`faceCrop`/`plateCrop`, both event-owned)
|
|
4303
4743
|
* then degrades to the plain `best` → `keyFrame` ladders.
|
|
4304
4744
|
*/
|
|
4305
|
-
|
|
4306
|
-
|
|
4745
|
+
/**
|
|
4746
|
+
* Mint a signed URL for each attachment, alongside the bytes it already
|
|
4747
|
+
* carries. Best-effort per attachment: a failed mint leaves that one
|
|
4748
|
+
* bytes-only rather than costing the notification. Without this a url-mode
|
|
4749
|
+
* target (WhatsApp, gotify) silently received NO media — the degrade engine
|
|
4750
|
+
* drops a bytes-only attachment for them (`attachment:noUrl`).
|
|
4751
|
+
*/
|
|
4752
|
+
/**
|
|
4753
|
+
* A failed footage render is best-effort — ship the still, skip the video.
|
|
4754
|
+
*
|
|
4755
|
+
* There is nothing to wait for: the clip ring only grows FORWARD, so a window
|
|
4756
|
+
* it does not already cover will never be covered by retrying. (An earlier
|
|
4757
|
+
* revision deferred delivery here, when clips were cut from finalized
|
|
4758
|
+
* recording segments; retrying under the pre-buffer would just age the
|
|
4759
|
+
* pre-roll out of the ring.)
|
|
4760
|
+
*/
|
|
4761
|
+
notePendingFootage(entry, err, what) {
|
|
4762
|
+
this.deps.logger.debug(`${what} attachment render failed`, {
|
|
4763
|
+
tags: { deviceId: entry.payload.subject.deviceId },
|
|
4764
|
+
meta: {
|
|
4765
|
+
error: String(err),
|
|
4766
|
+
action: "send-without-video"
|
|
4767
|
+
}
|
|
4768
|
+
});
|
|
4769
|
+
}
|
|
4770
|
+
async withArtifactUrls(attachments) {
|
|
4771
|
+
const publish = this.deps.publishArtifact;
|
|
4772
|
+
if (publish === void 0) return [...attachments];
|
|
4773
|
+
const out = [];
|
|
4774
|
+
for (const att of attachments) {
|
|
4775
|
+
const url = await publish(att.bytes, att.mime).catch(() => null);
|
|
4776
|
+
out.push(url === null ? { ...att } : {
|
|
4777
|
+
...att,
|
|
4778
|
+
url
|
|
4779
|
+
});
|
|
4780
|
+
}
|
|
4781
|
+
return out;
|
|
4782
|
+
}
|
|
4783
|
+
/** The full attachment list: the policy still (zone-cropped when the rule
|
|
4784
|
+
* froze zone ids) plus the footage cut from the broker's clip ring when
|
|
4785
|
+
* requested. Each part is best-effort — a failed crop falls back to the
|
|
4786
|
+
* uncropped still, a failed render just omits the video. */
|
|
4787
|
+
async resolveAttachments(entry) {
|
|
4788
|
+
const out = [];
|
|
4789
|
+
const zoneIdsWanted = entry.payload.mediaZoneIds;
|
|
4790
|
+
const still = await this.resolveAttachment(entry, zoneIdsWanted !== void 0 && zoneIdsWanted.length > 0 ? "keyFrame" : void 0);
|
|
4791
|
+
if (still !== null) {
|
|
4792
|
+
const zoneIds = zoneIdsWanted;
|
|
4793
|
+
if (zoneIds !== void 0 && zoneIds.length > 0) {
|
|
4794
|
+
const cropped = await this.zoneCrop(entry.payload.subject.deviceId, zoneIds, still.bytes);
|
|
4795
|
+
out.push(cropped !== null ? {
|
|
4796
|
+
...still,
|
|
4797
|
+
bytes: cropped,
|
|
4798
|
+
name: "zone.jpg"
|
|
4799
|
+
} : still);
|
|
4800
|
+
} else out.push(still);
|
|
4801
|
+
}
|
|
4802
|
+
for (const want of FOOTAGE_FORMATS) {
|
|
4803
|
+
if (entry.payload[want.flag] !== true || !this.deps.renderFootage) continue;
|
|
4804
|
+
try {
|
|
4805
|
+
const rendered = await this.deps.renderFootage({
|
|
4806
|
+
deviceId: entry.payload.subject.deviceId,
|
|
4807
|
+
aroundMs: entry.payload.subject.timestamp,
|
|
4808
|
+
format: want.format,
|
|
4809
|
+
...entry.payload.mediaClipPreRollSec !== void 0 ? { preRollSec: entry.payload.mediaClipPreRollSec } : {},
|
|
4810
|
+
...entry.payload.mediaClipPostRollSec !== void 0 ? { postRollSec: entry.payload.mediaClipPostRollSec } : {},
|
|
4811
|
+
...entry.payload.mediaProfile !== void 0 ? { profile: entry.payload.mediaProfile } : {}
|
|
4812
|
+
});
|
|
4813
|
+
if (rendered !== null && rendered.byteLength > 0) {
|
|
4814
|
+
const bytes = new Uint8Array(rendered.byteLength);
|
|
4815
|
+
bytes.set(rendered);
|
|
4816
|
+
out.push({
|
|
4817
|
+
mediaType: want.mediaType,
|
|
4818
|
+
bytes,
|
|
4819
|
+
mime: want.mime,
|
|
4820
|
+
name: want.name
|
|
4821
|
+
});
|
|
4822
|
+
}
|
|
4823
|
+
} catch (err) {
|
|
4824
|
+
this.notePendingFootage(entry, err, want.format === "gif" ? "gif" : "clip");
|
|
4825
|
+
}
|
|
4826
|
+
}
|
|
4827
|
+
return out;
|
|
4828
|
+
}
|
|
4829
|
+
/** Crop a JPEG to the padded bbox of the given zones (normalized polygons →
|
|
4830
|
+
* pixel rect via sharp metadata). Null on any failure — caller falls back
|
|
4831
|
+
* to the uncropped still. */
|
|
4832
|
+
async zoneCrop(deviceId, zoneIds, jpeg) {
|
|
4833
|
+
try {
|
|
4834
|
+
const points = (await this.deps.getZonePolygons?.(deviceId, zoneIds) ?? []).flat();
|
|
4835
|
+
if (points.length === 0) return null;
|
|
4836
|
+
const { default: sharp$7 } = await import("sharp");
|
|
4837
|
+
const img = sharp$7(Buffer.from(jpeg));
|
|
4838
|
+
const meta = await img.metadata();
|
|
4839
|
+
const W = meta.width ?? 0;
|
|
4840
|
+
const H = meta.height ?? 0;
|
|
4841
|
+
if (W === 0 || H === 0) return null;
|
|
4842
|
+
let minX = 1;
|
|
4843
|
+
let minY = 1;
|
|
4844
|
+
let maxX = 0;
|
|
4845
|
+
let maxY = 0;
|
|
4846
|
+
for (const p of points) {
|
|
4847
|
+
if (p.x < minX) minX = p.x;
|
|
4848
|
+
if (p.y < minY) minY = p.y;
|
|
4849
|
+
if (p.x > maxX) maxX = p.x;
|
|
4850
|
+
if (p.y > maxY) maxY = p.y;
|
|
4851
|
+
}
|
|
4852
|
+
if (maxX <= minX || maxY <= minY) return null;
|
|
4853
|
+
const padX = (maxX - minX) * .1;
|
|
4854
|
+
const padY = (maxY - minY) * .1;
|
|
4855
|
+
const left = Math.max(0, Math.floor((minX - padX) * W));
|
|
4856
|
+
const top = Math.max(0, Math.floor((minY - padY) * H));
|
|
4857
|
+
const width = Math.min(W - left, Math.ceil((maxX - minX + 2 * padX) * W));
|
|
4858
|
+
const height = Math.min(H - top, Math.ceil((maxY - minY + 2 * padY) * H));
|
|
4859
|
+
if (width < 16 || height < 16) return null;
|
|
4860
|
+
const outBuf = await img.extract({
|
|
4861
|
+
left,
|
|
4862
|
+
top,
|
|
4863
|
+
width,
|
|
4864
|
+
height
|
|
4865
|
+
}).jpeg({ quality: 82 }).toBuffer();
|
|
4866
|
+
const bytes = new Uint8Array(outBuf.byteLength);
|
|
4867
|
+
bytes.set(outBuf);
|
|
4868
|
+
return bytes;
|
|
4869
|
+
} catch (err) {
|
|
4870
|
+
this.deps.logger.debug("zone crop failed — attaching uncropped still", { meta: {
|
|
4871
|
+
deviceId,
|
|
4872
|
+
error: String(err)
|
|
4873
|
+
} });
|
|
4874
|
+
return null;
|
|
4875
|
+
}
|
|
4876
|
+
}
|
|
4877
|
+
async resolveAttachment(entry, policyOverride) {
|
|
4878
|
+
const policy = policyOverride ?? entry.payload.media;
|
|
4307
4879
|
if (policy === "none") return null;
|
|
4880
|
+
const frame = policyOverride === void 0 ? entry.payload.mediaFrame : void 0;
|
|
4308
4881
|
const subject = entry.payload.subject;
|
|
4309
4882
|
const signal = policy === "best-matching" ? matchSignal(entry.payload.matchedOn) : null;
|
|
4310
4883
|
const owners = [];
|
|
@@ -4316,11 +4889,11 @@ var NcDispatcher = class {
|
|
|
4316
4889
|
kind: "track",
|
|
4317
4890
|
id: subject.trackId
|
|
4318
4891
|
});
|
|
4319
|
-
if (policy === "keyFrame") owners.reverse();
|
|
4892
|
+
if (policy === "keyFrame" || frame === "full" || frame === "boxed") owners.reverse();
|
|
4320
4893
|
for (const owner of owners) try {
|
|
4321
4894
|
const files = await this.deps.getMediaForOwner(owner.kind, owner.id);
|
|
4322
4895
|
if (files.length === 0) continue;
|
|
4323
|
-
const preference = policy === "best-matching" ? bestMatchingKindPreference(signal, owner.kind) : attachmentKindPreference(policy, owner.kind);
|
|
4896
|
+
const preference = frame !== void 0 ? framePreference(frame, owner.kind) : policy === "best-matching" ? bestMatchingKindPreference(signal, owner.kind) : attachmentKindPreference(policy, owner.kind);
|
|
4324
4897
|
for (const kind of preference) {
|
|
4325
4898
|
const file = files.find((f) => f.kind === kind);
|
|
4326
4899
|
if (file === void 0) continue;
|
|
@@ -4345,15 +4918,37 @@ var NcDispatcher = class {
|
|
|
4345
4918
|
return null;
|
|
4346
4919
|
}
|
|
4347
4920
|
};
|
|
4348
|
-
|
|
4921
|
+
/**
|
|
4922
|
+
* Map admin zone IDs to their display names for rendering only.
|
|
4923
|
+
*
|
|
4924
|
+
* Order follows `zoneIds` (the order the track visited them), not the zone
|
|
4925
|
+
* catalog. Every failure mode degrades to the ID rather than dropping the
|
|
4926
|
+
* zone: an unknown id, a blank name, a throwing lookup, or no lookup wired at
|
|
4927
|
+
* all. A body that silently loses a zone is worse than one that shows a UUID.
|
|
4928
|
+
*/
|
|
4929
|
+
async function resolveZoneLabels(getZoneNames, deviceId, zoneIds) {
|
|
4930
|
+
if (zoneIds.length === 0) return [];
|
|
4931
|
+
if (getZoneNames === void 0) return [...zoneIds];
|
|
4932
|
+
try {
|
|
4933
|
+
const zones = await getZoneNames(deviceId);
|
|
4934
|
+
const byId = new Map(zones.map((z) => [z.id, z.name]));
|
|
4935
|
+
return zoneIds.map((id) => {
|
|
4936
|
+
const name = byId.get(id);
|
|
4937
|
+
return name !== void 0 && name.trim().length > 0 ? name : id;
|
|
4938
|
+
});
|
|
4939
|
+
} catch {
|
|
4940
|
+
return [...zoneIds];
|
|
4941
|
+
}
|
|
4942
|
+
}
|
|
4943
|
+
function buildTemplateVars(entry, deviceName, zoneLabels) {
|
|
4349
4944
|
const subject = entry.payload.subject;
|
|
4350
4945
|
const occupancy = subject.occupancy;
|
|
4351
4946
|
return {
|
|
4352
4947
|
camera: deviceName,
|
|
4353
4948
|
class: subject.className,
|
|
4354
4949
|
label: subject.label ?? "",
|
|
4355
|
-
zones:
|
|
4356
|
-
zone: occupancy?.zone ??
|
|
4950
|
+
zones: zoneLabels.join(", "),
|
|
4951
|
+
zone: occupancy?.zone ?? zoneLabels[0] ?? "",
|
|
4357
4952
|
confidence: subject.confidence !== void 0 ? `${Math.round(subject.confidence * 100)}%` : "",
|
|
4358
4953
|
time: new Date(subject.timestamp).toLocaleTimeString(),
|
|
4359
4954
|
rule: entry.payload.ruleName,
|
|
@@ -4371,12 +4966,12 @@ function renderTemplate(template, vars) {
|
|
|
4371
4966
|
if (template === void 0 || template.trim().length === 0) return null;
|
|
4372
4967
|
return template.replace(/\{\{\s*(\w+)\s*\}\}/g, (_m, name) => vars[name] ?? "");
|
|
4373
4968
|
}
|
|
4374
|
-
function defaultBody(entry, deviceName) {
|
|
4969
|
+
function defaultBody(entry, deviceName, zoneLabels) {
|
|
4375
4970
|
const subject = entry.payload.subject;
|
|
4376
4971
|
const occupancy = subject.occupancy;
|
|
4377
4972
|
if (occupancy !== void 0) return `${occupancy.zone ?? deviceName} ${occupancyOpWord(occupancy.occupied)} (${occupancy.count}/${occupancy.capacity})`;
|
|
4378
4973
|
const label = subject.label !== void 0 ? ` (${subject.label})` : "";
|
|
4379
|
-
const zones =
|
|
4974
|
+
const zones = zoneLabels.length > 0 ? ` in ${zoneLabels.join(", ")}` : "";
|
|
4380
4975
|
const suffix = entry.recordKind === "track-end" ? " — visit ended" : "";
|
|
4381
4976
|
return `${subject.className}${label} on ${deviceName}${zones}${suffix}`;
|
|
4382
4977
|
}
|
|
@@ -4642,7 +5237,7 @@ var NC_OCCUPANCY_INDEXES = [{
|
|
|
4642
5237
|
}];
|
|
4643
5238
|
/** Query cap — a per-(device, zone, class) key set is small; this is a
|
|
4644
5239
|
* generous ceiling that still bounds a pathological read. */
|
|
4645
|
-
var LOAD_LIMIT = 1e5;
|
|
5240
|
+
var LOAD_LIMIT$1 = 1e5;
|
|
4646
5241
|
var OccupancyStore = class {
|
|
4647
5242
|
cache = /* @__PURE__ */ new Map();
|
|
4648
5243
|
store;
|
|
@@ -4669,7 +5264,7 @@ var OccupancyStore = class {
|
|
|
4669
5264
|
try {
|
|
4670
5265
|
const records = await this.store.query.query({
|
|
4671
5266
|
collection: NC_OCCUPANCY_COLLECTION,
|
|
4672
|
-
filter: { limit: LOAD_LIMIT }
|
|
5267
|
+
filter: { limit: LOAD_LIMIT$1 }
|
|
4673
5268
|
});
|
|
4674
5269
|
this.cache.clear();
|
|
4675
5270
|
let skipped = 0;
|
|
@@ -5454,6 +6049,254 @@ var NcRuleStore = class {
|
|
|
5454
6049
|
}
|
|
5455
6050
|
};
|
|
5456
6051
|
//#endregion
|
|
6052
|
+
//#region src/notification-center/timelapse/timelapse-store.ts
|
|
6053
|
+
var NC_TIMELAPSE_RULES_COLLECTION = "notification-center:timelapse-rules";
|
|
6054
|
+
var NC_TIMELAPSE_RULES_COLUMNS = [
|
|
6055
|
+
{
|
|
6056
|
+
name: "id",
|
|
6057
|
+
type: "TEXT",
|
|
6058
|
+
primaryKey: true,
|
|
6059
|
+
notNull: true
|
|
6060
|
+
},
|
|
6061
|
+
{
|
|
6062
|
+
name: "name",
|
|
6063
|
+
type: "TEXT",
|
|
6064
|
+
notNull: true
|
|
6065
|
+
},
|
|
6066
|
+
{
|
|
6067
|
+
name: "enabled",
|
|
6068
|
+
type: "BOOLEAN",
|
|
6069
|
+
notNull: true
|
|
6070
|
+
},
|
|
6071
|
+
{
|
|
6072
|
+
name: "updatedAt",
|
|
6073
|
+
type: "INTEGER",
|
|
6074
|
+
notNull: true
|
|
6075
|
+
},
|
|
6076
|
+
(
|
|
6077
|
+
/** The FULL rule object (Zod-validated on read) — scalars above are
|
|
6078
|
+
* indexed projections only. */
|
|
6079
|
+
{
|
|
6080
|
+
name: "rule",
|
|
6081
|
+
type: "JSON",
|
|
6082
|
+
notNull: true
|
|
6083
|
+
})
|
|
6084
|
+
];
|
|
6085
|
+
var NC_TIMELAPSE_RULES_INDEXES = [{
|
|
6086
|
+
name: "idx_nc_timelapse_rules_enabled",
|
|
6087
|
+
columns: ["enabled"]
|
|
6088
|
+
}];
|
|
6089
|
+
/** Query cap — the rule set is operator-authored and tiny; a generous ceiling. */
|
|
6090
|
+
var LOAD_LIMIT = 1e4;
|
|
6091
|
+
/**
|
|
6092
|
+
* Resolve the three-way `template` patch signal onto a merged rule, immutably:
|
|
6093
|
+
* `undefined` (key absent) leaves it as-is, `null` DROPS the key, an object
|
|
6094
|
+
* replaces it. Keeping `null` out of the persisted rule is what lets
|
|
6095
|
+
* `TimelapseRuleSchema` stay a plain `.optional()`.
|
|
6096
|
+
*/
|
|
6097
|
+
function applyTemplatePatch(merged, template) {
|
|
6098
|
+
if (template === void 0) return merged;
|
|
6099
|
+
if (template !== null) return {
|
|
6100
|
+
...merged,
|
|
6101
|
+
template
|
|
6102
|
+
};
|
|
6103
|
+
const { template: _cleared, ...withoutTemplate } = merged;
|
|
6104
|
+
return withoutTemplate;
|
|
6105
|
+
}
|
|
6106
|
+
var TimelapseStore = class {
|
|
6107
|
+
byId = /* @__PURE__ */ new Map();
|
|
6108
|
+
store;
|
|
6109
|
+
logger;
|
|
6110
|
+
now;
|
|
6111
|
+
newId;
|
|
6112
|
+
constructor(deps) {
|
|
6113
|
+
this.store = deps.store;
|
|
6114
|
+
this.logger = deps.logger;
|
|
6115
|
+
this.now = deps.now ?? (() => Date.now());
|
|
6116
|
+
this.newId = deps.newId ?? (() => (0, node_crypto.randomUUID)());
|
|
6117
|
+
}
|
|
6118
|
+
static async declare(store) {
|
|
6119
|
+
await store.declareCollection.mutate({
|
|
6120
|
+
collection: NC_TIMELAPSE_RULES_COLLECTION,
|
|
6121
|
+
columns: [...NC_TIMELAPSE_RULES_COLUMNS],
|
|
6122
|
+
indexes: [...NC_TIMELAPSE_RULES_INDEXES]
|
|
6123
|
+
});
|
|
6124
|
+
}
|
|
6125
|
+
/**
|
|
6126
|
+
* (Re)hydrate the FULL rule set from the store — called at boot and on the
|
|
6127
|
+
* periodic refresh tick. Replaces the cache wholesale; a row whose JSON no
|
|
6128
|
+
* longer validates is skipped with a warning (a degraded rule must never
|
|
6129
|
+
* crash the scheduler).
|
|
6130
|
+
*/
|
|
6131
|
+
async load() {
|
|
6132
|
+
try {
|
|
6133
|
+
const rows = await this.store.query.query({
|
|
6134
|
+
collection: NC_TIMELAPSE_RULES_COLLECTION,
|
|
6135
|
+
filter: { limit: LOAD_LIMIT }
|
|
6136
|
+
});
|
|
6137
|
+
this.byId.clear();
|
|
6138
|
+
let skipped = 0;
|
|
6139
|
+
for (const row of rows) {
|
|
6140
|
+
const parsed = require_dist.TimelapseRuleSchema.safeParse(row.data["rule"]);
|
|
6141
|
+
if (!parsed.success) {
|
|
6142
|
+
skipped += 1;
|
|
6143
|
+
continue;
|
|
6144
|
+
}
|
|
6145
|
+
this.byId.set(parsed.data.id, parsed.data);
|
|
6146
|
+
}
|
|
6147
|
+
this.logger.debug("timelapse rules loaded", { meta: {
|
|
6148
|
+
rules: this.byId.size,
|
|
6149
|
+
...skipped > 0 ? { skippedInvalid: skipped } : {}
|
|
6150
|
+
} });
|
|
6151
|
+
} catch (err) {
|
|
6152
|
+
this.logger.warn("timelapse rules load failed", { meta: { error: String(err) } });
|
|
6153
|
+
}
|
|
6154
|
+
}
|
|
6155
|
+
/** Every rule, newest-first (admin path). */
|
|
6156
|
+
list() {
|
|
6157
|
+
return [...this.byId.values()].sort((a, b) => b.updatedAt - a.updatedAt);
|
|
6158
|
+
}
|
|
6159
|
+
/** The scheduler's read: only rules that should be ticked. */
|
|
6160
|
+
listEnabled() {
|
|
6161
|
+
return this.list().filter((r) => r.enabled);
|
|
6162
|
+
}
|
|
6163
|
+
get(ruleId) {
|
|
6164
|
+
return this.byId.get(ruleId) ?? null;
|
|
6165
|
+
}
|
|
6166
|
+
/**
|
|
6167
|
+
* Rules visible to `userId`: their OWN personal rules (`ownerUserId ===
|
|
6168
|
+
* userId`) plus every admin/global rule (`ownerUserId` absent). Never another
|
|
6169
|
+
* user's personal rows. Newest-first (inherits {@link list}).
|
|
6170
|
+
*
|
|
6171
|
+
* The caller identity is server-derived; an absent/undefined caller must be
|
|
6172
|
+
* resolved to a fail-closed value by the bridge action BEFORE calling here —
|
|
6173
|
+
* this store never treats a missing caller as admin/global.
|
|
6174
|
+
*/
|
|
6175
|
+
listForOwner(userId) {
|
|
6176
|
+
return this.list().filter((r) => r.ownerUserId === void 0 || r.ownerUserId === userId);
|
|
6177
|
+
}
|
|
6178
|
+
/**
|
|
6179
|
+
* Mutation gate for a NON-admin caller: true only for a PERSONAL rule this
|
|
6180
|
+
* user owns. A global rule (no `ownerUserId`) returns false — global rules
|
|
6181
|
+
* are admin-only, and the bridge action grants admins the mutation without
|
|
6182
|
+
* consulting this check. An unknown rule id is false (fail-closed).
|
|
6183
|
+
*/
|
|
6184
|
+
isOwnedBy(ruleId, userId) {
|
|
6185
|
+
const rule = this.byId.get(ruleId);
|
|
6186
|
+
return rule?.ownerUserId !== void 0 && rule.ownerUserId === userId;
|
|
6187
|
+
}
|
|
6188
|
+
/**
|
|
6189
|
+
* Create a rule. `createdBy` is the SERVER-injected caller userId;
|
|
6190
|
+
* `ownerUserId` is the server-derived owner (omit for an admin/global rule).
|
|
6191
|
+
* Neither is ever read from `input`.
|
|
6192
|
+
*
|
|
6193
|
+
* The input is re-parsed through {@link TimelapseRuleInputSchema} BEFORE the
|
|
6194
|
+
* spread — that schema carries no ownership/provenance keys, so it strips any
|
|
6195
|
+
* that rode in on the blob. Without it, an `ownerUserId` on `input` would
|
|
6196
|
+
* survive whenever the `ownerUserId` ARGUMENT is omitted (the admin/global
|
|
6197
|
+
* path): a `TimelapseRule` is structurally assignable to `TimelapseRuleInput`,
|
|
6198
|
+
* so a future "duplicate rule" action (`create(existingRule, caller)`) would
|
|
6199
|
+
* compile cleanly and silently clone the ORIGINAL owner.
|
|
6200
|
+
*/
|
|
6201
|
+
async create(input, createdBy, ownerUserId) {
|
|
6202
|
+
const now = this.now();
|
|
6203
|
+
const rule = require_dist.TimelapseRuleSchema.parse({
|
|
6204
|
+
...require_dist.TimelapseRuleInputSchema.parse(input),
|
|
6205
|
+
id: this.newId(),
|
|
6206
|
+
...ownerUserId !== void 0 ? { ownerUserId } : {},
|
|
6207
|
+
createdBy,
|
|
6208
|
+
createdAt: now,
|
|
6209
|
+
updatedAt: now
|
|
6210
|
+
});
|
|
6211
|
+
await this.persist(rule);
|
|
6212
|
+
this.byId.set(rule.id, rule);
|
|
6213
|
+
return rule;
|
|
6214
|
+
}
|
|
6215
|
+
/**
|
|
6216
|
+
* Apply a partial patch. Immutable: returns the NEW rule object. Identity,
|
|
6217
|
+
* ownership and generation state are re-pinned from the existing rule AFTER
|
|
6218
|
+
* the spread — the patch schema carries none of them, and this makes a
|
|
6219
|
+
* hand-built (unparsed) patch object equally unable to re-own a rule.
|
|
6220
|
+
*
|
|
6221
|
+
* `template` is the one clearable field: an absent key leaves it unchanged,
|
|
6222
|
+
* an explicit `null` CLEARS it (the persisted rule loses the key — `null`
|
|
6223
|
+
* never reaches {@link TimelapseRuleSchema}). See the patch schema's wire
|
|
6224
|
+
* note.
|
|
6225
|
+
*/
|
|
6226
|
+
async update(ruleId, patch) {
|
|
6227
|
+
const existing = this.byId.get(ruleId);
|
|
6228
|
+
if (!existing) throw new Error(`timelapse rule not found: ${ruleId}`);
|
|
6229
|
+
const { template, ...rest } = patch;
|
|
6230
|
+
const merged = {
|
|
6231
|
+
...existing,
|
|
6232
|
+
...rest,
|
|
6233
|
+
id: existing.id,
|
|
6234
|
+
ownerUserId: existing.ownerUserId,
|
|
6235
|
+
lastGeneratedAt: existing.lastGeneratedAt,
|
|
6236
|
+
createdBy: existing.createdBy,
|
|
6237
|
+
createdAt: existing.createdAt,
|
|
6238
|
+
updatedAt: this.now()
|
|
6239
|
+
};
|
|
6240
|
+
return this.write(applyTemplatePatch(merged, template));
|
|
6241
|
+
}
|
|
6242
|
+
async setEnabled(ruleId, enabled) {
|
|
6243
|
+
return this.update(ruleId, { enabled });
|
|
6244
|
+
}
|
|
6245
|
+
/**
|
|
6246
|
+
* Record a successful generation. `at` is the generation epoch-ms — the
|
|
6247
|
+
* durable state behind the 1-hour re-generation guard. Does NOT bump
|
|
6248
|
+
* `updatedAt` (generation is not an edit of the rule definition).
|
|
6249
|
+
*/
|
|
6250
|
+
async markGenerated(ruleId, at) {
|
|
6251
|
+
const existing = this.byId.get(ruleId);
|
|
6252
|
+
if (!existing) throw new Error(`timelapse rule not found: ${ruleId}`);
|
|
6253
|
+
return this.write({
|
|
6254
|
+
...existing,
|
|
6255
|
+
lastGeneratedAt: at
|
|
6256
|
+
});
|
|
6257
|
+
}
|
|
6258
|
+
/** Idempotent delete — unknown ids are a no-op. */
|
|
6259
|
+
async delete(ruleId) {
|
|
6260
|
+
this.byId.delete(ruleId);
|
|
6261
|
+
try {
|
|
6262
|
+
await this.store.delete.mutate({
|
|
6263
|
+
collection: NC_TIMELAPSE_RULES_COLLECTION,
|
|
6264
|
+
key: ruleId
|
|
6265
|
+
});
|
|
6266
|
+
} catch (err) {
|
|
6267
|
+
this.logger.warn("timelapse rule delete failed", { meta: {
|
|
6268
|
+
ruleId,
|
|
6269
|
+
error: String(err)
|
|
6270
|
+
} });
|
|
6271
|
+
throw err instanceof Error ? err : new Error(String(err));
|
|
6272
|
+
}
|
|
6273
|
+
}
|
|
6274
|
+
/**
|
|
6275
|
+
* Re-validate a merged candidate, persist it, then cache it. Re-validating
|
|
6276
|
+
* means a patch can never persist a rule that would be skipped at the next
|
|
6277
|
+
* `load()`; provenance/ownership survive because they are spread from the
|
|
6278
|
+
* existing rule and never present on a patch.
|
|
6279
|
+
*/
|
|
6280
|
+
async write(candidate) {
|
|
6281
|
+
const rule = require_dist.TimelapseRuleSchema.parse(candidate);
|
|
6282
|
+
await this.persist(rule);
|
|
6283
|
+
this.byId.set(rule.id, rule);
|
|
6284
|
+
return rule;
|
|
6285
|
+
}
|
|
6286
|
+
async persist(rule) {
|
|
6287
|
+
await this.store.set.mutate({
|
|
6288
|
+
collection: NC_TIMELAPSE_RULES_COLLECTION,
|
|
6289
|
+
key: rule.id,
|
|
6290
|
+
value: {
|
|
6291
|
+
name: rule.name,
|
|
6292
|
+
enabled: rule.enabled,
|
|
6293
|
+
updatedAt: rule.updatedAt,
|
|
6294
|
+
rule
|
|
6295
|
+
}
|
|
6296
|
+
});
|
|
6297
|
+
}
|
|
6298
|
+
};
|
|
6299
|
+
//#endregion
|
|
5457
6300
|
//#region src/notification-center/index.ts
|
|
5458
6301
|
var DEFAULT_DRAIN_INTERVAL_MS = 2e3;
|
|
5459
6302
|
var DEFAULT_RULE_RELOAD_INTERVAL_MS = 3e4;
|
|
@@ -5548,7 +6391,7 @@ function outboxEntryToHistory(entry) {
|
|
|
5548
6391
|
}
|
|
5549
6392
|
};
|
|
5550
6393
|
}
|
|
5551
|
-
var NotificationCenter = class {
|
|
6394
|
+
var NotificationCenter = class NotificationCenter {
|
|
5552
6395
|
logger;
|
|
5553
6396
|
rules;
|
|
5554
6397
|
outbox;
|
|
@@ -5610,11 +6453,19 @@ var NotificationCenter = class {
|
|
|
5610
6453
|
get ruleStore() {
|
|
5611
6454
|
return this.rules;
|
|
5612
6455
|
}
|
|
5613
|
-
/**
|
|
6456
|
+
/**
|
|
6457
|
+
* Declare every Notification Center collection (idempotent, boot-time).
|
|
6458
|
+
*
|
|
6459
|
+
* EVERY store the module can touch belongs here, including ones whose
|
|
6460
|
+
* feature is not wired yet: an UNDECLARED collection answers 412 on first
|
|
6461
|
+
* use and takes the whole runner down with it. The timelapse store shipped
|
|
6462
|
+
* without this line and was one enable away from doing exactly that.
|
|
6463
|
+
*/
|
|
5614
6464
|
static async declare(store) {
|
|
5615
6465
|
await NcRuleStore.declare(store);
|
|
5616
6466
|
await NcOutbox.declare(store);
|
|
5617
6467
|
await OccupancyStore.declare(store);
|
|
6468
|
+
await TimelapseStore.declare(store);
|
|
5618
6469
|
}
|
|
5619
6470
|
/**
|
|
5620
6471
|
* Load rules (every node — the cap provider serves CRUD from any node).
|
|
@@ -5799,6 +6650,7 @@ var NotificationCenter = class {
|
|
|
5799
6650
|
listRules: async () => ({ rules: [...this.rules.list()] }),
|
|
5800
6651
|
getRule: async ({ ruleId }) => ({ rule: this.rules.get(ruleId) }),
|
|
5801
6652
|
createRule: async ({ rule, caller }) => {
|
|
6653
|
+
NotificationCenter.assertHasAddressee(rule.targets, rule.targetUsers);
|
|
5802
6654
|
await this.validateTargetRefs(rule.targets.map((t) => t.targetId));
|
|
5803
6655
|
const created = await this.rules.create(rule, caller.userId);
|
|
5804
6656
|
this.logger.info("notification rule created", { meta: {
|
|
@@ -5810,6 +6662,10 @@ var NotificationCenter = class {
|
|
|
5810
6662
|
},
|
|
5811
6663
|
updateRule: async ({ ruleId, patch, caller }) => {
|
|
5812
6664
|
if (patch.targets !== void 0) await this.validateTargetRefs(patch.targets.map((t) => t.targetId));
|
|
6665
|
+
if (patch.targets !== void 0 || patch.targetUsers !== void 0) {
|
|
6666
|
+
const existing = this.rules.get(ruleId);
|
|
6667
|
+
NotificationCenter.assertHasAddressee(patch.targets ?? existing?.targets ?? [], patch.targetUsers ?? existing?.targetUsers);
|
|
6668
|
+
}
|
|
5813
6669
|
const { disabledTargetIds: _optOut, ...safePatch } = patch;
|
|
5814
6670
|
const updated = await this.rules.update(ruleId, safePatch);
|
|
5815
6671
|
this.logger.info("notification rule updated", { meta: {
|
|
@@ -5827,7 +6683,10 @@ var NotificationCenter = class {
|
|
|
5827
6683
|
return { success: true };
|
|
5828
6684
|
},
|
|
5829
6685
|
testRule: async ({ rule, lookbackMinutes }) => ({ results: [...await this.dryRun(rule, lookbackMinutes)] }),
|
|
5830
|
-
getConditionCatalog: async () => ({
|
|
6686
|
+
getConditionCatalog: async () => ({
|
|
6687
|
+
catalog: [...require_dist.NC_CONDITION_CATALOG],
|
|
6688
|
+
taxonomy: require_dist.NC_TAXONOMY
|
|
6689
|
+
}),
|
|
5831
6690
|
getHistory: async ({ filter }) => {
|
|
5832
6691
|
const entries = await this.outbox.queryHistory({
|
|
5833
6692
|
...filter.ruleId !== void 0 ? { ruleId: filter.ruleId } : {},
|
|
@@ -5868,26 +6727,84 @@ var NotificationCenter = class {
|
|
|
5868
6727
|
async evaluateAndEnqueue(subject, kind) {
|
|
5869
6728
|
const delivery = kind === "object-event" || kind === "audio-event" ? "immediate" : kind === "occupancy-event" ? "device-event" : kind;
|
|
5870
6729
|
const candidates = this.rules.listEnabled(delivery);
|
|
5871
|
-
if (candidates.length === 0)
|
|
6730
|
+
if (candidates.length === 0) {
|
|
6731
|
+
this.logger.debug("no enabled rule for this trigger", {
|
|
6732
|
+
tags: { deviceId: subject.deviceId },
|
|
6733
|
+
meta: {
|
|
6734
|
+
delivery,
|
|
6735
|
+
kind,
|
|
6736
|
+
rulesLoaded: this.rules.list().length
|
|
6737
|
+
}
|
|
6738
|
+
});
|
|
6739
|
+
return;
|
|
6740
|
+
}
|
|
5872
6741
|
const now = this.now();
|
|
5873
6742
|
for (const rule of candidates) {
|
|
5874
6743
|
const evaluation = evaluateRule(rule, subject);
|
|
5875
|
-
if (!evaluation.matched)
|
|
6744
|
+
if (!evaluation.matched) {
|
|
6745
|
+
this.logger.debug("rule did not match", {
|
|
6746
|
+
tags: { deviceId: subject.deviceId },
|
|
6747
|
+
meta: {
|
|
6748
|
+
ruleId: rule.id,
|
|
6749
|
+
rule: rule.name,
|
|
6750
|
+
kind,
|
|
6751
|
+
failed: evaluation.failedCondition,
|
|
6752
|
+
classes: subject.classNames,
|
|
6753
|
+
eventId: subject.recordId,
|
|
6754
|
+
...subject.trackId !== void 0 ? { trackId: subject.trackId } : {}
|
|
6755
|
+
}
|
|
6756
|
+
});
|
|
6757
|
+
continue;
|
|
6758
|
+
}
|
|
5876
6759
|
const key = cooldownKey(rule, subject);
|
|
5877
|
-
if (isCoolingDown(rule, this.lastFiredAt.get(key), now))
|
|
5878
|
-
|
|
6760
|
+
if (isCoolingDown(rule, this.lastFiredAt.get(key), now)) {
|
|
6761
|
+
this.logger.debug("rule matched but is cooling down", {
|
|
6762
|
+
tags: { deviceId: subject.deviceId },
|
|
6763
|
+
meta: {
|
|
6764
|
+
ruleId: rule.id,
|
|
6765
|
+
rule: rule.name,
|
|
6766
|
+
key,
|
|
6767
|
+
eventId: subject.recordId,
|
|
6768
|
+
...subject.trackId !== void 0 ? { trackId: subject.trackId } : {}
|
|
6769
|
+
}
|
|
6770
|
+
});
|
|
6771
|
+
continue;
|
|
6772
|
+
}
|
|
6773
|
+
this.logger.info("rule matched — enqueueing", {
|
|
6774
|
+
tags: { deviceId: subject.deviceId },
|
|
6775
|
+
meta: {
|
|
6776
|
+
ruleId: rule.id,
|
|
6777
|
+
rule: rule.name,
|
|
6778
|
+
kind,
|
|
6779
|
+
targets: rule.targets.length,
|
|
6780
|
+
eventId: subject.recordId,
|
|
6781
|
+
...subject.trackId !== void 0 ? { trackId: subject.trackId } : {},
|
|
6782
|
+
...subject.confidence !== void 0 ? { confidence: subject.confidence } : {},
|
|
6783
|
+
...subject.zones.length > 0 ? { zones: subject.zones } : {}
|
|
6784
|
+
}
|
|
6785
|
+
});
|
|
6786
|
+
const userTargets = await this.resolveUserTargets(rule, subject.deviceId);
|
|
6787
|
+
if (await this.outbox.enqueue(this.buildEntries(rule, subject, kind, evaluation.matchedOn, userTargets)) > 0) this.lastFiredAt.set(key, now);
|
|
5879
6788
|
}
|
|
5880
6789
|
}
|
|
5881
|
-
buildEntries(rule, subject, kind, matchedOn) {
|
|
6790
|
+
buildEntries(rule, subject, kind, matchedOn, userTargets = []) {
|
|
5882
6791
|
const hasEventMedia = kind === "object-event" || kind === "package-event";
|
|
5883
6792
|
const isTrackScoped = kind === "object-event" || kind === "track-end";
|
|
5884
|
-
|
|
6793
|
+
const direct = new Set(rule.targets.map((t) => t.targetId));
|
|
6794
|
+
return [...rule.targets, ...userTargets.filter((t) => !direct.has(t.targetId))].filter((target) => !rule.disabledTargetIds.includes(target.targetId)).map((target) => {
|
|
5885
6795
|
const payload = {
|
|
5886
6796
|
ruleName: rule.name,
|
|
5887
6797
|
delivery: rule.delivery,
|
|
5888
6798
|
priority: rule.priority,
|
|
5889
6799
|
...rule.template !== void 0 ? { template: rule.template } : {},
|
|
5890
6800
|
media: rule.media.attach,
|
|
6801
|
+
...rule.media.zoneCrop === true && rule.conditions.zones !== void 0 ? { mediaZoneIds: [...rule.conditions.zones.ids] } : {},
|
|
6802
|
+
...rule.media.frame !== void 0 ? { mediaFrame: rule.media.frame } : {},
|
|
6803
|
+
...rule.media.profile !== void 0 ? { mediaProfile: rule.media.profile } : {},
|
|
6804
|
+
...rule.media.gif === true ? { mediaGif: true } : {},
|
|
6805
|
+
...rule.media.clip === true ? { mediaClip: true } : {},
|
|
6806
|
+
...rule.media.clipPreRollSec !== void 0 ? { mediaClipPreRollSec: rule.media.clipPreRollSec } : {},
|
|
6807
|
+
...rule.media.clipPostRollSec !== void 0 ? { mediaClipPostRollSec: rule.media.clipPostRollSec } : {},
|
|
5891
6808
|
...matchedOn !== void 0 && matchedOn.length > 0 ? { matchedOn } : {},
|
|
5892
6809
|
...target.params !== void 0 ? { params: target.params } : {},
|
|
5893
6810
|
subject: {
|
|
@@ -5913,6 +6830,63 @@ var NotificationCenter = class {
|
|
|
5913
6830
|
};
|
|
5914
6831
|
});
|
|
5915
6832
|
}
|
|
6833
|
+
/** rule→user fan-out caches (60 s users/targets; device routes immutable). */
|
|
6834
|
+
userGrantsCache = null;
|
|
6835
|
+
userGrantsAt = 0;
|
|
6836
|
+
ownedTargetsCache = null;
|
|
6837
|
+
ownedTargetsAt = 0;
|
|
6838
|
+
deviceRouteCache = /* @__PURE__ */ new Map();
|
|
6839
|
+
userFanoutUnavailableLogged = false;
|
|
6840
|
+
/**
|
|
6841
|
+
* The personal targets a rule's `targetUsers` resolve to for THIS device:
|
|
6842
|
+
* each addressed user contributes the enabled targets they own — but only
|
|
6843
|
+
* when their `allowedDevices` grant covers the firing camera (admin users
|
|
6844
|
+
* always pass). A user must never be notified about a device they cannot
|
|
6845
|
+
* open.
|
|
6846
|
+
*/
|
|
6847
|
+
async resolveUserTargets(rule, deviceId) {
|
|
6848
|
+
const users = rule.targetUsers;
|
|
6849
|
+
if (users === void 0 || users.length === 0) return [];
|
|
6850
|
+
const { listUsers, getDeviceRoute } = this.deps;
|
|
6851
|
+
if (listUsers === void 0 || getDeviceRoute === void 0) {
|
|
6852
|
+
if (!this.userFanoutUnavailableLogged) {
|
|
6853
|
+
this.userFanoutUnavailableLogged = true;
|
|
6854
|
+
this.logger.warn("rule addresses users but the user fan-out deps are not wired", { meta: { ruleId: rule.id } });
|
|
6855
|
+
}
|
|
6856
|
+
return [];
|
|
6857
|
+
}
|
|
6858
|
+
const now = this.now();
|
|
6859
|
+
if (this.userGrantsCache === null || now - this.userGrantsAt > 6e4) {
|
|
6860
|
+
this.userGrantsCache = await listUsers();
|
|
6861
|
+
this.userGrantsAt = now;
|
|
6862
|
+
}
|
|
6863
|
+
if (this.ownedTargetsCache === null || now - this.ownedTargetsAt > 6e4) {
|
|
6864
|
+
const targets = await this.deps.dispatcher.listTargets();
|
|
6865
|
+
const owned = /* @__PURE__ */ new Map();
|
|
6866
|
+
for (const t of targets) {
|
|
6867
|
+
if (t.ownerUserId === void 0 || !t.enabled) continue;
|
|
6868
|
+
const list = owned.get(t.ownerUserId) ?? [];
|
|
6869
|
+
list.push(t.id);
|
|
6870
|
+
owned.set(t.ownerUserId, list);
|
|
6871
|
+
}
|
|
6872
|
+
this.ownedTargetsCache = owned;
|
|
6873
|
+
this.ownedTargetsAt = now;
|
|
6874
|
+
}
|
|
6875
|
+
if (!this.deviceRouteCache.has(deviceId)) this.deviceRouteCache.set(deviceId, await getDeviceRoute(deviceId));
|
|
6876
|
+
const route = this.deviceRouteCache.get(deviceId) ?? null;
|
|
6877
|
+
const out = [];
|
|
6878
|
+
for (const userId of users) {
|
|
6879
|
+
const user = this.userGrantsCache.find((u) => u.id === userId);
|
|
6880
|
+
if (user === void 0) continue;
|
|
6881
|
+
if (!user.isAdmin) {
|
|
6882
|
+
if (route === null) continue;
|
|
6883
|
+
const grant = user.allowedDevices[route.addonId];
|
|
6884
|
+
if (!(grant === "*" || Array.isArray(grant) && grant.includes(route.stableId))) continue;
|
|
6885
|
+
}
|
|
6886
|
+
for (const targetId of this.ownedTargetsCache.get(userId) ?? []) out.push({ targetId });
|
|
6887
|
+
}
|
|
6888
|
+
return out;
|
|
6889
|
+
}
|
|
5916
6890
|
/** Rebuild the cooldown map from persisted outbox rows (restart-proof). */
|
|
5917
6891
|
async seedCooldowns() {
|
|
5918
6892
|
const entries = await this.outbox.queryRecentPersisted(this.now() - 864e5);
|
|
@@ -6026,10 +7000,21 @@ var NotificationCenter = class {
|
|
|
6026
7000
|
this.drainTicks += 1;
|
|
6027
7001
|
if (this.drainTicks % WATERMARK_EVERY_TICKS === 0) await this.outbox.setWatermark(this.now());
|
|
6028
7002
|
}
|
|
7003
|
+
/** The "at least one addressee" invariant lives here, not in Zod: the
|
|
7004
|
+
* schema allows empty `targets` (a users-only rule) and a cross-field
|
|
7005
|
+
* refine would break `NcRulePatchSchema.partial()`. */
|
|
7006
|
+
static assertHasAddressee(targets, targetUsers) {
|
|
7007
|
+
if (targets.length === 0 && (targetUsers?.length ?? 0) === 0) throw new Error("a rule needs at least one delivery target or user");
|
|
7008
|
+
}
|
|
6029
7009
|
/** Rule-save referential check: every targetId must resolve in the
|
|
6030
7010
|
* live notification-output catalog (spec §2.3 save-time validation). */
|
|
6031
7011
|
async validateTargetRefs(targetIds) {
|
|
7012
|
+
if (targetIds.length === 0) return;
|
|
6032
7013
|
const targets = await this.deps.dispatcher.listTargets();
|
|
7014
|
+
if (targets.length === 0) {
|
|
7015
|
+
this.logger.warn("target catalog came back EMPTY at rule save — the notification-output catalog is unreachable or under-reporting (collection fan-out regression?). Skipping the referential check; delivery re-checks with retry semantics.", { meta: { targetIds: [...targetIds] } });
|
|
7016
|
+
return;
|
|
7017
|
+
}
|
|
6033
7018
|
const known = new Set(targets.map((t) => t.id));
|
|
6034
7019
|
for (const id of targetIds) if (!known.has(id)) throw new Error(`unknown notification target: ${id}`);
|
|
6035
7020
|
}
|
|
@@ -6144,15 +7129,28 @@ function makeNcActionHandlers(deps) {
|
|
|
6144
7129
|
if (rule === null) throw new Error(`forbidden: rule not found: ${ruleId}`);
|
|
6145
7130
|
return rule;
|
|
6146
7131
|
};
|
|
6147
|
-
|
|
7132
|
+
/**
|
|
7133
|
+
* A rule the caller may EDIT. Ownership binds non-admins; an ADMIN
|
|
7134
|
+
* administers every rule — global ones (`ownerUserId: undefined`, what the
|
|
7135
|
+
* admin UI creates) and, for support, a user's personal rule.
|
|
7136
|
+
*
|
|
7137
|
+
* Without the admin bypass the comparison `rule.ownerUserId === userId` is
|
|
7138
|
+
* false for EVERY caller on a global rule, so the viewer refused to let an
|
|
7139
|
+
* admin edit an admin rule (operator report, 2026-07-30). Same bypass shape
|
|
7140
|
+
* as `assertTargetsOwned`.
|
|
7141
|
+
*/
|
|
7142
|
+
const assertRuleEditable = (ruleId, caller) => {
|
|
6148
7143
|
const rule = assertOwnsRule(ruleId);
|
|
6149
|
-
if (
|
|
7144
|
+
if (caller.isAdmin) return rule;
|
|
7145
|
+
if (rule.ownerUserId !== caller.userId) throw new Error(`forbidden: rule not owned: ${ruleId}`);
|
|
6150
7146
|
return rule;
|
|
6151
7147
|
};
|
|
6152
|
-
/** A rule the caller may SEE — his own personal rule
|
|
6153
|
-
|
|
7148
|
+
/** A rule the caller may SEE — his own personal rule, a global/admin rule, or
|
|
7149
|
+
* (for an admin) any rule at all. */
|
|
7150
|
+
const assertRuleVisible = (ruleId, caller) => {
|
|
6154
7151
|
const rule = assertOwnsRule(ruleId);
|
|
6155
|
-
if (
|
|
7152
|
+
if (caller.isAdmin) return rule;
|
|
7153
|
+
if (rule.ownerUserId !== void 0 && rule.ownerUserId !== caller.userId) throw new Error(`forbidden: rule not owned: ${ruleId}`);
|
|
6156
7154
|
return rule;
|
|
6157
7155
|
};
|
|
6158
7156
|
const assertTargetsOwned = async (targetIds, caller) => {
|
|
@@ -6163,9 +7161,9 @@ function makeNcActionHandlers(deps) {
|
|
|
6163
7161
|
return {
|
|
6164
7162
|
"nc.listRules": async (_input, caller) => {
|
|
6165
7163
|
const c = requireCaller(caller);
|
|
6166
|
-
return { rules: deps.ruleStore.listForOwner(c.userId).map((r) => ({
|
|
7164
|
+
return { rules: (c.isAdmin ? deps.ruleStore.list() : deps.ruleStore.listForOwner(c.userId)).map((r) => ({
|
|
6167
7165
|
...r,
|
|
6168
|
-
readOnly: r.ownerUserId !== c.userId
|
|
7166
|
+
readOnly: !c.isAdmin && r.ownerUserId !== c.userId
|
|
6169
7167
|
})) };
|
|
6170
7168
|
},
|
|
6171
7169
|
"nc.getConditionCatalog": async () => ({
|
|
@@ -6188,7 +7186,7 @@ function makeNcActionHandlers(deps) {
|
|
|
6188
7186
|
},
|
|
6189
7187
|
"nc.updateRule": async (input, caller) => {
|
|
6190
7188
|
const c = requireCaller(caller);
|
|
6191
|
-
|
|
7189
|
+
assertRuleEditable(input.ruleId, c);
|
|
6192
7190
|
const patch = require_dist.NcRulePatchSchema.parse(input.patch);
|
|
6193
7191
|
if (patch.targets !== void 0) await assertTargetsOwned(patch.targets.map((t) => t.targetId), c);
|
|
6194
7192
|
const { ownerUserId: _owner, disabledTargetIds: _optOut, ...safe } = patch;
|
|
@@ -6201,7 +7199,7 @@ function makeNcActionHandlers(deps) {
|
|
|
6201
7199
|
},
|
|
6202
7200
|
"nc.deleteRule": async (input, caller) => {
|
|
6203
7201
|
const c = requireCaller(caller);
|
|
6204
|
-
|
|
7202
|
+
assertRuleEditable(input.ruleId, c);
|
|
6205
7203
|
await deps.ruleStore.delete(input.ruleId);
|
|
6206
7204
|
deps.logger.info("nc rule deleted", { meta: {
|
|
6207
7205
|
ruleId: input.ruleId,
|
|
@@ -6211,7 +7209,7 @@ function makeNcActionHandlers(deps) {
|
|
|
6211
7209
|
},
|
|
6212
7210
|
"nc.setRuleTargetEnabled": async (input, caller) => {
|
|
6213
7211
|
const c = requireCaller(caller);
|
|
6214
|
-
assertRuleVisible(input.ruleId, c
|
|
7212
|
+
assertRuleVisible(input.ruleId, c);
|
|
6215
7213
|
await assertTargetsOwned([input.targetId], c);
|
|
6216
7214
|
await deps.ruleStore.setRuleTargetEnabled(input.ruleId, input.targetId, input.enabled);
|
|
6217
7215
|
return { success: true };
|
|
@@ -8863,7 +9861,15 @@ var MEDIA_COLUMNS = [
|
|
|
8863
9861
|
name: "sizeBytes",
|
|
8864
9862
|
type: "INTEGER",
|
|
8865
9863
|
notNull: true
|
|
8866
|
-
}
|
|
9864
|
+
},
|
|
9865
|
+
(
|
|
9866
|
+
/** Storage location holding the blob. NULL = the default `eventMedia`
|
|
9867
|
+
* location (every pre-Phase-3 row, and every write until multi-location
|
|
9868
|
+
* events exist) — the relocate mover stamps real ids as it moves blobs. */
|
|
9869
|
+
{
|
|
9870
|
+
name: "locationId",
|
|
9871
|
+
type: "TEXT"
|
|
9872
|
+
})
|
|
8867
9873
|
];
|
|
8868
9874
|
var MEDIA_INDEXES = [{
|
|
8869
9875
|
name: "idx_media_owner",
|
|
@@ -8872,11 +9878,18 @@ var MEDIA_INDEXES = [{
|
|
|
8872
9878
|
name: "idx_media_device_ts",
|
|
8873
9879
|
columns: ["deviceId", "timestamp"]
|
|
8874
9880
|
}];
|
|
9881
|
+
/** The storage location of a media row/record: its stamped `locationId`, or
|
|
9882
|
+
* the default `eventMedia` location for NULL (pre-multi-location) rows. */
|
|
9883
|
+
var DEFAULT_MEDIA_LOCATION = "eventMedia";
|
|
9884
|
+
function mediaRowLocation(data) {
|
|
9885
|
+
const id = data?.["locationId"];
|
|
9886
|
+
return typeof id === "string" && id.length > 0 ? id : DEFAULT_MEDIA_LOCATION;
|
|
9887
|
+
}
|
|
8875
9888
|
function buildKey(params) {
|
|
8876
9889
|
return isSingleInstanceKind(params.kind) ? `${params.ownerKind}:${params.ownerId}:${params.kind}` : `${params.ownerKind}:${params.ownerId}:${params.kind}:${params.timestamp}`;
|
|
8877
9890
|
}
|
|
8878
9891
|
function buildPath(params) {
|
|
8879
|
-
const base =
|
|
9892
|
+
const base = `${params.deviceId}/events/${params.ownerKind}/${params.ownerId}`;
|
|
8880
9893
|
return isSingleInstanceKind(params.kind) ? `${base}/${params.kind}.jpg` : `${base}/${params.kind}-${params.timestamp}.jpg`;
|
|
8881
9894
|
}
|
|
8882
9895
|
var MediaStore = class {
|
|
@@ -8907,7 +9920,8 @@ var MediaStore = class {
|
|
|
8907
9920
|
kind: params.kind,
|
|
8908
9921
|
timestamp: params.timestamp,
|
|
8909
9922
|
path,
|
|
8910
|
-
sizeBytes: params.data.length
|
|
9923
|
+
sizeBytes: params.data.length,
|
|
9924
|
+
locationId: null
|
|
8911
9925
|
};
|
|
8912
9926
|
try {
|
|
8913
9927
|
await this.storage.write({
|
|
@@ -8964,10 +9978,11 @@ var MediaStore = class {
|
|
|
8964
9978
|
const newKey = await this.put(params);
|
|
8965
9979
|
for (const row of existing) {
|
|
8966
9980
|
if (row.id === newKey) continue;
|
|
8967
|
-
const
|
|
9981
|
+
const rowData = row.data;
|
|
9982
|
+
const path = String(rowData["path"] ?? "");
|
|
8968
9983
|
if (path) try {
|
|
8969
9984
|
await this.storage.delete({
|
|
8970
|
-
location:
|
|
9985
|
+
location: mediaRowLocation(rowData),
|
|
8971
9986
|
relativePath: path
|
|
8972
9987
|
});
|
|
8973
9988
|
} catch {}
|
|
@@ -9035,7 +10050,7 @@ var MediaStore = class {
|
|
|
9035
10050
|
key,
|
|
9036
10051
|
kind,
|
|
9037
10052
|
base64: (await this.storage.read({
|
|
9038
|
-
location:
|
|
10053
|
+
location: mediaRowLocation(data),
|
|
9039
10054
|
relativePath: path
|
|
9040
10055
|
})).toString("base64"),
|
|
9041
10056
|
sizeBytes,
|
|
@@ -9055,10 +10070,11 @@ var MediaStore = class {
|
|
|
9055
10070
|
key
|
|
9056
10071
|
});
|
|
9057
10072
|
if (!row) return;
|
|
9058
|
-
const
|
|
10073
|
+
const data = row;
|
|
10074
|
+
const path = String(data["path"] ?? "");
|
|
9059
10075
|
if (path) try {
|
|
9060
10076
|
await this.storage.delete({
|
|
9061
|
-
location:
|
|
10077
|
+
location: mediaRowLocation(data),
|
|
9062
10078
|
relativePath: path
|
|
9063
10079
|
});
|
|
9064
10080
|
} catch {}
|
|
@@ -9097,7 +10113,7 @@ var MediaStore = class {
|
|
|
9097
10113
|
const kind = String(data["kind"]);
|
|
9098
10114
|
try {
|
|
9099
10115
|
const buf = await this.storage.read({
|
|
9100
|
-
location:
|
|
10116
|
+
location: mediaRowLocation(data),
|
|
9101
10117
|
relativePath: path
|
|
9102
10118
|
});
|
|
9103
10119
|
files.push({
|
|
@@ -9132,10 +10148,11 @@ var MediaStore = class {
|
|
|
9132
10148
|
} }
|
|
9133
10149
|
});
|
|
9134
10150
|
for (const row of rows) {
|
|
9135
|
-
const
|
|
10151
|
+
const rowData = row.data;
|
|
10152
|
+
const path = String(rowData["path"] ?? "");
|
|
9136
10153
|
try {
|
|
9137
10154
|
if (path) await this.storage.delete({
|
|
9138
|
-
location:
|
|
10155
|
+
location: mediaRowLocation(rowData),
|
|
9139
10156
|
relativePath: path
|
|
9140
10157
|
});
|
|
9141
10158
|
} catch {}
|
|
@@ -9231,14 +10248,14 @@ var MediaStore = class {
|
|
|
9231
10248
|
kind,
|
|
9232
10249
|
timestamp,
|
|
9233
10250
|
data: await this.storage.read({
|
|
9234
|
-
location:
|
|
10251
|
+
location: mediaRowLocation(meta),
|
|
9235
10252
|
relativePath: oldPath
|
|
9236
10253
|
})
|
|
9237
10254
|
};
|
|
9238
10255
|
const newKey = await this.put(newParams);
|
|
9239
10256
|
try {
|
|
9240
10257
|
await this.storage.delete({
|
|
9241
|
-
location:
|
|
10258
|
+
location: mediaRowLocation(meta),
|
|
9242
10259
|
relativePath: oldPath
|
|
9243
10260
|
});
|
|
9244
10261
|
} catch (err) {
|
|
@@ -9308,10 +10325,11 @@ var MediaStore = class {
|
|
|
9308
10325
|
if (rows.length === 0) break;
|
|
9309
10326
|
let deletedInPage = 0;
|
|
9310
10327
|
for (const row of rows) {
|
|
9311
|
-
const
|
|
10328
|
+
const rowData = row.data;
|
|
10329
|
+
const path = String(rowData["path"] ?? "");
|
|
9312
10330
|
try {
|
|
9313
10331
|
if (path) await this.storage.delete({
|
|
9314
|
-
location:
|
|
10332
|
+
location: mediaRowLocation(rowData),
|
|
9315
10333
|
relativePath: path
|
|
9316
10334
|
});
|
|
9317
10335
|
} catch {}
|
|
@@ -9867,6 +10885,7 @@ var EventStore = class {
|
|
|
9867
10885
|
const rows = await this.store.query.query({
|
|
9868
10886
|
collection,
|
|
9869
10887
|
filter: {
|
|
10888
|
+
...params.deviceId !== void 0 ? { where: { deviceId: params.deviceId } } : {},
|
|
9870
10889
|
whereBetween: { timestamp: [0, cutoffMs] },
|
|
9871
10890
|
limit: 500
|
|
9872
10891
|
}
|
|
@@ -10267,6 +11286,183 @@ function stripNulls(data) {
|
|
|
10267
11286
|
return out;
|
|
10268
11287
|
}
|
|
10269
11288
|
//#endregion
|
|
11289
|
+
//#region src/pipeline-analytics/location-aware-media-storage.ts
|
|
11290
|
+
/**
|
|
11291
|
+
* Location-aware blob storage for event media (entity-routing spec, Phase 3).
|
|
11292
|
+
*
|
|
11293
|
+
* Media rows may carry a `locationId` (stamped by the relocate mover when a
|
|
11294
|
+
* blob is moved off the default location). The write-rate bypass provider
|
|
11295
|
+
* only knows the default media root — this wrapper routes any OTHER location
|
|
11296
|
+
* id through a resolver (the storage cap's `resolve`, cached forever: a
|
|
11297
|
+
* location's root only changes via operator reconfig, which restarts us) and
|
|
11298
|
+
* does direct fs I/O against that root, keeping the bypass's
|
|
11299
|
+
* no-RPC-per-blob property for every location.
|
|
11300
|
+
*/
|
|
11301
|
+
function createLocationAwareMediaStorage(deps) {
|
|
11302
|
+
const roots = /* @__PURE__ */ new Map();
|
|
11303
|
+
const rootOf = async (locationId) => {
|
|
11304
|
+
const cached = roots.get(locationId);
|
|
11305
|
+
if (cached !== void 0) return cached;
|
|
11306
|
+
const root = await deps.resolveRoot(locationId);
|
|
11307
|
+
roots.set(locationId, root);
|
|
11308
|
+
return root;
|
|
11309
|
+
};
|
|
11310
|
+
const absOf = async (locationId, relativePath) => node_path.default.join(await rootOf(locationId), relativePath);
|
|
11311
|
+
return {
|
|
11312
|
+
write: async (input) => {
|
|
11313
|
+
if (input.location === deps.defaultLocation) return deps.base.write(input);
|
|
11314
|
+
const abs = await absOf(input.location, input.relativePath);
|
|
11315
|
+
await node_fs.promises.mkdir(node_path.default.dirname(abs), { recursive: true });
|
|
11316
|
+
await node_fs.promises.writeFile(abs, input.data);
|
|
11317
|
+
},
|
|
11318
|
+
read: async (input) => {
|
|
11319
|
+
if (input.location === deps.defaultLocation) return deps.base.read(input);
|
|
11320
|
+
return node_fs.promises.readFile(await absOf(input.location, input.relativePath));
|
|
11321
|
+
},
|
|
11322
|
+
delete: async (input) => {
|
|
11323
|
+
if (input.location === deps.defaultLocation) return deps.base.delete(input);
|
|
11324
|
+
await node_fs.promises.rm(await absOf(input.location, input.relativePath), { force: true });
|
|
11325
|
+
}
|
|
11326
|
+
};
|
|
11327
|
+
}
|
|
11328
|
+
//#endregion
|
|
11329
|
+
//#region src/pipeline-analytics/media-relocate-engine.ts
|
|
11330
|
+
var PAGE_SIZE = 200;
|
|
11331
|
+
var DEFAULT_THROTTLE_MBPS = 40;
|
|
11332
|
+
function snapshot(j) {
|
|
11333
|
+
return {
|
|
11334
|
+
jobId: j.jobId,
|
|
11335
|
+
state: j.state,
|
|
11336
|
+
fromLocationId: j.fromLocationId,
|
|
11337
|
+
toLocationId: j.toLocationId,
|
|
11338
|
+
deviceId: j.deviceId,
|
|
11339
|
+
entities: ["media"],
|
|
11340
|
+
filesMoved: j.filesMoved,
|
|
11341
|
+
bytesMoved: j.bytesMoved,
|
|
11342
|
+
filesTotal: null,
|
|
11343
|
+
startedAt: j.startedAt,
|
|
11344
|
+
finishedAt: j.finishedAt,
|
|
11345
|
+
error: j.error
|
|
11346
|
+
};
|
|
11347
|
+
}
|
|
11348
|
+
var MediaRelocateEngine = class {
|
|
11349
|
+
deps;
|
|
11350
|
+
jobs = /* @__PURE__ */ new Map();
|
|
11351
|
+
constructor(deps) {
|
|
11352
|
+
this.deps = deps;
|
|
11353
|
+
}
|
|
11354
|
+
list() {
|
|
11355
|
+
return [...this.jobs.values()].sort((a, b) => b.startedAt - a.startedAt).map(snapshot);
|
|
11356
|
+
}
|
|
11357
|
+
cancel(jobId) {
|
|
11358
|
+
const job = this.jobs.get(jobId);
|
|
11359
|
+
if (!job || job.state !== "running") return false;
|
|
11360
|
+
job.cancelRequested = true;
|
|
11361
|
+
return true;
|
|
11362
|
+
}
|
|
11363
|
+
start(input) {
|
|
11364
|
+
for (const j of this.jobs.values()) if (j.state === "running") throw new Error(`a media relocation is already running (${j.jobId})`);
|
|
11365
|
+
const job = {
|
|
11366
|
+
jobId: this.deps.newId(),
|
|
11367
|
+
state: "running",
|
|
11368
|
+
fromLocationId: "*",
|
|
11369
|
+
toLocationId: input.toLocationId,
|
|
11370
|
+
deviceId: input.deviceId ?? null,
|
|
11371
|
+
filesMoved: 0,
|
|
11372
|
+
bytesMoved: 0,
|
|
11373
|
+
startedAt: this.deps.now(),
|
|
11374
|
+
finishedAt: null,
|
|
11375
|
+
error: null,
|
|
11376
|
+
cancelRequested: false
|
|
11377
|
+
};
|
|
11378
|
+
this.jobs.set(job.jobId, job);
|
|
11379
|
+
this.run(job, input.throttleMbps ?? DEFAULT_THROTTLE_MBPS);
|
|
11380
|
+
return job.jobId;
|
|
11381
|
+
}
|
|
11382
|
+
async run(job, throttleMbps) {
|
|
11383
|
+
const sleep = this.deps.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
|
|
11384
|
+
const bytesPerMs = throttleMbps * 1024 * 1024 / 1e3;
|
|
11385
|
+
try {
|
|
11386
|
+
await this.deps.resolveTargetRoot(job.toLocationId);
|
|
11387
|
+
let cursor = 0;
|
|
11388
|
+
let seenAtCursor = /* @__PURE__ */ new Set();
|
|
11389
|
+
for (;;) {
|
|
11390
|
+
if (job.cancelRequested) break;
|
|
11391
|
+
const fresh = (await this.deps.store.query.query({
|
|
11392
|
+
collection: MEDIA_COLLECTION,
|
|
11393
|
+
filter: {
|
|
11394
|
+
...job.deviceId !== null ? { where: { deviceId: job.deviceId } } : {},
|
|
11395
|
+
whereBetween: { timestamp: [cursor, Number.MAX_SAFE_INTEGER] },
|
|
11396
|
+
orderBy: {
|
|
11397
|
+
field: "timestamp",
|
|
11398
|
+
direction: "asc"
|
|
11399
|
+
},
|
|
11400
|
+
limit: PAGE_SIZE
|
|
11401
|
+
}
|
|
11402
|
+
})).filter((r) => !seenAtCursor.has(r.id));
|
|
11403
|
+
if (fresh.length === 0) break;
|
|
11404
|
+
for (const row of fresh) {
|
|
11405
|
+
if (job.cancelRequested) break;
|
|
11406
|
+
const data = row.data;
|
|
11407
|
+
const from = mediaRowLocation(data);
|
|
11408
|
+
if (from === job.toLocationId) continue;
|
|
11409
|
+
const relativePath = String(data["path"] ?? "");
|
|
11410
|
+
if (relativePath.length === 0) continue;
|
|
11411
|
+
try {
|
|
11412
|
+
const bytes = await this.deps.storage.read({
|
|
11413
|
+
location: from,
|
|
11414
|
+
relativePath
|
|
11415
|
+
});
|
|
11416
|
+
await this.deps.storage.write({
|
|
11417
|
+
location: job.toLocationId,
|
|
11418
|
+
relativePath,
|
|
11419
|
+
data: bytes
|
|
11420
|
+
});
|
|
11421
|
+
await this.deps.store.set.mutate({
|
|
11422
|
+
collection: MEDIA_COLLECTION,
|
|
11423
|
+
key: row.id,
|
|
11424
|
+
value: {
|
|
11425
|
+
...data,
|
|
11426
|
+
locationId: job.toLocationId
|
|
11427
|
+
}
|
|
11428
|
+
});
|
|
11429
|
+
await this.deps.storage.delete({
|
|
11430
|
+
location: from,
|
|
11431
|
+
relativePath
|
|
11432
|
+
});
|
|
11433
|
+
job.filesMoved++;
|
|
11434
|
+
job.bytesMoved += bytes.length;
|
|
11435
|
+
await sleep(bytes.length / bytesPerMs);
|
|
11436
|
+
} catch (err) {
|
|
11437
|
+
this.deps.logger.debug("media relocate row failed", { meta: {
|
|
11438
|
+
key: row.id,
|
|
11439
|
+
error: String(err)
|
|
11440
|
+
} });
|
|
11441
|
+
}
|
|
11442
|
+
}
|
|
11443
|
+
const last = fresh[fresh.length - 1];
|
|
11444
|
+
const lastTs = Number(last.data["timestamp"] ?? cursor);
|
|
11445
|
+
if (lastTs === cursor) for (const r of fresh) seenAtCursor.add(r.id);
|
|
11446
|
+
else {
|
|
11447
|
+
cursor = lastTs;
|
|
11448
|
+
seenAtCursor = new Set(fresh.filter((r) => Number(r.data["timestamp"]) === lastTs).map((r) => r.id));
|
|
11449
|
+
}
|
|
11450
|
+
}
|
|
11451
|
+
job.state = job.cancelRequested ? "cancelled" : "done";
|
|
11452
|
+
} catch (err) {
|
|
11453
|
+
job.state = "failed";
|
|
11454
|
+
job.error = err instanceof Error ? err.message : String(err);
|
|
11455
|
+
this.deps.logger.warn("media relocate job failed", { meta: {
|
|
11456
|
+
jobId: job.jobId,
|
|
11457
|
+
error: job.error
|
|
11458
|
+
} });
|
|
11459
|
+
} finally {
|
|
11460
|
+
job.finishedAt = this.deps.now();
|
|
11461
|
+
this.deps.onFinished?.(snapshot(job));
|
|
11462
|
+
}
|
|
11463
|
+
}
|
|
11464
|
+
};
|
|
11465
|
+
//#endregion
|
|
10270
11466
|
//#region src/pipeline-analytics/store/sensor-event-store.ts
|
|
10271
11467
|
var SENSOR_EVENTS_COLLECTION = "pipeline-analytics:sensor-events";
|
|
10272
11468
|
var SENSOR_EVENT_COLUMNS = [
|
|
@@ -11393,7 +12589,7 @@ var EventMediaDispatcher = class {
|
|
|
11393
12589
|
if (sn.rollingLastFrame && boxed) lastFrameWritten = await this.replaceKind(deviceId, sn.trackId, "lastFrame", sn.timestamp, boxed);
|
|
11394
12590
|
let thumbnailWritten = false;
|
|
11395
12591
|
if (sn.bestThumbnail) {
|
|
11396
|
-
const variants = await this.cropSubjectVariants(frameHandle, fw, fh, sn.bbox);
|
|
12592
|
+
const variants = await this.cropSubjectVariants(deviceId, frameHandle, fw, fh, sn.bbox);
|
|
11397
12593
|
if (variants) {
|
|
11398
12594
|
thumbnailWritten = await this.replaceKind(deviceId, sn.trackId, "thumbnail", sn.timestamp, variants.thumbnail);
|
|
11399
12595
|
await this.replaceKind(deviceId, sn.trackId, "thumbnailSmall", sn.timestamp, variants.thumbnailSmall);
|
|
@@ -11495,12 +12691,15 @@ var EventMediaDispatcher = class {
|
|
|
11495
12691
|
* per-frame retry lands a real native crop later. Never a local resize
|
|
11496
12692
|
* upscale of a ≤640 tile (a blurred lie).
|
|
11497
12693
|
*/
|
|
11498
|
-
async cropSubjectVariants(frameHandle, fw, fh, bbox) {
|
|
12694
|
+
async cropSubjectVariants(deviceId, frameHandle, fw, fh, bbox) {
|
|
11499
12695
|
if (!this.deps.getNativeCropJpeg) {
|
|
11500
|
-
this.deps.logger.warn("native subject crop miss — will retry, no upscale", {
|
|
11501
|
-
|
|
11502
|
-
|
|
11503
|
-
|
|
12696
|
+
this.deps.logger.warn("native subject crop miss — will retry, no upscale", {
|
|
12697
|
+
tags: { deviceId },
|
|
12698
|
+
meta: {
|
|
12699
|
+
shmId: frameHandle.shmId,
|
|
12700
|
+
reason: "no-native-cap"
|
|
12701
|
+
}
|
|
12702
|
+
});
|
|
11504
12703
|
return null;
|
|
11505
12704
|
}
|
|
11506
12705
|
try {
|
|
@@ -11508,12 +12707,19 @@ var EventMediaDispatcher = class {
|
|
|
11508
12707
|
W: fw,
|
|
11509
12708
|
H: fh
|
|
11510
12709
|
});
|
|
12710
|
+
const askedAt = Date.now();
|
|
11511
12711
|
const slab = await this.deps.getNativeCropJpeg(frameHandle, layout.fetch);
|
|
11512
12712
|
if (!slab) {
|
|
11513
|
-
this.deps.logger.warn("native subject crop miss — will retry, no upscale", {
|
|
11514
|
-
|
|
11515
|
-
|
|
11516
|
-
|
|
12713
|
+
this.deps.logger.warn("native subject crop miss — will retry, no upscale", {
|
|
12714
|
+
tags: { deviceId },
|
|
12715
|
+
meta: {
|
|
12716
|
+
shmId: frameHandle.shmId,
|
|
12717
|
+
handle: `${frameHandle.shmId}#${frameHandle.slot}#${frameHandle.seq}`,
|
|
12718
|
+
handleNodeId: frameHandle.nodeId,
|
|
12719
|
+
roundTripMs: Date.now() - askedAt,
|
|
12720
|
+
reason: "native-miss"
|
|
12721
|
+
}
|
|
12722
|
+
});
|
|
11517
12723
|
return null;
|
|
11518
12724
|
}
|
|
11519
12725
|
const thumbnail = await composeWideCentralSquareThumbnail(slab, layout);
|
|
@@ -11522,10 +12728,14 @@ var EventMediaDispatcher = class {
|
|
|
11522
12728
|
thumbnailSmall: await deriveThumbnailSmall(thumbnail)
|
|
11523
12729
|
};
|
|
11524
12730
|
} catch (err) {
|
|
11525
|
-
this.deps.logger.warn("native subject crop miss — will retry, no upscale", {
|
|
11526
|
-
|
|
11527
|
-
|
|
11528
|
-
|
|
12731
|
+
this.deps.logger.warn("native subject crop miss — will retry, no upscale", {
|
|
12732
|
+
tags: { deviceId },
|
|
12733
|
+
meta: {
|
|
12734
|
+
shmId: frameHandle.shmId,
|
|
12735
|
+
reason: "threw",
|
|
12736
|
+
error: err instanceof Error ? err.message : String(err)
|
|
12737
|
+
}
|
|
12738
|
+
});
|
|
11529
12739
|
return null;
|
|
11530
12740
|
}
|
|
11531
12741
|
}
|
|
@@ -12717,6 +13927,23 @@ function resolveDetectionSensitivitySettings(raw) {
|
|
|
12717
13927
|
};
|
|
12718
13928
|
}
|
|
12719
13929
|
var TrackingSettingsSchema = require_dist.object({
|
|
13930
|
+
/**
|
|
13931
|
+
* How much of a detection's box must lie inside a zone (0-1 fraction of the
|
|
13932
|
+
* box's own area) for that zone to be STAMPED onto the detection.
|
|
13933
|
+
*
|
|
13934
|
+
* This is the field a zone-scoped notification rule ultimately depends on: a
|
|
13935
|
+
* rule's `zones` condition is a plain set test over the stamped zone ids, so
|
|
13936
|
+
* a subject that merely clips a zone edge satisfies it. Measured on
|
|
13937
|
+
* 2026-07-30: a dog overlapping `Aiuola` by 4.8% would have counted as
|
|
13938
|
+
* inside it.
|
|
13939
|
+
*
|
|
13940
|
+
* DEFAULT 0 — byte-identical to the behaviour before 2026-07-31. Raising it
|
|
13941
|
+
* is deliberately an operator decision: the overlap fractions are now logged
|
|
13942
|
+
* (`zone membership` lines), so the bar can be chosen from the distribution
|
|
13943
|
+
* instead of guessed. Distinct from a zone RULE's `bboxInclusionPct`, which
|
|
13944
|
+
* gates the DETECTION stage, not what gets stamped.
|
|
13945
|
+
*/
|
|
13946
|
+
zoneMembershipMinOverlap: require_dist.number().min(0).max(1).default(0),
|
|
12720
13947
|
/** IoU required to match a (predicted) track to a detection. */
|
|
12721
13948
|
iouThreshold: require_dist.number().min(0).max(1).default(.3),
|
|
12722
13949
|
/** Wall-clock coasting budget (ms) before a missed track is dropped. Time-
|
|
@@ -12853,6 +14080,7 @@ function resolveTrackingSettings(raw) {
|
|
|
12853
14080
|
const maxMissedMs = raw.maxMissedMs !== void 0 ? s.maxMissedMs.catch(TRACKING_DEFAULTS.maxMissedMs).parse(raw.maxMissedMs) : raw.maxMissedFrames !== void 0 ? Math.round(maxMissedFrames * 133) : TRACKING_DEFAULTS.maxMissedMs;
|
|
12854
14081
|
const occlusionMaxMissedMs = raw.occlusionMaxMissedMs !== void 0 ? s.occlusionMaxMissedMs.catch(TRACKING_DEFAULTS.occlusionMaxMissedMs).parse(raw.occlusionMaxMissedMs) : raw.occlusionMaxMissedFrames !== void 0 ? Math.round(occlusionMaxMissedFrames * 133) : TRACKING_DEFAULTS.occlusionMaxMissedMs;
|
|
12855
14082
|
return {
|
|
14083
|
+
zoneMembershipMinOverlap: s.zoneMembershipMinOverlap.catch(TRACKING_DEFAULTS.zoneMembershipMinOverlap).parse(raw.zoneMembershipMinOverlap),
|
|
12856
14084
|
iouThreshold: s.iouThreshold.catch(TRACKING_DEFAULTS.iouThreshold).parse(raw.iouThreshold),
|
|
12857
14085
|
maxMissedMs,
|
|
12858
14086
|
minTrackAgeMs: s.minTrackAgeMs.catch(TRACKING_DEFAULTS.minTrackAgeMs).parse(raw.minTrackAgeMs),
|
|
@@ -12907,7 +14135,11 @@ function resolveTrackingSettings(raw) {
|
|
|
12907
14135
|
* low-res detection frame (a static "person" phantom, a parked-truck ghost, a
|
|
12908
14136
|
* misclassified static object).
|
|
12909
14137
|
*
|
|
12910
|
-
*
|
|
14138
|
+
* ON by default (`enabled` defaults to TRUE). An earlier version of this line
|
|
14139
|
+
* said "Ships DORMANT: `enabled` defaults to `false`" — that was stale, and on
|
|
14140
|
+
* 2026-07-30 it nearly produced the conclusion that the gate was not running at
|
|
14141
|
+
* all. It is: it suppressed several phantom births on device 615 that same day.
|
|
14142
|
+
* Read the schema, not this paragraph. Historically the intent was byte-identical
|
|
12911
14143
|
* to today until an operator opts in per camera. The gate is fail-OPEN — any
|
|
12912
14144
|
* missing frame handle, unavailable inference cap, crop-fetch miss, re-detection
|
|
12913
14145
|
* error, or timeout ALLOWS the birth (a real track is never suppressed because
|
|
@@ -13003,10 +14235,11 @@ function isConfirmationCompatible(trackClassName, detectionMacroClass) {
|
|
|
13003
14235
|
if (track === "other" || det === "other") return true;
|
|
13004
14236
|
return track === det;
|
|
13005
14237
|
}
|
|
13006
|
-
var failOpen = (
|
|
13007
|
-
trackId,
|
|
14238
|
+
var failOpen = (candidate, reason) => ({
|
|
14239
|
+
trackId: candidate.trackId,
|
|
13008
14240
|
confirmed: true,
|
|
13009
|
-
reason
|
|
14241
|
+
reason,
|
|
14242
|
+
className: candidate.className
|
|
13010
14243
|
});
|
|
13011
14244
|
function withTimeout(promise, timeoutMs) {
|
|
13012
14245
|
return new Promise((resolve, reject) => {
|
|
@@ -13022,23 +14255,34 @@ function withTimeout(promise, timeoutMs) {
|
|
|
13022
14255
|
}
|
|
13023
14256
|
async function runConfirmation(candidate, config, deps) {
|
|
13024
14257
|
const crop = await deps.fetchCrop(candidate);
|
|
13025
|
-
if (!crop) return failOpen(candidate
|
|
14258
|
+
if (!crop) return failOpen(candidate, "no-crop");
|
|
13026
14259
|
const detections = await deps.redetect(crop);
|
|
13027
|
-
if (detections === null) return failOpen(candidate
|
|
13028
|
-
|
|
14260
|
+
if (detections === null) return failOpen(candidate, "redetect-error");
|
|
14261
|
+
let best;
|
|
14262
|
+
let bestIncompatible;
|
|
14263
|
+
for (const d of detections) if (isConfirmationCompatible(candidate.className, d.macroClass)) {
|
|
14264
|
+
if (!best || d.score > best.score) best = d;
|
|
14265
|
+
} else if (!bestIncompatible || d.score > bestIncompatible.score) bestIncompatible = d;
|
|
14266
|
+
const confirmed = best !== void 0 && best.score >= config.minConfidence;
|
|
13029
14267
|
return {
|
|
13030
14268
|
trackId: candidate.trackId,
|
|
13031
14269
|
confirmed,
|
|
13032
|
-
reason: confirmed ? "confirmed" : "suppressed"
|
|
14270
|
+
reason: confirmed ? "confirmed" : "suppressed",
|
|
14271
|
+
className: candidate.className,
|
|
14272
|
+
...best ? { bestScore: best.score } : {},
|
|
14273
|
+
...bestIncompatible ? {
|
|
14274
|
+
bestIncompatibleClass: bestIncompatible.macroClass,
|
|
14275
|
+
bestIncompatibleScore: bestIncompatible.score
|
|
14276
|
+
} : {}
|
|
13033
14277
|
};
|
|
13034
14278
|
}
|
|
13035
14279
|
async function confirmOne(candidate, config, deps) {
|
|
13036
14280
|
const cropPx = Math.max(candidate.bbox.w, candidate.bbox.h);
|
|
13037
|
-
if (config.minCropPx > 0 && cropPx < config.minCropPx) return failOpen(candidate
|
|
14281
|
+
if (config.minCropPx > 0 && cropPx < config.minCropPx) return failOpen(candidate, "below-min-crop");
|
|
13038
14282
|
try {
|
|
13039
14283
|
return await withTimeout(runConfirmation(candidate, config, deps), config.timeoutMs);
|
|
13040
14284
|
} catch {
|
|
13041
|
-
return failOpen(candidate
|
|
14285
|
+
return failOpen(candidate, "timeout");
|
|
13042
14286
|
}
|
|
13043
14287
|
}
|
|
13044
14288
|
/**
|
|
@@ -13221,8 +14465,23 @@ function resolveMediaSettings(raw) {
|
|
|
13221
14465
|
* §3.3 draft said 45s).
|
|
13222
14466
|
*/
|
|
13223
14467
|
var PackageDropSettingsSchema = require_dist.object({
|
|
13224
|
-
/**
|
|
13225
|
-
|
|
14468
|
+
/**
|
|
14469
|
+
* Explicit per-camera OFF. **On by default: the ZONE RULE is what enables
|
|
14470
|
+
* package detection**, not a second switch.
|
|
14471
|
+
*
|
|
14472
|
+
* As an opt-in default-false this was a parallel source of truth. Device 615
|
|
14473
|
+
* on 2026-07-30 had the zone (`Uscio`), an enabled `package`-stage zone rule
|
|
14474
|
+
* (`Pacchetti`, classFilter `['package']`), and a notification rule on
|
|
14475
|
+
* `delivery: 'package-event'` — three layers of operator intent, all defeated
|
|
14476
|
+
* silently by a boolean none of them mentions.
|
|
14477
|
+
*
|
|
14478
|
+
* Defaulting to true costs nothing on cameras nobody configured:
|
|
14479
|
+
* `PackageDropDetector.onAppeared` still returns early when the device has no
|
|
14480
|
+
* enabled `package`-stage rule, so the work is a class check plus one cached
|
|
14481
|
+
* lookup. Set this false to force the feature off on a camera that HAS a zone
|
|
14482
|
+
* rule.
|
|
14483
|
+
*/
|
|
14484
|
+
packageDropEnabled: require_dist.boolean().default(true),
|
|
13226
14485
|
/**
|
|
13227
14486
|
* Minimum OBSERVED dwell (seconds, since first-seen) before a newly
|
|
13228
14487
|
* promoted stationary package counts as a delivery. Kills a bag briefly
|
|
@@ -13586,37 +14845,7 @@ var AnalyticsQueryFacade = class {
|
|
|
13586
14845
|
}));
|
|
13587
14846
|
}
|
|
13588
14847
|
};
|
|
13589
|
-
|
|
13590
|
-
//#region src/pipeline-analytics/track-retention-sweep.ts
|
|
13591
|
-
/**
|
|
13592
|
-
* Periodic track retention sweep (design §6) — the piece that makes retention
|
|
13593
|
-
* actually BOUNDED. Without it, `pruneTracksBefore` only runs when something
|
|
13594
|
-
* calls it; this sweep ages persisted tracks out on the provider's existing
|
|
13595
|
-
* retention interval.
|
|
13596
|
-
*
|
|
13597
|
-
* `retentionMs` is a PER-DEVICE setting (`trackRetentionDays`, default 7 days;
|
|
13598
|
-
* `0` = keep forever → the device is skipped). Enrolled gallery is exempt by the
|
|
13599
|
-
* cascade store layer (design §4), not here.
|
|
13600
|
-
*
|
|
13601
|
-
* Kept as a pure, dependency-injected function so the loop is unit-testable
|
|
13602
|
-
* without booting the addon.
|
|
13603
|
-
*/
|
|
13604
|
-
var DAY_MS = 1440 * 60 * 1e3;
|
|
13605
|
-
/** Per-device track retention setting. Default 7 days; 0 = keep forever. */
|
|
13606
|
-
var TrackRetentionSettingsSchema = require_dist.object({ trackRetentionDays: require_dist.number().min(0).default(7) });
|
|
13607
|
-
var TRACK_RETENTION_DEFAULT_DAYS = TrackRetentionSettingsSchema.parse({}).trackRetentionDays;
|
|
13608
|
-
/** Resolve `trackRetentionDays` from a raw per-device store blob — an invalid or
|
|
13609
|
-
* missing value falls back to the default (parse never throws). */
|
|
13610
|
-
function resolveTrackRetentionDays(raw) {
|
|
13611
|
-
const parsed = TrackRetentionSettingsSchema.shape.trackRetentionDays.safeParse(raw["trackRetentionDays"]);
|
|
13612
|
-
return parsed.success ? parsed.data : TRACK_RETENTION_DEFAULT_DAYS;
|
|
13613
|
-
}
|
|
13614
|
-
/** Cutoff timestamp for a retention window. `null` when retention is disabled
|
|
13615
|
-
* (`retentionDays <= 0` → keep forever, the sweep skips the device). */
|
|
13616
|
-
function trackRetentionCutoff(nowMs, retentionDays) {
|
|
13617
|
-
if (retentionDays <= 0) return null;
|
|
13618
|
-
return nowMs - retentionDays * DAY_MS;
|
|
13619
|
-
}
|
|
14848
|
+
var TRACK_RETENTION_DEFAULT_DAYS = require_dist.object({ trackRetentionDays: require_dist.number().min(0).default(7) }).parse({}).trackRetentionDays;
|
|
13620
14849
|
/**
|
|
13621
14850
|
* Sweep every device with persisted tracks: skip retention-disabled devices,
|
|
13622
14851
|
* prune the rest at `now − retentionMs`. Per-device ISOLATED — one bad device
|
|
@@ -13627,7 +14856,7 @@ async function sweepTrackRetention(deps) {
|
|
|
13627
14856
|
const nowMs = deps.now();
|
|
13628
14857
|
let totalTracks = 0;
|
|
13629
14858
|
for (const deviceId of devices) try {
|
|
13630
|
-
const cutoffMs =
|
|
14859
|
+
const cutoffMs = await deps.resolveCutoffMs(deviceId, nowMs);
|
|
13631
14860
|
if (cutoffMs === null) continue;
|
|
13632
14861
|
const counts = await deps.pruneTracksBefore(deviceId, cutoffMs);
|
|
13633
14862
|
totalTracks += counts.tracks;
|
|
@@ -14192,6 +15421,19 @@ function retagDetectionSections(sections) {
|
|
|
14192
15421
|
} : s);
|
|
14193
15422
|
}
|
|
14194
15423
|
/**
|
|
15424
|
+
* Re-home the analytics `retention` section onto the recorder's `recording`
|
|
15425
|
+
* top-tab (operator ask, 2026-07-29): footage and analytics retention are ONE
|
|
15426
|
+
* unified policy since the follow-recordings default, so their controls
|
|
15427
|
+
* belong on ONE tab. Same pure-retag mechanism as the detection sections —
|
|
15428
|
+
* `DeviceDetail` folds matching-tab sections together, no admin-ui change.
|
|
15429
|
+
*/
|
|
15430
|
+
function retagRetentionSection(sections) {
|
|
15431
|
+
return sections.map((s) => s.id === "retention" ? {
|
|
15432
|
+
...s,
|
|
15433
|
+
tab: "recording"
|
|
15434
|
+
} : s);
|
|
15435
|
+
}
|
|
15436
|
+
/**
|
|
14195
15437
|
* Fields that live ONLY on the global settings page and must never surface in a
|
|
14196
15438
|
* per-device contribution. The face-recognition `enabled` switch is the GLOBAL
|
|
14197
15439
|
* master kill for the whole subsystem — per-camera face production is governed
|
|
@@ -14370,9 +15612,23 @@ function buildGlobalSettingsSchema() {
|
|
|
14370
15612
|
{
|
|
14371
15613
|
id: "retention",
|
|
14372
15614
|
title: "Retention",
|
|
14373
|
-
description: "How long analytics history is kept
|
|
15615
|
+
description: "How long analytics history (tracks, events, media) is kept. Default: as long as the camera's recordings — one policy for footage and events. The day windows below apply in Custom mode, and as the bound when the camera has no footage.",
|
|
14374
15616
|
columns: 3,
|
|
14375
15617
|
fields: [
|
|
15618
|
+
{
|
|
15619
|
+
type: "select",
|
|
15620
|
+
key: "retentionMode",
|
|
15621
|
+
label: "Mode",
|
|
15622
|
+
description: "Follow recordings: tracks/events live exactly as long as retained footage (age retention AND disk eviction; never prunes the last 7 days). Custom: the day windows below.",
|
|
15623
|
+
default: "follow-recordings",
|
|
15624
|
+
options: [{
|
|
15625
|
+
value: "follow-recordings",
|
|
15626
|
+
label: "Follow recordings (default)"
|
|
15627
|
+
}, {
|
|
15628
|
+
value: "custom",
|
|
15629
|
+
label: "Custom windows"
|
|
15630
|
+
}]
|
|
15631
|
+
},
|
|
14376
15632
|
{
|
|
14377
15633
|
type: "number",
|
|
14378
15634
|
key: "trackRetentionDays",
|
|
@@ -14907,6 +16163,86 @@ async function encodeKeyFrameVariants(native) {
|
|
|
14907
16163
|
};
|
|
14908
16164
|
}
|
|
14909
16165
|
//#endregion
|
|
16166
|
+
//#region src/pipeline-analytics/retention-policy.ts
|
|
16167
|
+
/**
|
|
16168
|
+
* Unified analytics retention policy (storage entity-routing spec, Phase 1).
|
|
16169
|
+
*
|
|
16170
|
+
* Two modes, per device:
|
|
16171
|
+
*
|
|
16172
|
+
* - `follow-recordings` (the DEFAULT): tracks and events live exactly as long
|
|
16173
|
+
* as the camera's retained footage — ONE cutoff, the instant of the oldest
|
|
16174
|
+
* segment still on disk. That follows the recorder's age retention AND its
|
|
16175
|
+
* disk-pressure eviction automatically, with no second policy to keep in
|
|
16176
|
+
* sync. A camera with no footage at all (recording off / brand new) falls
|
|
16177
|
+
* back to the custom day windows below, so analytics stay bounded either
|
|
16178
|
+
* way.
|
|
16179
|
+
*
|
|
16180
|
+
* - `custom`: the per-kind day windows (tracks / motion / object / audio),
|
|
16181
|
+
* exactly the pre-unification behaviour — except the knobs are now actually
|
|
16182
|
+
* READ (they were exposed in settings and silently ignored by the sweep,
|
|
16183
|
+
* which used hardcoded 14/30/7).
|
|
16184
|
+
*
|
|
16185
|
+
* Follow-mode floor: events younger than {@link FOLLOW_FLOOR_DAYS} are never
|
|
16186
|
+
* pruned, even when footage is younger (a camera that STARTED recording
|
|
16187
|
+
* yesterday must not nuke its whole event history back to the first segment).
|
|
16188
|
+
*/
|
|
16189
|
+
var DAY_MS = 1440 * 60 * 1e3;
|
|
16190
|
+
/** Per-device analytics retention settings as stored in the device blob.
|
|
16191
|
+
* `retentionMode` absent = follow-recordings (the new default). The day
|
|
16192
|
+
* windows keep their historical defaults and double as the no-footage
|
|
16193
|
+
* fallback in follow mode. */
|
|
16194
|
+
var RetentionSettingsSchema = require_dist.object({
|
|
16195
|
+
retentionMode: require_dist._enum(["follow-recordings", "custom"]).default("follow-recordings"),
|
|
16196
|
+
trackRetentionDays: require_dist.number().min(0).default(7),
|
|
16197
|
+
retentionMotionDays: require_dist.number().min(1).default(14),
|
|
16198
|
+
retentionObjectDays: require_dist.number().min(1).default(30),
|
|
16199
|
+
retentionAudioDays: require_dist.number().min(1).default(7)
|
|
16200
|
+
});
|
|
16201
|
+
/** Resolve the settings from a raw device-store blob — invalid or missing
|
|
16202
|
+
* fields fall back to defaults, never throw. */
|
|
16203
|
+
function resolveRetentionSettings(raw) {
|
|
16204
|
+
const parsed = RetentionSettingsSchema.safeParse(raw);
|
|
16205
|
+
if (parsed.success) return parsed.data;
|
|
16206
|
+
const shape = RetentionSettingsSchema.shape;
|
|
16207
|
+
const field = (k) => shape[k].safeParse(raw[k]).success ? shape[k].parse(raw[k]) : RetentionSettingsSchema.parse({})[k];
|
|
16208
|
+
return {
|
|
16209
|
+
retentionMode: field("retentionMode"),
|
|
16210
|
+
trackRetentionDays: field("trackRetentionDays"),
|
|
16211
|
+
retentionMotionDays: field("retentionMotionDays"),
|
|
16212
|
+
retentionObjectDays: field("retentionObjectDays"),
|
|
16213
|
+
retentionAudioDays: field("retentionAudioDays")
|
|
16214
|
+
};
|
|
16215
|
+
}
|
|
16216
|
+
/**
|
|
16217
|
+
* Follow-mode cutoff: the oldest retained footage instant, floored so
|
|
16218
|
+
* anything younger than {@link FOLLOW_FLOOR_DAYS} survives. `null` footage
|
|
16219
|
+
* (none on disk) → `null`, the caller falls back to custom windows.
|
|
16220
|
+
*/
|
|
16221
|
+
function followCutoffMs(nowMs, earliestFootageMs) {
|
|
16222
|
+
if (earliestFootageMs === null) return null;
|
|
16223
|
+
return Math.min(earliestFootageMs, nowMs - 7 * DAY_MS);
|
|
16224
|
+
}
|
|
16225
|
+
/** Compute the effective cutoffs for one device at `nowMs`. */
|
|
16226
|
+
function resolveRetentionCutoffs(settings, nowMs, earliestFootageMs) {
|
|
16227
|
+
if (settings.retentionMode === "follow-recordings") {
|
|
16228
|
+
const cutoff = followCutoffMs(nowMs, earliestFootageMs);
|
|
16229
|
+
if (cutoff !== null) return {
|
|
16230
|
+
trackCutoffMs: cutoff,
|
|
16231
|
+
motionCutoffMs: cutoff,
|
|
16232
|
+
objectCutoffMs: cutoff,
|
|
16233
|
+
audioCutoffMs: cutoff,
|
|
16234
|
+
effectiveMode: "follow-recordings"
|
|
16235
|
+
};
|
|
16236
|
+
}
|
|
16237
|
+
return {
|
|
16238
|
+
trackCutoffMs: settings.trackRetentionDays <= 0 ? null : nowMs - settings.trackRetentionDays * DAY_MS,
|
|
16239
|
+
motionCutoffMs: nowMs - settings.retentionMotionDays * DAY_MS,
|
|
16240
|
+
objectCutoffMs: nowMs - settings.retentionObjectDays * DAY_MS,
|
|
16241
|
+
audioCutoffMs: nowMs - settings.retentionAudioDays * DAY_MS,
|
|
16242
|
+
effectiveMode: "custom"
|
|
16243
|
+
};
|
|
16244
|
+
}
|
|
16245
|
+
//#endregion
|
|
14910
16246
|
//#region src/pipeline-analytics/store/identity-store.ts
|
|
14911
16247
|
/**
|
|
14912
16248
|
* IdentityStore — per-person identity registry for face recognition.
|
|
@@ -18293,6 +19629,17 @@ var KEY_EVENT_DEFAULT_LIMIT = 50;
|
|
|
18293
19629
|
* Absent / empty / non-string all fall back to the hub default — the exact
|
|
18294
19630
|
* narrowing the old raw read applied inline. */
|
|
18295
19631
|
var PostProcessingNodeIdSchema = require_dist.string().min(1);
|
|
19632
|
+
/**
|
|
19633
|
+
* Footage-attachment window + geometry, used when the rule states none. The
|
|
19634
|
+
* window is CENTRED on the event, so the recipient sees the approach and what
|
|
19635
|
+
* followed rather than one side of it.
|
|
19636
|
+
*/
|
|
19637
|
+
var NC_FOOTAGE_PRE_ROLL_SEC = 3;
|
|
19638
|
+
var NC_FOOTAGE_POST_ROLL_SEC = 5;
|
|
19639
|
+
var NC_FOOTAGE_MAX_WIDTH = 480;
|
|
19640
|
+
var NC_FOOTAGE_FPS = 5;
|
|
19641
|
+
/** Per-install HMAC secret behind the signed artifact links (minted once). */
|
|
19642
|
+
var NcArtifactSecretSchema = require_dist.string();
|
|
18296
19643
|
var EmbeddingEnabledSchema = require_dist.boolean();
|
|
18297
19644
|
var SILENCE_FLOOR_DBFS = -55;
|
|
18298
19645
|
var RETENTION_SWEEP_INTERVAL_MS = 5 * 6e4;
|
|
@@ -18353,7 +19700,21 @@ function decodeEmbeddingBase64(base64) {
|
|
|
18353
19700
|
const view = new Float32Array(bytes.buffer, bytes.byteOffset, Math.floor(bytes.byteLength / 4));
|
|
18354
19701
|
return Array.from(view);
|
|
18355
19702
|
}
|
|
19703
|
+
/** A track shorter than this is a candidate phantom, not a subject that came
|
|
19704
|
+
* and went. Brackets the observed plant tracks (3.8-19.0 s). */
|
|
19705
|
+
var SHORT_TRACK_MAX_MS = 25e3;
|
|
19706
|
+
/** Total displacement under this is "did not move at all" (observed: 0-2.5 px). */
|
|
19707
|
+
var MOTIONLESS_MAX_PX = 8;
|
|
19708
|
+
/** Grid the spawn point is quantised to, so respawns whose boxes never repeat
|
|
19709
|
+
* to the pixel still land in one cell. */
|
|
19710
|
+
var PHANTOM_CELL_PX = 32;
|
|
19711
|
+
/** How long a cell remembers its closes. */
|
|
19712
|
+
var PHANTOM_CELL_WINDOW_MS = 360 * 6e4;
|
|
18356
19713
|
var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
19714
|
+
/** Recent SHORT+MOTIONLESS track closes per `<device>:<class>:<cell>` —
|
|
19715
|
+
* see {@link noteShortMotionlessTrack}. Measurement only; each entry is
|
|
19716
|
+
* filtered against the 6-hour window on write, so it stays bounded. */
|
|
19717
|
+
shortMotionlessCells = /* @__PURE__ */ new Map();
|
|
18357
19718
|
processors = /* @__PURE__ */ new Map();
|
|
18358
19719
|
trackStore = null;
|
|
18359
19720
|
/** Parked-object registry: promotes a track that stopped moving into a
|
|
@@ -18364,6 +19725,8 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
18364
19725
|
eventStore = null;
|
|
18365
19726
|
/** Events-domain ops-log (footprint/prune/manual-delete audit). Null until onInitialize. */
|
|
18366
19727
|
eventsOpsLog = null;
|
|
19728
|
+
/** Event-media relocation engine (entity-routing Phase 4). */
|
|
19729
|
+
mediaRelocate = null;
|
|
18367
19730
|
/** Per-camera history of LINKED-device sensor state changes (Part B). */
|
|
18368
19731
|
sensorEventStore = null;
|
|
18369
19732
|
/** Ingest-side reverse index (sensor device → linked camera ids), TTL-cached
|
|
@@ -18422,6 +19785,88 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
18422
19785
|
retentionSweepTimer = null;
|
|
18423
19786
|
/** Handle for the event-media data-plane listener (dispose on shutdown). */
|
|
18424
19787
|
eventMediaDataPlane = null;
|
|
19788
|
+
/** The NC artifact plane (signed public links for notification media) and its
|
|
19789
|
+
* data-plane handle. Null until served / when the facility is absent. */
|
|
19790
|
+
ncArtifactPlane = null;
|
|
19791
|
+
ncArtifactDataPlane = null;
|
|
19792
|
+
/** The operator's marked notification endpoint, or null (AUTO / unavailable). */
|
|
19793
|
+
async markedNotificationEndpoint() {
|
|
19794
|
+
try {
|
|
19795
|
+
return (await this.ctx.api.localNetwork.getNotificationEndpoint.query()).baseUrl ?? void 0;
|
|
19796
|
+
} catch {
|
|
19797
|
+
return;
|
|
19798
|
+
}
|
|
19799
|
+
}
|
|
19800
|
+
/**
|
|
19801
|
+
* Serve the NC artifact plane: a PUBLIC route whose authority is the HMAC on
|
|
19802
|
+
* each link (see `notification-center/artifact-url.ts`). It exists because
|
|
19803
|
+
* attachments used to carry BYTES only, and the degrade engine drops a
|
|
19804
|
+
* bytes-only attachment for a url-mode backend — WhatsApp and gotify were
|
|
19805
|
+
* receiving no media at all.
|
|
19806
|
+
*
|
|
19807
|
+
* Best-effort at every step: no facility, no secret or no reachable base URL
|
|
19808
|
+
* ⇒ no plane, and the dispatcher keeps shipping bytes exactly as before.
|
|
19809
|
+
*/
|
|
19810
|
+
async serveNcArtifactPlane() {
|
|
19811
|
+
try {
|
|
19812
|
+
const secretState = this.state("ncArtifactSecret", NcArtifactSecretSchema, "");
|
|
19813
|
+
let secret = await secretState.get();
|
|
19814
|
+
if (secret === "") {
|
|
19815
|
+
secret = (0, node_crypto.randomUUID)().replace(/-/g, "");
|
|
19816
|
+
await secretState.set(secret);
|
|
19817
|
+
}
|
|
19818
|
+
const store = new NcArtifactStore({
|
|
19819
|
+
dir: node_path.default.join(this.ctx.dataDir, "nc-artifacts"),
|
|
19820
|
+
logger: this.ctx.logger.child("nc-artifacts")
|
|
19821
|
+
});
|
|
19822
|
+
await store.start();
|
|
19823
|
+
const plane = new NcArtifactPlane({
|
|
19824
|
+
store,
|
|
19825
|
+
secret,
|
|
19826
|
+
logger: this.ctx.logger.child("nc-artifacts"),
|
|
19827
|
+
routePrefix: `/addon/${this.ctx.id}/nc-artifact`,
|
|
19828
|
+
listEndpoints: async () => collectArtifactEndpoints({
|
|
19829
|
+
markedBaseUrl: await this.markedNotificationEndpoint(),
|
|
19830
|
+
configuredPublicUrl: process.env["CAMSTACK_HUB_PUBLIC_URL"],
|
|
19831
|
+
getConnected: async () => {
|
|
19832
|
+
const status = await this.ctx.api.networkAccess.getStatus.query();
|
|
19833
|
+
this.ctx.logger.debug("artifact base-url: connected ingress", { meta: {
|
|
19834
|
+
connected: status.connected,
|
|
19835
|
+
url: status.endpoint?.url ?? null,
|
|
19836
|
+
protocol: status.endpoint?.protocol ?? null
|
|
19837
|
+
} });
|
|
19838
|
+
return status.connected && status.endpoint !== null ? {
|
|
19839
|
+
url: status.endpoint.url,
|
|
19840
|
+
protocol: status.endpoint.protocol
|
|
19841
|
+
} : null;
|
|
19842
|
+
},
|
|
19843
|
+
listExternal: async () => {
|
|
19844
|
+
return (await this.ctx.api.networkAccess.listEndpoints.query()).map((e) => ({
|
|
19845
|
+
url: e.url,
|
|
19846
|
+
protocol: e.protocol
|
|
19847
|
+
}));
|
|
19848
|
+
},
|
|
19849
|
+
listLan: async (port) => {
|
|
19850
|
+
return (await this.ctx.api.localNetwork.getConnectionEndpoints.query({ port })).endpoints.map((e) => ({
|
|
19851
|
+
baseUrl: e.baseUrl,
|
|
19852
|
+
kind: e.kind,
|
|
19853
|
+
priority: e.priority
|
|
19854
|
+
}));
|
|
19855
|
+
},
|
|
19856
|
+
logger: this.ctx.logger
|
|
19857
|
+
})
|
|
19858
|
+
});
|
|
19859
|
+
this.ncArtifactDataPlane = await this.ctx.dataPlane?.serve({
|
|
19860
|
+
prefix: "nc-artifact",
|
|
19861
|
+
access: "public",
|
|
19862
|
+
handler: plane.handler
|
|
19863
|
+
}) ?? null;
|
|
19864
|
+
this.ncArtifactPlane = this.ncArtifactDataPlane !== null ? plane : null;
|
|
19865
|
+
this.ctx.logger.info("nc-artifact data-plane served", { meta: { served: this.ncArtifactPlane !== null } });
|
|
19866
|
+
} catch (err) {
|
|
19867
|
+
this.ctx.logger.warn("nc-artifact data-plane failed to serve", { meta: { error: require_dist.errMsg(err) } });
|
|
19868
|
+
}
|
|
19869
|
+
}
|
|
18425
19870
|
/** Public base URL for event thumbnails: `/addon/<addonId>/event-media`.
|
|
18426
19871
|
* Set once the data-plane is registered; null until then (e.g. no
|
|
18427
19872
|
* dataPlane facility in the current environment). */
|
|
@@ -18579,7 +20024,10 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
18579
20024
|
});
|
|
18580
20025
|
},
|
|
18581
20026
|
emitTrackLifecycle: (payload, timestampMs) => this.emitTrackLifecycle(payload, timestampMs),
|
|
18582
|
-
onTrackClosed: (track, ownedMedia, info) =>
|
|
20027
|
+
onTrackClosed: (track, ownedMedia, info) => {
|
|
20028
|
+
this.noteShortMotionlessTrack(track);
|
|
20029
|
+
return this.notificationCenter?.onTrackClosed(track, ownedMedia, info);
|
|
20030
|
+
},
|
|
18583
20031
|
deriveThumbnailFromKeyFrame: async (input) => {
|
|
18584
20032
|
const derived = await deriveKeyFrameThumbnailJpeg({
|
|
18585
20033
|
...input,
|
|
@@ -18725,12 +20173,19 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
18725
20173
|
let storage = this.ctx.kernel.storage;
|
|
18726
20174
|
const mediaRoot = process.env.CAMSTACK_MEDIA_ROOT?.trim();
|
|
18727
20175
|
if (mediaRoot) {
|
|
18728
|
-
const { FilesystemStorageProvider } = await Promise.resolve().then(() => require("../node-
|
|
18729
|
-
storage = new FilesystemStorageProvider(mediaRoot);
|
|
20176
|
+
const { FilesystemStorageProvider } = await Promise.resolve().then(() => require("../node-Dwh-F2Zf.js"));
|
|
20177
|
+
storage = new FilesystemStorageProvider(mediaRoot, { eventMedia: mediaRoot });
|
|
18730
20178
|
logger.info("pipeline-analytics: event media rooted at CAMSTACK_MEDIA_ROOT", { meta: { mediaRoot } });
|
|
18731
20179
|
}
|
|
18732
20180
|
if (!storage) throw new Error("pipeline-analytics requires ctx.kernel.storage");
|
|
18733
|
-
return
|
|
20181
|
+
return createLocationAwareMediaStorage({
|
|
20182
|
+
base: storage,
|
|
20183
|
+
defaultLocation: "eventMedia",
|
|
20184
|
+
resolveRoot: (locationId) => this.ctx.api.storage.resolve.query({
|
|
20185
|
+
location: locationId,
|
|
20186
|
+
relativePath: ""
|
|
20187
|
+
})
|
|
20188
|
+
});
|
|
18734
20189
|
}
|
|
18735
20190
|
/** Constructs every SQLite-backed store plus the stationary/package-drop/
|
|
18736
20191
|
* sensor plumbing, in the exact pre-S7 order. Returns the non-null bundle
|
|
@@ -18778,6 +20233,27 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
18778
20233
|
logger: logger.child("MediaStore")
|
|
18779
20234
|
});
|
|
18780
20235
|
this.mediaStore = mediaStore;
|
|
20236
|
+
this.mediaRelocate = new MediaRelocateEngine({
|
|
20237
|
+
store: api.settingsStore,
|
|
20238
|
+
storage,
|
|
20239
|
+
logger: logger.child("MediaRelocate"),
|
|
20240
|
+
now: () => Date.now(),
|
|
20241
|
+
newId: () => (0, node_crypto.randomUUID)(),
|
|
20242
|
+
resolveTargetRoot: (locationId) => api.storage.resolve.query({
|
|
20243
|
+
location: locationId,
|
|
20244
|
+
relativePath: ""
|
|
20245
|
+
}),
|
|
20246
|
+
onFinished: (job) => {
|
|
20247
|
+
this.eventsOpsLog?.append({
|
|
20248
|
+
op: "relocate",
|
|
20249
|
+
reason: "operator",
|
|
20250
|
+
deviceId: job.deviceId,
|
|
20251
|
+
itemsAffected: job.filesMoved,
|
|
20252
|
+
bytesReclaimed: 0,
|
|
20253
|
+
detail: `${job.state}: media → ${job.toLocationId} (${job.bytesMoved} bytes${job.error ? `; ${job.error}` : ""})`
|
|
20254
|
+
});
|
|
20255
|
+
}
|
|
20256
|
+
});
|
|
18781
20257
|
const eventStore = new EventStore({
|
|
18782
20258
|
store: api.settingsStore,
|
|
18783
20259
|
logger: logger.child("EventStore"),
|
|
@@ -19023,16 +20499,66 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
19023
20499
|
this.notificationCenter = new NotificationCenter({
|
|
19024
20500
|
store: api.settingsStore,
|
|
19025
20501
|
logger: logger.child("NotificationCenter"),
|
|
20502
|
+
listUsers: async () => {
|
|
20503
|
+
return (await api.userManagement.listUsers.query()).map((u) => ({
|
|
20504
|
+
id: u.id,
|
|
20505
|
+
isAdmin: u.isAdmin,
|
|
20506
|
+
allowedDevices: u.allowedDevices
|
|
20507
|
+
}));
|
|
20508
|
+
},
|
|
20509
|
+
getDeviceRoute: async (deviceId) => {
|
|
20510
|
+
const device = await api.deviceManager.getDevice.query({ deviceId });
|
|
20511
|
+
return device === null ? null : {
|
|
20512
|
+
stableId: device.stableId,
|
|
20513
|
+
addonId: device.addonId
|
|
20514
|
+
};
|
|
20515
|
+
},
|
|
19026
20516
|
dispatcher: {
|
|
19027
|
-
|
|
19028
|
-
return (await api.
|
|
19029
|
-
id:
|
|
19030
|
-
|
|
19031
|
-
name: t.name,
|
|
19032
|
-
kind: t.kind,
|
|
19033
|
-
enabled: t.enabled
|
|
20517
|
+
getZoneNames: async (deviceId) => {
|
|
20518
|
+
return (await api.zones.listZones.query({ deviceId })).map((z) => ({
|
|
20519
|
+
id: z.id,
|
|
20520
|
+
name: z.name
|
|
19034
20521
|
}));
|
|
19035
20522
|
},
|
|
20523
|
+
getZonePolygons: async (deviceId, zoneIds) => {
|
|
20524
|
+
const zones = await api.zones.listZones.query({ deviceId });
|
|
20525
|
+
const wanted = new Set(zoneIds);
|
|
20526
|
+
return zones.filter((z) => wanted.has(z.id) && Array.isArray(z.polygon)).map((z) => (z.polygon ?? []).map((pt) => ({
|
|
20527
|
+
x: pt.x,
|
|
20528
|
+
y: pt.y
|
|
20529
|
+
})));
|
|
20530
|
+
},
|
|
20531
|
+
renderFootage: async (req) => {
|
|
20532
|
+
const res = await api.streamBroker.renderPreBufferClip.mutate({
|
|
20533
|
+
deviceId: req.deviceId,
|
|
20534
|
+
aroundMs: req.aroundMs,
|
|
20535
|
+
format: req.format,
|
|
20536
|
+
preRollSec: req.preRollSec ?? NC_FOOTAGE_PRE_ROLL_SEC,
|
|
20537
|
+
postRollSec: req.postRollSec ?? NC_FOOTAGE_POST_ROLL_SEC,
|
|
20538
|
+
maxWidth: NC_FOOTAGE_MAX_WIDTH,
|
|
20539
|
+
fps: NC_FOOTAGE_FPS,
|
|
20540
|
+
...req.profile === "high" || req.profile === "mid" || req.profile === "low" ? { profile: req.profile } : {}
|
|
20541
|
+
});
|
|
20542
|
+
const buf = Buffer.from(res.base64, "base64");
|
|
20543
|
+
if (buf.byteLength === 0) return null;
|
|
20544
|
+
const bytes = new Uint8Array(buf.byteLength);
|
|
20545
|
+
bytes.set(buf);
|
|
20546
|
+
return bytes;
|
|
20547
|
+
},
|
|
20548
|
+
publishArtifact: async (bytes, mime) => await this.ncArtifactPlane?.publish(bytes, mime) ?? null,
|
|
20549
|
+
listTargets: async () => {
|
|
20550
|
+
return (await api.notificationOutput.listTargets.query({})).map((t) => {
|
|
20551
|
+
const owner = t.config["ownerUserId"];
|
|
20552
|
+
return {
|
|
20553
|
+
id: t.id,
|
|
20554
|
+
addonId: t.addonId,
|
|
20555
|
+
name: t.name,
|
|
20556
|
+
kind: t.kind,
|
|
20557
|
+
enabled: t.enabled,
|
|
20558
|
+
...typeof owner === "string" && owner.length > 0 ? { ownerUserId: owner } : {}
|
|
20559
|
+
};
|
|
20560
|
+
});
|
|
20561
|
+
},
|
|
19036
20562
|
send: async (input) => {
|
|
19037
20563
|
const { attachments, ...notification } = input.notification;
|
|
19038
20564
|
return api.notificationOutput.send.mutate({
|
|
@@ -19085,6 +20611,7 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
19085
20611
|
handler
|
|
19086
20612
|
}) ?? null;
|
|
19087
20613
|
this.eventMediaBaseUrl = this.eventMediaDataPlane !== null ? `/addon/${this.ctx.id}/event-media` : null;
|
|
20614
|
+
await this.serveNcArtifactPlane();
|
|
19088
20615
|
this.ctx.logger.info("event-media data-plane served", { meta: { baseUrl: this.eventMediaBaseUrl ?? "(no dataPlane facility)" } });
|
|
19089
20616
|
} catch (err) {
|
|
19090
20617
|
this.ctx.logger.warn("event-media data-plane failed to serve", { meta: { error: require_dist.errMsg(err) } });
|
|
@@ -19370,6 +20897,7 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
19370
20897
|
const liveRules = proxy?.state.zoneRules.value?.detection ?? [];
|
|
19371
20898
|
processor.setZones(liveZones);
|
|
19372
20899
|
processor.setDetectionRules(liveRules);
|
|
20900
|
+
processor.setZoneMembershipMinOverlap(trk.zoneMembershipMinOverlap);
|
|
19373
20901
|
const result = processor.process({
|
|
19374
20902
|
timestamp: frame.timestamp,
|
|
19375
20903
|
frame
|
|
@@ -19600,7 +21128,29 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
19600
21128
|
} });
|
|
19601
21129
|
}
|
|
19602
21130
|
await Promise.all([...result.objectEvents, ...result.appearanceEvents].map((e) => this.eventStore.insertObject(e)));
|
|
19603
|
-
if (this.notificationCenter !== null)
|
|
21131
|
+
if (this.notificationCenter !== null) {
|
|
21132
|
+
const overlaps = processor.getLastZoneOverlaps();
|
|
21133
|
+
for (const e of result.objectEvents) {
|
|
21134
|
+
if (e.zones && e.zones.length > 0) {
|
|
21135
|
+
const m = e.trackId ? overlaps.get(e.trackId) : void 0;
|
|
21136
|
+
this.ctx.logger.info("zone membership stamped on event", {
|
|
21137
|
+
tags: { deviceId },
|
|
21138
|
+
meta: {
|
|
21139
|
+
eventId: e.id,
|
|
21140
|
+
trackId: e.trackId,
|
|
21141
|
+
className: e.className,
|
|
21142
|
+
minOverlap: trk.zoneMembershipMinOverlap,
|
|
21143
|
+
zones: (m ?? []).map((z) => ({
|
|
21144
|
+
id: z.zoneId,
|
|
21145
|
+
name: z.zoneName,
|
|
21146
|
+
overlapPct: Math.round(z.overlap * 1e3) / 10
|
|
21147
|
+
}))
|
|
21148
|
+
}
|
|
21149
|
+
});
|
|
21150
|
+
}
|
|
21151
|
+
this.notificationCenter.onObjectEventPersisted(e);
|
|
21152
|
+
}
|
|
21153
|
+
}
|
|
19604
21154
|
const objectEmbeddingBests = [];
|
|
19605
21155
|
if (this.objectEmbeddingStore) for (const t of result.tracked) {
|
|
19606
21156
|
if (!isClipObjectEmbedding(t)) continue;
|
|
@@ -19952,19 +21502,28 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
19952
21502
|
}),
|
|
19953
21503
|
redetect: (cropJpeg) => this.redetectCropForConfirmation(nodeId, deviceId, cropJpeg),
|
|
19954
21504
|
onDecision: (decision) => {
|
|
21505
|
+
const meta = {
|
|
21506
|
+
trackId: decision.trackId,
|
|
21507
|
+
reason: decision.reason,
|
|
21508
|
+
className: decision.className,
|
|
21509
|
+
...decision.bestScore !== void 0 ? { bestScore: decision.bestScore } : {},
|
|
21510
|
+
...decision.bestIncompatibleClass !== void 0 ? {
|
|
21511
|
+
bestIncompatibleClass: decision.bestIncompatibleClass,
|
|
21512
|
+
bestIncompatibleScore: decision.bestIncompatibleScore
|
|
21513
|
+
} : {},
|
|
21514
|
+
minConfidence: config.minConfidence
|
|
21515
|
+
};
|
|
19955
21516
|
if (!decision.confirmed) this.ctx.logger.info("confirmation gate: birth suppressed (false positive)", {
|
|
19956
21517
|
tags: { deviceId },
|
|
19957
|
-
meta
|
|
19958
|
-
trackId: decision.trackId,
|
|
19959
|
-
reason: decision.reason
|
|
19960
|
-
}
|
|
21518
|
+
meta
|
|
19961
21519
|
});
|
|
19962
21520
|
else if (decision.reason !== "confirmed") this.ctx.logger.debug("confirmation gate: birth allowed (fail-open)", {
|
|
19963
21521
|
tags: { deviceId },
|
|
19964
|
-
meta
|
|
19965
|
-
|
|
19966
|
-
|
|
19967
|
-
}
|
|
21522
|
+
meta
|
|
21523
|
+
});
|
|
21524
|
+
else this.ctx.logger.info("confirmation gate: birth confirmed", {
|
|
21525
|
+
tags: { deviceId },
|
|
21526
|
+
meta
|
|
19968
21527
|
});
|
|
19969
21528
|
}
|
|
19970
21529
|
});
|
|
@@ -19981,6 +21540,48 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
19981
21540
|
async resolveDeviceStationarySettings(deviceId) {
|
|
19982
21541
|
return this.stationarySettingsCache.get(deviceId, (id) => this.readDeviceSettings(id, resolveStationarySettings));
|
|
19983
21542
|
}
|
|
21543
|
+
/**
|
|
21544
|
+
* Count SHORT + MOTIONLESS track closes per frame cell.
|
|
21545
|
+
*
|
|
21546
|
+
* The stationary registry cannot see this class of phantom. Promotion needs a
|
|
21547
|
+
* track that has EXISTED for `PROMOTION_WINDOW_MS` (30 s), and these die long
|
|
21548
|
+
* before: the dead ornamental grass on device 615 produced tracks of 3.8 s,
|
|
21549
|
+
* 4.1 s, 6.5 s and 19.0 s with 0-2.5 px of total displacement, roughly
|
|
21550
|
+
* fifteen of them in a day, all at the same spot. Each one is individually
|
|
21551
|
+
* innocent; the RECURRENCE is the signal, and nothing survives a track death
|
|
21552
|
+
* to notice it.
|
|
21553
|
+
*
|
|
21554
|
+
* Lowering the promotion window is not the fix — those 30 s exist so someone
|
|
21555
|
+
* standing still at a door is not declared scenery.
|
|
21556
|
+
*
|
|
21557
|
+
* This is the measurement half: it establishes how often a cell repeats
|
|
21558
|
+
* before any suppression is built, so "N closes in what window" comes from
|
|
21559
|
+
* data rather than intuition. It suppresses NOTHING.
|
|
21560
|
+
*/
|
|
21561
|
+
noteShortMotionlessTrack(track) {
|
|
21562
|
+
const lifeMs = track.lastSeen - track.firstSeen;
|
|
21563
|
+
const moved = track.totalDistance ?? 0;
|
|
21564
|
+
if (lifeMs > SHORT_TRACK_MAX_MS || moved > MOTIONLESS_MAX_PX) return;
|
|
21565
|
+
const first = track.positions?.[0];
|
|
21566
|
+
if (!first) return;
|
|
21567
|
+
const cell = `${Math.round(first.x / PHANTOM_CELL_PX)},${Math.round(first.y / PHANTOM_CELL_PX)}`;
|
|
21568
|
+
const key = `${track.deviceId}:${track.className}:${cell}`;
|
|
21569
|
+
const now = Date.now();
|
|
21570
|
+
const seen = this.shortMotionlessCells.get(key)?.filter((t) => now - t < PHANTOM_CELL_WINDOW_MS) ?? [];
|
|
21571
|
+
seen.push(now);
|
|
21572
|
+
this.shortMotionlessCells.set(key, seen);
|
|
21573
|
+
this.ctx.logger.info("short motionless track closed", {
|
|
21574
|
+
tags: { deviceId: track.deviceId },
|
|
21575
|
+
meta: {
|
|
21576
|
+
trackId: track.trackId,
|
|
21577
|
+
className: track.className,
|
|
21578
|
+
lifeMs,
|
|
21579
|
+
movedPx: Math.round(moved * 10) / 10,
|
|
21580
|
+
cell,
|
|
21581
|
+
repeatsInWindow: seen.length
|
|
21582
|
+
}
|
|
21583
|
+
});
|
|
21584
|
+
}
|
|
19984
21585
|
stationarySettingsFromCache(deviceId) {
|
|
19985
21586
|
return this.stationarySettingsCache.peek(deviceId) ?? STATIONARY_DEFAULTS;
|
|
19986
21587
|
}
|
|
@@ -19990,9 +21591,14 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
19990
21591
|
/**
|
|
19991
21592
|
* Resolve a device's ENABLED `package`-stage zone rules independent of the
|
|
19992
21593
|
* live frame path (mirrors `resolveDeviceZones`). Warms the proxy and,
|
|
19993
|
-
* when the cached slice is empty, forces one refresh.
|
|
19994
|
-
*
|
|
19995
|
-
*
|
|
21594
|
+
* when the cached slice is empty, forces one refresh.
|
|
21595
|
+
*
|
|
21596
|
+
* The `package` slice is written through the `zone-rules` capability
|
|
21597
|
+
* (`zoneRules.setRules({stage:'package'})`), which is live. An earlier
|
|
21598
|
+
* version of this comment claimed the provider did not exist yet and that
|
|
21599
|
+
* "no package events fire" — that was stale, and believing it produced a
|
|
21600
|
+
* confidently wrong diagnosis on 2026-07-30. An empty list here means the
|
|
21601
|
+
* operator has drawn no package zone rule, nothing more.
|
|
19996
21602
|
*/
|
|
19997
21603
|
async resolveDevicePackageRules(deviceId) {
|
|
19998
21604
|
const proxy = await this.ensureProxy(deviceId);
|
|
@@ -20729,42 +22335,112 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
20729
22335
|
if (this.shuttingDown || !this.trackStore) return;
|
|
20730
22336
|
await this.trackCloser.sweep();
|
|
20731
22337
|
}
|
|
22338
|
+
/** Unified retention (Phase 1): earliest retained footage per device — the
|
|
22339
|
+
* follow-recordings cutoff source. Cached 10 min: availability answers in
|
|
22340
|
+
* ~70 ms but the sweep asks once per device per pass. `null` = no footage
|
|
22341
|
+
* (or recorder unreachable) → the policy falls back to the custom windows. */
|
|
22342
|
+
earliestFootageCache = /* @__PURE__ */ new Map();
|
|
22343
|
+
async earliestFootageMs(deviceId) {
|
|
22344
|
+
const cached = this.earliestFootageCache.get(deviceId);
|
|
22345
|
+
if (cached && Date.now() - cached.at < 10 * 6e4) return cached.value;
|
|
22346
|
+
let value = null;
|
|
22347
|
+
try {
|
|
22348
|
+
const res = await this.ctx.api.recording.getAvailability.query({
|
|
22349
|
+
deviceId,
|
|
22350
|
+
fromMs: 0,
|
|
22351
|
+
toMs: Date.now()
|
|
22352
|
+
});
|
|
22353
|
+
let min = Number.POSITIVE_INFINITY;
|
|
22354
|
+
for (const r of res.ranges) if (r.startMs < min) min = r.startMs;
|
|
22355
|
+
value = Number.isFinite(min) ? min : null;
|
|
22356
|
+
} catch {
|
|
22357
|
+
value = null;
|
|
22358
|
+
}
|
|
22359
|
+
this.earliestFootageCache.set(deviceId, {
|
|
22360
|
+
at: Date.now(),
|
|
22361
|
+
value
|
|
22362
|
+
});
|
|
22363
|
+
return value;
|
|
22364
|
+
}
|
|
22365
|
+
/** The per-device effective cutoffs (mode + footage → numbers). */
|
|
22366
|
+
async deviceRetentionCutoffs(deviceId, nowMs) {
|
|
22367
|
+
const settings = resolveRetentionSettings(await this.ctx?.settings?.readDeviceStore(deviceId) ?? {});
|
|
22368
|
+
return resolveRetentionCutoffs(settings, nowMs, settings.retentionMode === "follow-recordings" ? await this.earliestFootageMs(deviceId) : null);
|
|
22369
|
+
}
|
|
22370
|
+
/** The devices one retention pass covers: every camera the hub knows plus
|
|
22371
|
+
* any device that still has persisted tracks (covers deleted cameras whose
|
|
22372
|
+
* history must keep aging out). */
|
|
22373
|
+
async retentionDeviceIds() {
|
|
22374
|
+
const ids = /* @__PURE__ */ new Set();
|
|
22375
|
+
try {
|
|
22376
|
+
const all = await this.ctx.api.deviceManager.listAll.query({});
|
|
22377
|
+
for (const d of all) ids.add(d.id);
|
|
22378
|
+
} catch {}
|
|
22379
|
+
try {
|
|
22380
|
+
for (const id of await this.trackStore?.listDeviceIds() ?? []) ids.add(id);
|
|
22381
|
+
} catch {}
|
|
22382
|
+
return [...ids];
|
|
22383
|
+
}
|
|
20732
22384
|
async sweepRetention() {
|
|
20733
22385
|
if (this.shuttingDown || !this.eventStore || !this.mediaStore) return;
|
|
20734
22386
|
const now = Date.now();
|
|
20735
22387
|
const day = 1440 * 60 * 1e3;
|
|
20736
|
-
const OBJECT_RETENTION_DAYS = 30;
|
|
20737
|
-
const motionCutoffMs = now - 14 * day;
|
|
20738
|
-
const objectCutoffMs = now - OBJECT_RETENTION_DAYS * day;
|
|
20739
|
-
const audioCutoffMs = now - 7 * day;
|
|
20740
22388
|
try {
|
|
20741
|
-
const
|
|
20742
|
-
|
|
20743
|
-
|
|
20744
|
-
|
|
20745
|
-
|
|
20746
|
-
|
|
20747
|
-
|
|
20748
|
-
|
|
20749
|
-
|
|
20750
|
-
|
|
20751
|
-
|
|
20752
|
-
|
|
20753
|
-
|
|
20754
|
-
|
|
20755
|
-
|
|
20756
|
-
|
|
20757
|
-
|
|
20758
|
-
|
|
20759
|
-
|
|
20760
|
-
|
|
22389
|
+
const deviceIds = await this.retentionDeviceIds();
|
|
22390
|
+
const evictedIds = [];
|
|
22391
|
+
let minObjectCutoffMs = Number.POSITIVE_INFINITY;
|
|
22392
|
+
for (const deviceId of deviceIds) {
|
|
22393
|
+
if (this.shuttingDown) return;
|
|
22394
|
+
try {
|
|
22395
|
+
const cutoffs = await this.deviceRetentionCutoffs(deviceId, now);
|
|
22396
|
+
if (cutoffs.objectCutoffMs !== null && cutoffs.objectCutoffMs < minObjectCutoffMs) minObjectCutoffMs = cutoffs.objectCutoffMs;
|
|
22397
|
+
if (cutoffs.motionCutoffMs === null && cutoffs.objectCutoffMs === null && cutoffs.audioCutoffMs === null) continue;
|
|
22398
|
+
const evicted = await this.eventStore.evictBefore({
|
|
22399
|
+
deviceId,
|
|
22400
|
+
motionCutoffMs: cutoffs.motionCutoffMs ?? 0,
|
|
22401
|
+
objectCutoffMs: cutoffs.objectCutoffMs ?? 0,
|
|
22402
|
+
audioCutoffMs: cutoffs.audioCutoffMs ?? 0
|
|
22403
|
+
});
|
|
22404
|
+
const ids = [
|
|
22405
|
+
...evicted.motion,
|
|
22406
|
+
...evicted.object,
|
|
22407
|
+
...evicted.audio
|
|
22408
|
+
];
|
|
22409
|
+
if (ids.length > 0) {
|
|
22410
|
+
evictedIds.push(...ids);
|
|
22411
|
+
this.eventsOpsLog?.append({
|
|
22412
|
+
op: "prune",
|
|
22413
|
+
reason: "retention",
|
|
22414
|
+
deviceId,
|
|
22415
|
+
itemsAffected: ids.length,
|
|
22416
|
+
bytesReclaimed: 0,
|
|
22417
|
+
detail: `age sweep (${cutoffs.effectiveMode}): ${evicted.motion.length} motion, ${evicted.object.length} object, ${evicted.audio.length} audio`
|
|
22418
|
+
});
|
|
22419
|
+
this.ctx.logger.info("analytics event eviction (age sweep)", {
|
|
22420
|
+
tags: { deviceId },
|
|
22421
|
+
meta: {
|
|
22422
|
+
motion: evicted.motion.length,
|
|
22423
|
+
object: evicted.object.length,
|
|
22424
|
+
audio: evicted.audio.length,
|
|
22425
|
+
mode: cutoffs.effectiveMode,
|
|
22426
|
+
objectCutoffMs: cutoffs.objectCutoffMs
|
|
22427
|
+
}
|
|
22428
|
+
});
|
|
22429
|
+
}
|
|
22430
|
+
} catch (err) {
|
|
22431
|
+
this.ctx.logger.debug("event retention sweep (device) failed", {
|
|
22432
|
+
tags: { deviceId },
|
|
22433
|
+
meta: { error: String(err) }
|
|
22434
|
+
});
|
|
22435
|
+
}
|
|
20761
22436
|
}
|
|
20762
|
-
await this.mediaStore.
|
|
20763
|
-
if (
|
|
20764
|
-
|
|
22437
|
+
if (evictedIds.length > 0) await this.mediaStore.deleteForEvents(evictedIds);
|
|
22438
|
+
if (Number.isFinite(minObjectCutoffMs)) await this.mediaStore.evictBefore(minObjectCutoffMs - 1 * day);
|
|
22439
|
+
if (this.sensorEventStore && Number.isFinite(minObjectCutoffMs)) try {
|
|
22440
|
+
const sensorDeleted = await this.sensorEventStore.evictBefore(minObjectCutoffMs);
|
|
20765
22441
|
if (sensorDeleted > 0) this.ctx.logger.info("sensor-event retention prune", { meta: {
|
|
20766
22442
|
deleted: sensorDeleted,
|
|
20767
|
-
cutoffMs:
|
|
22443
|
+
cutoffMs: minObjectCutoffMs
|
|
20768
22444
|
} });
|
|
20769
22445
|
} catch (err) {
|
|
20770
22446
|
this.ctx.logger.debug("sensor-event prune failed", { meta: { error: String(err) } });
|
|
@@ -20795,8 +22471,8 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
20795
22471
|
} catch (err) {
|
|
20796
22472
|
this.ctx.logger.debug("plate buffer prune failed", { meta: { error: String(err) } });
|
|
20797
22473
|
}
|
|
20798
|
-
if (this.objectEmbeddingStore) try {
|
|
20799
|
-
const deletedEmbIds = await this.objectEmbeddingStore.pruneAll(
|
|
22474
|
+
if (this.objectEmbeddingStore && Number.isFinite(minObjectCutoffMs)) try {
|
|
22475
|
+
const deletedEmbIds = await this.objectEmbeddingStore.pruneAll(minObjectCutoffMs);
|
|
20800
22476
|
if (deletedEmbIds.length > 0) this.ctx.logger.info("object embedding retention prune", { meta: { deleted: deletedEmbIds.length } });
|
|
20801
22477
|
} catch (err) {
|
|
20802
22478
|
this.ctx.logger.debug("object embedding prune failed", { meta: { error: String(err) } });
|
|
@@ -21462,6 +23138,16 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
21462
23138
|
return this.queryFacade.deleteDeviceEvents(input);
|
|
21463
23139
|
}
|
|
21464
23140
|
/** The events ops-log rows (newest-first), optionally scoped to one camera. */
|
|
23141
|
+
async relocateMedia(input) {
|
|
23142
|
+
if (!this.mediaRelocate) throw new Error("media relocation unavailable");
|
|
23143
|
+
return { jobId: this.mediaRelocate.start(input) };
|
|
23144
|
+
}
|
|
23145
|
+
async getMediaRelocateStatus() {
|
|
23146
|
+
return this.mediaRelocate?.list() ?? [];
|
|
23147
|
+
}
|
|
23148
|
+
async cancelMediaRelocate(input) {
|
|
23149
|
+
return { cancelled: this.mediaRelocate?.cancel(input.jobId) ?? false };
|
|
23150
|
+
}
|
|
21465
23151
|
async listOpsLog(input) {
|
|
21466
23152
|
return this.queryFacade.listOpsLog(input);
|
|
21467
23153
|
}
|
|
@@ -21541,13 +23227,22 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
21541
23227
|
try {
|
|
21542
23228
|
const total = await sweepTrackRetention({
|
|
21543
23229
|
listDeviceIds: () => trackStore.listDeviceIds(),
|
|
21544
|
-
|
|
21545
|
-
|
|
23230
|
+
resolveCutoffMs: async (deviceId, nowMs) => (await this.deviceRetentionCutoffs(deviceId, nowMs)).trackCutoffMs,
|
|
23231
|
+
pruneTracksBefore: async (deviceId, cutoffMs) => {
|
|
23232
|
+
const counts = await this.pruneTracksBefore({
|
|
23233
|
+
deviceId,
|
|
23234
|
+
cutoffMs
|
|
23235
|
+
});
|
|
23236
|
+
if (counts.tracks > 0) this.eventsOpsLog?.append({
|
|
23237
|
+
op: "prune",
|
|
23238
|
+
reason: "retention",
|
|
23239
|
+
deviceId,
|
|
23240
|
+
itemsAffected: counts.tracks,
|
|
23241
|
+
bytesReclaimed: 0,
|
|
23242
|
+
detail: `track retention cascade: ${counts.tracks} tracks, ${counts.events} events, ${counts.media} media`
|
|
23243
|
+
});
|
|
23244
|
+
return counts;
|
|
21546
23245
|
},
|
|
21547
|
-
pruneTracksBefore: (deviceId, cutoffMs) => this.pruneTracksBefore({
|
|
21548
|
-
deviceId,
|
|
21549
|
-
cutoffMs
|
|
21550
|
-
}),
|
|
21551
23246
|
now: () => Date.now(),
|
|
21552
23247
|
onError: (deviceId, err) => {
|
|
21553
23248
|
this.ctx.logger.debug("track retention sweep (device) failed", { meta: {
|
|
@@ -21641,7 +23336,7 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
21641
23336
|
const raw = await this.ctx?.settings?.readDeviceStore(input.deviceId) ?? {};
|
|
21642
23337
|
const baseSections = schema ? require_dist.hydrateSchema({
|
|
21643
23338
|
...schema,
|
|
21644
|
-
sections: retagDetectionSections(stripGlobalOnlyFields(toAnalyticsDeviceSections(schema.sections)))
|
|
23339
|
+
sections: retagRetentionSection(retagDetectionSections(stripGlobalOnlyFields(toAnalyticsDeviceSections(schema.sections))))
|
|
21645
23340
|
}, raw).sections : [];
|
|
21646
23341
|
const liveStatsSection = {
|
|
21647
23342
|
id: "live-stats",
|
|
@@ -21697,5 +23392,6 @@ exports.default = PipelineAnalyticsAddon;
|
|
|
21697
23392
|
exports.ncActions = ncActions;
|
|
21698
23393
|
exports.pickCleanMedia = pickCleanMedia;
|
|
21699
23394
|
exports.retagDetectionSections = retagDetectionSections;
|
|
23395
|
+
exports.retagRetentionSection = retagRetentionSection;
|
|
21700
23396
|
exports.stripGlobalOnlyFields = stripGlobalOnlyFields;
|
|
21701
23397
|
exports.toAnalyticsDeviceSections = toAnalyticsDeviceSections;
|