@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
|
@@ -1,6 +1,346 @@
|
|
|
1
|
-
import { A as
|
|
2
|
-
import {
|
|
1
|
+
import { A as errMsg, B as number, D as subKindsOf, E as plateGalleryCapability, F as nodePin, H as string, I as _enum, L as array, M as DeviceType, N as createEvent, O as videoclipsCapability, P as hydrateSchema, R as boolean, S as faceGalleryCapability, T as pipelineAnalyticsCapability, U as EventCategory, V as object, _ as buildEventKindDescriptor, a as NC_CONDITION_CATALOG, b as defineCustomActions, c as NcRuleInputSchema, d as NcTaxonomySchema, f as OpsLogEntrySchema, g as audioMetricsCapability, h as addonWidgetsSourceCapability, i as MACRO_LABELS, j as BaseAddon, k as zoneAnalyticsCapability, l as NcRulePatchSchema, m as TimelapseRuleSchema, n as EVENT_KIND_BY_CAP, o as NC_TAXONOMY, p as TimelapseRuleInputSchema, r as EVENT_PAD_MS, s as NcConditionDescriptorSchema, t as DEFAULT_EVENT_COLOR, u as NcRuleSchema, v as cosineSimilarity, w as notificationRulesCapability, y as customAction, z as literal } from "../dist-C41w6Xvl.mjs";
|
|
2
|
+
import { promises } from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { createHmac, randomUUID, timingSafeEqual } from "node:crypto";
|
|
3
5
|
import sharp from "sharp";
|
|
6
|
+
//#region src/notification-center/artifact-url.ts
|
|
7
|
+
/**
|
|
8
|
+
* Signed, externally-reachable URLs for notification artifacts.
|
|
9
|
+
*
|
|
10
|
+
* WHY this exists: the dispatcher only ever produced attachment BYTES, and the
|
|
11
|
+
* degrade engine drops a bytes-only attachment for a `mode:'url'` target
|
|
12
|
+
* (`attachment:noUrl`) — so WhatsApp and gotify received no media at all,
|
|
13
|
+
* silently. A URL also lets an oversized attachment degrade to a link instead
|
|
14
|
+
* of being dropped on `maxBytes`.
|
|
15
|
+
*
|
|
16
|
+
* Two decisions the shape depends on:
|
|
17
|
+
*
|
|
18
|
+
* - **Signed, not authenticated.** The fetcher is a notifier BACKEND (or the
|
|
19
|
+
* recipient's phone) — it holds no session and no admin token. The route is
|
|
20
|
+
* therefore `access:'public'` and the authority is the signature: an
|
|
21
|
+
* unguessable HMAC over `(id, exp)` with a per-install secret, expiring on
|
|
22
|
+
* its own. Nothing else about the install is reachable through it.
|
|
23
|
+
* - **The base URL is CHOSEN, never assumed.** A notification carrying
|
|
24
|
+
* `http://127.0.0.1:...` is useless (the Alexa `hubUrl` bug, again). The
|
|
25
|
+
* ranked endpoint list the `local-network` cap already computes — public
|
|
26
|
+
* tunnel > mesh > LAN, loopback last — is the authority, and loopback is
|
|
27
|
+
* refused outright rather than shipped as a broken link.
|
|
28
|
+
*/
|
|
29
|
+
/**
|
|
30
|
+
* Pick the base URL an artifact link should use: the LOWEST-priority-number
|
|
31
|
+
* endpoint that is not loopback (the list is already ranked public > mesh >
|
|
32
|
+
* LAN > loopback). `null` when only loopback is available — a link nobody
|
|
33
|
+
* outside the host could open is worse than no link, because the notification
|
|
34
|
+
* would arrive advertising media that 404s.
|
|
35
|
+
*/
|
|
36
|
+
function pickArtifactBaseUrl(endpoints) {
|
|
37
|
+
return endpoints.filter((e) => e.kind !== "loopback").toSorted((a, b) => a.priority - b.priority)[0]?.baseUrl ?? null;
|
|
38
|
+
}
|
|
39
|
+
/** Sign `(id, exp)` — the exact string the verifier recomputes. */
|
|
40
|
+
function signArtifact(secret, id, expMs) {
|
|
41
|
+
return createHmac("sha256", secret).update(`${id}:${expMs}`).digest("hex");
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Build the fully-qualified, signed URL for an artifact.
|
|
45
|
+
* `baseUrl` comes from {@link pickArtifactBaseUrl}, `routePrefix` from the
|
|
46
|
+
* data-plane handle (`/addon/<addonId>/nc-artifact`).
|
|
47
|
+
*/
|
|
48
|
+
function buildArtifactUrl(input) {
|
|
49
|
+
const sig = signArtifact(input.secret, input.id, input.expMs);
|
|
50
|
+
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}`;
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Verify a request's `(id, exp, sig)`. Constant-time on the signature so a
|
|
54
|
+
* public route cannot be probed for it byte by byte, and expiry-checked before
|
|
55
|
+
* the compare so an expired link is rejected even with a valid signature.
|
|
56
|
+
*/
|
|
57
|
+
function verifyArtifactSignature(input) {
|
|
58
|
+
if (!Number.isFinite(input.exp) || input.exp <= input.nowMs) return false;
|
|
59
|
+
const expected = signArtifact(input.secret, input.id, input.exp);
|
|
60
|
+
const a = Buffer.from(expected, "utf8");
|
|
61
|
+
const b = Buffer.from(input.sig, "utf8");
|
|
62
|
+
if (a.length !== b.length) return false;
|
|
63
|
+
return timingSafeEqual(a, b);
|
|
64
|
+
}
|
|
65
|
+
//#endregion
|
|
66
|
+
//#region src/notification-center/artifact-plane.ts
|
|
67
|
+
/**
|
|
68
|
+
* The hub's default HTTPS API port — the same constant `derive-hub-url.ts`
|
|
69
|
+
* documents. Only used for the LAN candidates; an operator who runs the hub on
|
|
70
|
+
* another port sets `CAMSTACK_HUB_PUBLIC_URL`, which wins outright.
|
|
71
|
+
*/
|
|
72
|
+
var HUB_API_PORT = 4443;
|
|
73
|
+
/**
|
|
74
|
+
* Ranking of the public sources. All NEGATIVE: `local-network` gives its
|
|
75
|
+
* preferred LAN interface priority `0`, so a public candidate has to sit below
|
|
76
|
+
* zero to outrank it.
|
|
77
|
+
*/
|
|
78
|
+
var PRIORITY_MARKED = -400;
|
|
79
|
+
var PRIORITY_CONFIGURED = -300;
|
|
80
|
+
var PRIORITY_CONNECTED = -200;
|
|
81
|
+
var PRIORITY_EXTERNAL_LIST = -100;
|
|
82
|
+
/**
|
|
83
|
+
* Build the ranked candidate list, in the operator's own order: an explicitly
|
|
84
|
+
* configured public origin, then the connected external ingress (tunnels),
|
|
85
|
+
* then the LAN addresses. Every source is best-effort — one that throws
|
|
86
|
+
* contributes nothing rather than costing the whole list.
|
|
87
|
+
*/
|
|
88
|
+
async function collectArtifactEndpoints(sources) {
|
|
89
|
+
const out = [];
|
|
90
|
+
const marked = sources.markedBaseUrl;
|
|
91
|
+
if (marked !== void 0 && marked !== "") out.push({
|
|
92
|
+
baseUrl: marked,
|
|
93
|
+
kind: "public",
|
|
94
|
+
priority: PRIORITY_MARKED
|
|
95
|
+
});
|
|
96
|
+
const configured = sources.configuredPublicUrl;
|
|
97
|
+
if (configured !== void 0 && configured !== "" && !/127\.0\.0\.1|localhost/.test(configured)) out.push({
|
|
98
|
+
baseUrl: configured,
|
|
99
|
+
kind: "public",
|
|
100
|
+
priority: PRIORITY_CONFIGURED
|
|
101
|
+
});
|
|
102
|
+
try {
|
|
103
|
+
const connected = await sources.getConnected();
|
|
104
|
+
if (connected !== null && connected.protocol === "https") out.push({
|
|
105
|
+
baseUrl: connected.url,
|
|
106
|
+
kind: "public",
|
|
107
|
+
priority: PRIORITY_CONNECTED
|
|
108
|
+
});
|
|
109
|
+
} catch (err) {
|
|
110
|
+
sources.logger.debug("connected endpoint lookup failed", { meta: { error: String(err) } });
|
|
111
|
+
}
|
|
112
|
+
try {
|
|
113
|
+
const external = await sources.listExternal();
|
|
114
|
+
for (const [i, e] of external.entries()) {
|
|
115
|
+
if (e.protocol !== "https") continue;
|
|
116
|
+
out.push({
|
|
117
|
+
baseUrl: e.url,
|
|
118
|
+
kind: "public",
|
|
119
|
+
priority: PRIORITY_EXTERNAL_LIST + i
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
} catch (err) {
|
|
123
|
+
sources.logger.debug("external endpoint lookup failed", { meta: { error: String(err) } });
|
|
124
|
+
}
|
|
125
|
+
try {
|
|
126
|
+
out.push(...await sources.listLan(HUB_API_PORT));
|
|
127
|
+
} catch (err) {
|
|
128
|
+
sources.logger.debug("LAN endpoint lookup failed", { meta: { error: String(err) } });
|
|
129
|
+
}
|
|
130
|
+
return out;
|
|
131
|
+
}
|
|
132
|
+
var DEFAULT_TTL_MS = 24 * 36e5;
|
|
133
|
+
var DEFAULT_ENDPOINT_CACHE_MS = 6e4;
|
|
134
|
+
var NcArtifactPlane = class {
|
|
135
|
+
deps;
|
|
136
|
+
now;
|
|
137
|
+
cachedBaseUrl = null;
|
|
138
|
+
/** Last base URL announced in the log — so the line appears on CHANGE only. */
|
|
139
|
+
lastLoggedBaseUrl = null;
|
|
140
|
+
cachedAt = 0;
|
|
141
|
+
constructor(deps) {
|
|
142
|
+
this.deps = deps;
|
|
143
|
+
this.now = deps.now ?? (() => Date.now());
|
|
144
|
+
}
|
|
145
|
+
/**
|
|
146
|
+
* Store bytes and mint a signed, externally-reachable URL for them.
|
|
147
|
+
* Returns `null` when no usable base URL exists (loopback only) — the caller
|
|
148
|
+
* then ships bytes alone, exactly as before. Never throws: an artifact URL is
|
|
149
|
+
* an ENHANCEMENT, and failing to mint one must not cost the notification.
|
|
150
|
+
*/
|
|
151
|
+
async publish(bytes, mime) {
|
|
152
|
+
try {
|
|
153
|
+
const baseUrl = await this.resolveBaseUrl();
|
|
154
|
+
if (baseUrl === null) return null;
|
|
155
|
+
const artifact = await this.deps.store.put(bytes, mime);
|
|
156
|
+
return buildArtifactUrl({
|
|
157
|
+
baseUrl,
|
|
158
|
+
routePrefix: this.deps.routePrefix,
|
|
159
|
+
id: artifact.id,
|
|
160
|
+
secret: this.deps.secret,
|
|
161
|
+
expMs: this.now() + (this.deps.ttlMs ?? DEFAULT_TTL_MS)
|
|
162
|
+
});
|
|
163
|
+
} catch (err) {
|
|
164
|
+
this.deps.logger.debug("artifact publish failed — shipping bytes only", { meta: { error: String(err) } });
|
|
165
|
+
return null;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
/** The data-plane handler for `<prefix>/<id>?exp=&sig=`. */
|
|
169
|
+
handler = async (req, res) => {
|
|
170
|
+
const url = new URL(req.url ?? "/", "http://placeholder");
|
|
171
|
+
const id = decodeURIComponent(url.pathname.split("/").filter(Boolean).pop() ?? "");
|
|
172
|
+
if (!verifyArtifactSignature({
|
|
173
|
+
secret: this.deps.secret,
|
|
174
|
+
id,
|
|
175
|
+
exp: Number(url.searchParams.get("exp")),
|
|
176
|
+
sig: url.searchParams.get("sig") ?? "",
|
|
177
|
+
nowMs: this.now()
|
|
178
|
+
})) {
|
|
179
|
+
res.writeHead(404, { "content-type": "text/plain" });
|
|
180
|
+
res.end("not found");
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
const found = await this.deps.store.read(id);
|
|
184
|
+
if (found === null) {
|
|
185
|
+
res.writeHead(404, { "content-type": "text/plain" });
|
|
186
|
+
res.end("not found");
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
res.writeHead(200, {
|
|
190
|
+
"content-type": found.mime,
|
|
191
|
+
"content-length": String(found.bytes.byteLength),
|
|
192
|
+
"cache-control": "private, max-age=3600"
|
|
193
|
+
});
|
|
194
|
+
res.end(found.bytes);
|
|
195
|
+
};
|
|
196
|
+
/** The chosen base URL, cached briefly (the interface list barely moves). */
|
|
197
|
+
async resolveBaseUrl() {
|
|
198
|
+
const ttl = this.deps.endpointCacheMs ?? DEFAULT_ENDPOINT_CACHE_MS;
|
|
199
|
+
if (this.cachedBaseUrl !== null && this.now() - this.cachedAt < ttl) return this.cachedBaseUrl;
|
|
200
|
+
const picked = pickArtifactBaseUrl(await this.deps.listEndpoints());
|
|
201
|
+
if (picked === null) {
|
|
202
|
+
this.deps.logger.debug("no externally-reachable endpoint — artifacts ship as bytes only");
|
|
203
|
+
return null;
|
|
204
|
+
}
|
|
205
|
+
if (picked !== this.lastLoggedBaseUrl) {
|
|
206
|
+
this.deps.logger.info("artifact links will use", { meta: { baseUrl: picked } });
|
|
207
|
+
this.lastLoggedBaseUrl = picked;
|
|
208
|
+
}
|
|
209
|
+
this.cachedBaseUrl = picked;
|
|
210
|
+
this.cachedAt = this.now();
|
|
211
|
+
return picked;
|
|
212
|
+
}
|
|
213
|
+
};
|
|
214
|
+
//#endregion
|
|
215
|
+
//#region src/notification-center/artifact-store.ts
|
|
216
|
+
/**
|
|
217
|
+
* NcArtifactStore — the bytes behind a signed notification-artifact URL.
|
|
218
|
+
*
|
|
219
|
+
* A notification attachment is transient by nature: it exists to be fetched
|
|
220
|
+
* once, by a notifier backend or the recipient's phone, within minutes of the
|
|
221
|
+
* event. So this is a BOUNDED SCRATCH, not a media library — the durable copy
|
|
222
|
+
* of an event's media already lives in the media store, and duplicating it here
|
|
223
|
+
* permanently would grow without limit for no benefit.
|
|
224
|
+
*
|
|
225
|
+
* Two bounds, both enforced on every write (age first, then count): whichever
|
|
226
|
+
* bites first, the oldest artifacts go. A sweep on boot clears whatever a crash
|
|
227
|
+
* left behind — the directory is disposable by construction, so a lost file is
|
|
228
|
+
* a 404 on one link, never a corrupt state.
|
|
229
|
+
*/
|
|
230
|
+
var DEFAULT_MAX_AGE_MS = 6 * 36e5;
|
|
231
|
+
var DEFAULT_MAX_ENTRIES = 500;
|
|
232
|
+
/** `image/jpeg` → `jpg`, so a fetched link has a name a client will accept. */
|
|
233
|
+
function extensionFor(mime) {
|
|
234
|
+
if (mime === "image/jpeg") return "jpg";
|
|
235
|
+
if (mime === "image/gif") return "gif";
|
|
236
|
+
if (mime === "video/mp4") return "mp4";
|
|
237
|
+
if (mime === "image/png") return "png";
|
|
238
|
+
return "bin";
|
|
239
|
+
}
|
|
240
|
+
var NcArtifactStore = class {
|
|
241
|
+
options;
|
|
242
|
+
now;
|
|
243
|
+
maxAgeMs;
|
|
244
|
+
maxEntries;
|
|
245
|
+
/** id → mime, so the route can answer with the right content-type without
|
|
246
|
+
* re-deriving it from the extension on every request. */
|
|
247
|
+
mimeById = /* @__PURE__ */ new Map();
|
|
248
|
+
constructor(options) {
|
|
249
|
+
this.options = options;
|
|
250
|
+
this.now = options.now ?? (() => Date.now());
|
|
251
|
+
this.maxAgeMs = options.maxAgeMs ?? DEFAULT_MAX_AGE_MS;
|
|
252
|
+
this.maxEntries = options.maxEntries ?? DEFAULT_MAX_ENTRIES;
|
|
253
|
+
}
|
|
254
|
+
/** Create the directory and sweep whatever a previous run left behind. */
|
|
255
|
+
async start() {
|
|
256
|
+
await promises.mkdir(this.options.dir, { recursive: true });
|
|
257
|
+
await this.sweep();
|
|
258
|
+
}
|
|
259
|
+
/** Persist bytes and return the artifact handle (never throws on sweep). */
|
|
260
|
+
async put(bytes, mime) {
|
|
261
|
+
const id = `${this.now()}-${randomUUID()}`;
|
|
262
|
+
const file = path.join(this.options.dir, `${id}.${extensionFor(mime)}`);
|
|
263
|
+
await promises.mkdir(this.options.dir, { recursive: true });
|
|
264
|
+
await promises.writeFile(file, bytes);
|
|
265
|
+
this.mimeById.set(id, mime);
|
|
266
|
+
await this.sweep();
|
|
267
|
+
return {
|
|
268
|
+
id,
|
|
269
|
+
mime,
|
|
270
|
+
bytes: bytes.byteLength
|
|
271
|
+
};
|
|
272
|
+
}
|
|
273
|
+
/** Read an artifact's bytes + mime, or null when it is gone (expired/swept). */
|
|
274
|
+
async read(id) {
|
|
275
|
+
if (!/^[0-9]+-[0-9a-f-]{36}$/i.test(id)) return null;
|
|
276
|
+
const found = (await this.list()).find((e) => e.id === id);
|
|
277
|
+
if (!found) return null;
|
|
278
|
+
try {
|
|
279
|
+
return {
|
|
280
|
+
bytes: await promises.readFile(found.file),
|
|
281
|
+
mime: this.mimeById.get(id) ?? mimeFromExtension(found.file)
|
|
282
|
+
};
|
|
283
|
+
} catch {
|
|
284
|
+
return null;
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
/** Delete every artifact (operator cleanup / shutdown). */
|
|
288
|
+
async clear() {
|
|
289
|
+
await promises.rm(this.options.dir, {
|
|
290
|
+
recursive: true,
|
|
291
|
+
force: true
|
|
292
|
+
}).catch(() => {});
|
|
293
|
+
this.mimeById.clear();
|
|
294
|
+
}
|
|
295
|
+
/** Age- then count-bounded prune. Best-effort: a failed unlink is logged once. */
|
|
296
|
+
async sweep() {
|
|
297
|
+
const entries = await this.list();
|
|
298
|
+
const cutoff = this.now() - this.maxAgeMs;
|
|
299
|
+
const doomed = entries.filter((e) => e.mtimeMs < cutoff);
|
|
300
|
+
const survivors = entries.filter((e) => e.mtimeMs >= cutoff).toSorted((a, b) => b.mtimeMs - a.mtimeMs);
|
|
301
|
+
doomed.push(...survivors.slice(this.maxEntries));
|
|
302
|
+
for (const e of doomed) try {
|
|
303
|
+
await promises.rm(e.file, { force: true });
|
|
304
|
+
this.mimeById.delete(e.id);
|
|
305
|
+
} catch (err) {
|
|
306
|
+
this.options.logger.debug("artifact sweep failed to unlink", { meta: {
|
|
307
|
+
file: e.file,
|
|
308
|
+
error: String(err)
|
|
309
|
+
} });
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
async list() {
|
|
313
|
+
let names;
|
|
314
|
+
try {
|
|
315
|
+
names = await promises.readdir(this.options.dir);
|
|
316
|
+
} catch {
|
|
317
|
+
return [];
|
|
318
|
+
}
|
|
319
|
+
const out = [];
|
|
320
|
+
for (const name of names) {
|
|
321
|
+
const file = path.join(this.options.dir, name);
|
|
322
|
+
try {
|
|
323
|
+
const st = await promises.stat(file);
|
|
324
|
+
if (!st.isFile()) continue;
|
|
325
|
+
out.push({
|
|
326
|
+
id: name.slice(0, name.lastIndexOf(".")),
|
|
327
|
+
file,
|
|
328
|
+
mtimeMs: st.mtimeMs
|
|
329
|
+
});
|
|
330
|
+
} catch {}
|
|
331
|
+
}
|
|
332
|
+
return out;
|
|
333
|
+
}
|
|
334
|
+
};
|
|
335
|
+
function mimeFromExtension(file) {
|
|
336
|
+
const ext = file.slice(file.lastIndexOf(".") + 1).toLowerCase();
|
|
337
|
+
if (ext === "jpg") return "image/jpeg";
|
|
338
|
+
if (ext === "gif") return "image/gif";
|
|
339
|
+
if (ext === "mp4") return "video/mp4";
|
|
340
|
+
if (ext === "png") return "image/png";
|
|
341
|
+
return "application/octet-stream";
|
|
342
|
+
}
|
|
343
|
+
//#endregion
|
|
4
344
|
//#region src/pipeline-analytics/videoclips-provider.ts
|
|
5
345
|
var SOURCE = "analytics";
|
|
6
346
|
function clipIdFor(eventId, startMs, endMs) {
|
|
@@ -1956,16 +2296,28 @@ function resolveRuleThreshold(rule) {
|
|
|
1956
2296
|
*/
|
|
1957
2297
|
var ZoneEngine = class {
|
|
1958
2298
|
/**
|
|
1959
|
-
* Annotate a single detection with its zone memberships
|
|
1960
|
-
*
|
|
1961
|
-
*
|
|
2299
|
+
* Annotate a single detection with its zone memberships — every zone whose
|
|
2300
|
+
* polygon the detection overlaps by MORE than `minOverlap` (0–1 fraction of
|
|
2301
|
+
* the detection's own area).
|
|
2302
|
+
*
|
|
2303
|
+
* The previous version of this comment claimed memberships were returned
|
|
2304
|
+
* "above any active rule's threshold"; they were not — the threshold was
|
|
2305
|
+
* hardcoded to {@link MEMBERSHIP_MIN_OVERLAP} (zero) and no rule was ever
|
|
2306
|
+
* consulted. Read the parameter, not this paragraph.
|
|
2307
|
+
*
|
|
2308
|
+
* `minOverlap` matters because membership is what lands on an event as
|
|
2309
|
+
* `zones`, and a notification rule's `zones` condition is a plain set test
|
|
2310
|
+
* over that field — so this, not the zone-RULE threshold, is what decides
|
|
2311
|
+
* whether a zone-scoped notification fires. At the default of 0 a subject
|
|
2312
|
+
* clipping a zone by one pixel counts as inside it (measured 2026-07-30: a
|
|
2313
|
+
* dog overlapping `Aiuola` by 4.8% would have been stamped as in it).
|
|
1962
2314
|
*/
|
|
1963
|
-
annotateDetection(bbox, zones, frameWidth, frameHeight, mask, maskWidth, maskHeight) {
|
|
2315
|
+
annotateDetection(bbox, zones, frameWidth, frameHeight, mask, maskWidth, maskHeight, minOverlap = MEMBERSHIP_MIN_OVERLAP) {
|
|
1964
2316
|
const memberships = [];
|
|
1965
2317
|
for (const zone of zones) {
|
|
1966
2318
|
const pixelPolygon = zone.polygon.map((p) => normalizeToPixel(p, frameWidth, frameHeight));
|
|
1967
2319
|
const overlap = mask && maskWidth && maskHeight ? maskPolygonOverlap(mask, maskWidth, maskHeight, bbox, pixelPolygon, frameWidth, frameHeight) : bboxPolygonOverlap(bbox, pixelPolygon);
|
|
1968
|
-
if (overlap >
|
|
2320
|
+
if (overlap > minOverlap) memberships.push({
|
|
1969
2321
|
zoneId: zone.id,
|
|
1970
2322
|
zoneName: zone.name,
|
|
1971
2323
|
overlap
|
|
@@ -2065,6 +2417,10 @@ var FrameProcessor = class {
|
|
|
2065
2417
|
* runners that haven't picked up the new gating yet.
|
|
2066
2418
|
*/
|
|
2067
2419
|
detectionRules;
|
|
2420
|
+
/** See {@link setZoneMembershipMinOverlap}. 0 = any positive overlap. */
|
|
2421
|
+
zoneMembershipMinOverlap;
|
|
2422
|
+
/** See {@link getLastZoneOverlaps}. */
|
|
2423
|
+
lastZoneOverlaps;
|
|
2068
2424
|
zoneEngine = new ZoneEngine();
|
|
2069
2425
|
/** Optional stationary-object gate (parked-object suppression). Null until
|
|
2070
2426
|
* the addon wires it via {@link setStationaryGate}. */
|
|
@@ -2077,10 +2433,32 @@ var FrameProcessor = class {
|
|
|
2077
2433
|
this.eventEmitter = new DetectionEventEmitter(emitterConfig);
|
|
2078
2434
|
this.zones = [];
|
|
2079
2435
|
this.detectionRules = [];
|
|
2436
|
+
this.zoneMembershipMinOverlap = 0;
|
|
2437
|
+
this.lastZoneOverlaps = /* @__PURE__ */ new Map();
|
|
2080
2438
|
}
|
|
2081
2439
|
setZones(zones) {
|
|
2082
2440
|
this.zones = zones;
|
|
2083
2441
|
}
|
|
2442
|
+
/**
|
|
2443
|
+
* How much of a detection's box must lie inside a zone for the zone to be
|
|
2444
|
+
* stamped onto it (0–1 fraction of the box's own area).
|
|
2445
|
+
*
|
|
2446
|
+
* DEFAULT 0 — byte-identical to the behaviour before 2026-07-31, where any
|
|
2447
|
+
* positive overlap counted. Raising it is an operator decision and needs
|
|
2448
|
+
* evidence: a bar set blind removes notifications silently, which is the
|
|
2449
|
+
* failure mode this whole area keeps producing. {@link lastZoneOverlaps}
|
|
2450
|
+
* exists so the distribution can be read before a number is picked.
|
|
2451
|
+
*/
|
|
2452
|
+
setZoneMembershipMinOverlap(minOverlap) {
|
|
2453
|
+
this.zoneMembershipMinOverlap = Number.isFinite(minOverlap) && minOverlap >= 0 && minOverlap <= 1 ? minOverlap : 0;
|
|
2454
|
+
}
|
|
2455
|
+
/** Per-track zone memberships WITH their overlap fractions, from the most
|
|
2456
|
+
* recent frame. The engine computes these and the pipeline previously
|
|
2457
|
+
* discarded everything but the ids — which is why no amount of production
|
|
2458
|
+
* data could say how far inside the zone a notifying subject actually was. */
|
|
2459
|
+
getLastZoneOverlaps() {
|
|
2460
|
+
return this.lastZoneOverlaps;
|
|
2461
|
+
}
|
|
2084
2462
|
setDetectionRules(rules) {
|
|
2085
2463
|
this.detectionRules = rules;
|
|
2086
2464
|
}
|
|
@@ -2191,11 +2569,14 @@ var FrameProcessor = class {
|
|
|
2191
2569
|
const objectStates = this.stateAnalyzer.analyze(trackedDetections, timestamp);
|
|
2192
2570
|
const rawEvents = this.eventEmitter.emit(trackedDetections, objectStates, [], [], String(this.deviceId));
|
|
2193
2571
|
const zonesByTrack = /* @__PURE__ */ new Map();
|
|
2572
|
+
const overlapsByTrack = /* @__PURE__ */ new Map();
|
|
2194
2573
|
for (const td of trackedDetections) {
|
|
2195
2574
|
const m = maskByBbox.get(td.bbox);
|
|
2196
|
-
const memberships = this.zoneEngine.annotateDetection(td.bbox, this.zones, frameWidth, frameHeight, m?.mask, m?.width, m?.height);
|
|
2575
|
+
const memberships = this.zoneEngine.annotateDetection(td.bbox, this.zones, frameWidth, frameHeight, m?.mask, m?.width, m?.height, this.zoneMembershipMinOverlap);
|
|
2197
2576
|
zonesByTrack.set(td.trackId, memberships.map((m2) => m2.zoneId));
|
|
2577
|
+
if (memberships.length > 0) overlapsByTrack.set(td.trackId, memberships);
|
|
2198
2578
|
}
|
|
2579
|
+
this.lastZoneOverlaps = overlapsByTrack;
|
|
2199
2580
|
const tracked = trackedDetections.map((td) => {
|
|
2200
2581
|
const state = mapObjectStateToTrackState(objectStates.find((o) => o.trackId === td.trackId)?.state);
|
|
2201
2582
|
const label = resolveDetectionLabel({
|
|
@@ -4157,6 +4538,42 @@ function attachmentKindPreference(policy, ownerKind) {
|
|
|
4157
4538
|
];
|
|
4158
4539
|
}
|
|
4159
4540
|
/**
|
|
4541
|
+
* The kind ladder for an EXPLICIT frame choice. Strict by design — each option
|
|
4542
|
+
* stays inside its own family, because a rule that asked for the clean scene
|
|
4543
|
+
* and received the boxed one (or vice versa) is not "degrading gracefully", it
|
|
4544
|
+
* is answering a different question.
|
|
4545
|
+
*
|
|
4546
|
+
* `boxed` is the one exception: with no annotated frame stored, the CLEAN
|
|
4547
|
+
* scene is the honest fallback (same picture, no annotation) — never a subject
|
|
4548
|
+
* crop, which shows something else entirely.
|
|
4549
|
+
*/
|
|
4550
|
+
function framePreference(frame, ownerKind) {
|
|
4551
|
+
if (frame === "cropped") return ownerKind === "track" ? [
|
|
4552
|
+
"thumbnail",
|
|
4553
|
+
"thumbnailSmall",
|
|
4554
|
+
"crop"
|
|
4555
|
+
] : [
|
|
4556
|
+
"crop",
|
|
4557
|
+
"thumbnail",
|
|
4558
|
+
"thumbnailSmall"
|
|
4559
|
+
];
|
|
4560
|
+
if (frame === "boxed") return [
|
|
4561
|
+
"fullFrameBoxed",
|
|
4562
|
+
"keyFrame",
|
|
4563
|
+
"keyFrameSmall",
|
|
4564
|
+
"fullFrame"
|
|
4565
|
+
];
|
|
4566
|
+
return ownerKind === "track" ? [
|
|
4567
|
+
"keyFrame",
|
|
4568
|
+
"keyFrameSmall",
|
|
4569
|
+
"firstFrame"
|
|
4570
|
+
] : [
|
|
4571
|
+
"fullFrame",
|
|
4572
|
+
"keyFrame",
|
|
4573
|
+
"keyFrameSmall"
|
|
4574
|
+
];
|
|
4575
|
+
}
|
|
4576
|
+
/**
|
|
4160
4577
|
* Derive the `best-matching` media signal from a matched rule's condition
|
|
4161
4578
|
* summary ({@link NcEvaluation.matchedOn}). Identity takes priority over plate
|
|
4162
4579
|
* (D-3 ordering: a face rule that ALSO plate-matched attaches the face crop).
|
|
@@ -4185,6 +4602,20 @@ function bestMatchingKindPreference(signal, ownerKind) {
|
|
|
4185
4602
|
//#endregion
|
|
4186
4603
|
//#region src/notification-center/dispatcher.ts
|
|
4187
4604
|
var DEFAULT_TARGET_CACHE_TTL_MS = 6e4;
|
|
4605
|
+
/** GIF and MP4 are the same cut in two containers — one render request each. */
|
|
4606
|
+
var FOOTAGE_FORMATS = [{
|
|
4607
|
+
flag: "mediaGif",
|
|
4608
|
+
format: "gif",
|
|
4609
|
+
mediaType: "gif",
|
|
4610
|
+
mime: "image/gif",
|
|
4611
|
+
name: "event.gif"
|
|
4612
|
+
}, {
|
|
4613
|
+
flag: "mediaClip",
|
|
4614
|
+
format: "mp4",
|
|
4615
|
+
mediaType: "video",
|
|
4616
|
+
mime: "video/mp4",
|
|
4617
|
+
name: "event.mp4"
|
|
4618
|
+
}];
|
|
4188
4619
|
var NcDispatcher = class {
|
|
4189
4620
|
deps;
|
|
4190
4621
|
targetCache = null;
|
|
@@ -4239,7 +4670,9 @@ var NcDispatcher = class {
|
|
|
4239
4670
|
ruleId: entry.ruleId,
|
|
4240
4671
|
target: target.name,
|
|
4241
4672
|
kind: target.kind,
|
|
4242
|
-
recordKind: entry.recordKind
|
|
4673
|
+
recordKind: entry.recordKind,
|
|
4674
|
+
eventId: entry.recordId,
|
|
4675
|
+
...entry.trackId !== void 0 ? { trackId: entry.trackId } : {}
|
|
4243
4676
|
}
|
|
4244
4677
|
});
|
|
4245
4678
|
return { ok: true };
|
|
@@ -4254,14 +4687,19 @@ var NcDispatcher = class {
|
|
|
4254
4687
|
async resolveTarget(targetId) {
|
|
4255
4688
|
const cached = this.cachedTarget(targetId);
|
|
4256
4689
|
if (cached !== null) return cached;
|
|
4690
|
+
let targets;
|
|
4257
4691
|
try {
|
|
4258
|
-
|
|
4259
|
-
this.targetCache = new Map(targets.map((t) => [t.id, t]));
|
|
4260
|
-
this.targetCacheAt = this.now();
|
|
4692
|
+
targets = await this.deps.listTargets();
|
|
4261
4693
|
} catch (err) {
|
|
4262
4694
|
this.deps.logger.warn("target catalog refresh failed", { meta: { error: String(err) } });
|
|
4263
4695
|
throw err instanceof Error ? err : new Error(String(err));
|
|
4264
4696
|
}
|
|
4697
|
+
if (targets.length === 0) {
|
|
4698
|
+
this.deps.logger.warn("target catalog came back EMPTY — treating as transient", { meta: { targetId } });
|
|
4699
|
+
throw new Error(`target catalog empty (transient) while resolving ${targetId}`);
|
|
4700
|
+
}
|
|
4701
|
+
this.targetCache = new Map(targets.map((t) => [t.id, t]));
|
|
4702
|
+
this.targetCacheAt = this.now();
|
|
4265
4703
|
return this.targetCache.get(targetId) ?? null;
|
|
4266
4704
|
}
|
|
4267
4705
|
cachedTarget(targetId) {
|
|
@@ -4272,10 +4710,11 @@ var NcDispatcher = class {
|
|
|
4272
4710
|
async buildNotification(entry) {
|
|
4273
4711
|
const subject = entry.payload.subject;
|
|
4274
4712
|
const deviceName = await this.deps.getDeviceName(subject.deviceId).catch(() => null) ?? `camera ${subject.deviceId}`;
|
|
4275
|
-
const
|
|
4713
|
+
const zoneLabels = await resolveZoneLabels(this.deps.getZoneNames, subject.deviceId, subject.zones);
|
|
4714
|
+
const vars = buildTemplateVars(entry, deviceName, zoneLabels);
|
|
4276
4715
|
const title = renderTemplate(entry.payload.template?.title, vars) ?? entry.payload.ruleName;
|
|
4277
|
-
const body = renderTemplate(entry.payload.template?.body, vars) ?? defaultBody(entry, deviceName);
|
|
4278
|
-
const
|
|
4716
|
+
const body = renderTemplate(entry.payload.template?.body, vars) ?? defaultBody(entry, deviceName, zoneLabels);
|
|
4717
|
+
const attachments = await this.withArtifactUrls(await this.resolveAttachments(entry));
|
|
4279
4718
|
const params = pickParams(entry.payload.params);
|
|
4280
4719
|
return {
|
|
4281
4720
|
body,
|
|
@@ -4285,7 +4724,7 @@ var NcDispatcher = class {
|
|
|
4285
4724
|
tag: entry.ruleId,
|
|
4286
4725
|
deviceId: subject.deviceId,
|
|
4287
4726
|
...subject.eventId !== void 0 ? { eventId: subject.eventId } : {},
|
|
4288
|
-
...
|
|
4727
|
+
...attachments.length > 0 ? { attachments } : {}
|
|
4289
4728
|
};
|
|
4290
4729
|
}
|
|
4291
4730
|
/**
|
|
@@ -4297,9 +4736,142 @@ var NcDispatcher = class {
|
|
|
4297
4736
|
* explains the fired condition (`faceCrop`/`plateCrop`, both event-owned)
|
|
4298
4737
|
* then degrades to the plain `best` → `keyFrame` ladders.
|
|
4299
4738
|
*/
|
|
4300
|
-
|
|
4301
|
-
|
|
4739
|
+
/**
|
|
4740
|
+
* Mint a signed URL for each attachment, alongside the bytes it already
|
|
4741
|
+
* carries. Best-effort per attachment: a failed mint leaves that one
|
|
4742
|
+
* bytes-only rather than costing the notification. Without this a url-mode
|
|
4743
|
+
* target (WhatsApp, gotify) silently received NO media — the degrade engine
|
|
4744
|
+
* drops a bytes-only attachment for them (`attachment:noUrl`).
|
|
4745
|
+
*/
|
|
4746
|
+
/**
|
|
4747
|
+
* A failed footage render is best-effort — ship the still, skip the video.
|
|
4748
|
+
*
|
|
4749
|
+
* There is nothing to wait for: the clip ring only grows FORWARD, so a window
|
|
4750
|
+
* it does not already cover will never be covered by retrying. (An earlier
|
|
4751
|
+
* revision deferred delivery here, when clips were cut from finalized
|
|
4752
|
+
* recording segments; retrying under the pre-buffer would just age the
|
|
4753
|
+
* pre-roll out of the ring.)
|
|
4754
|
+
*/
|
|
4755
|
+
notePendingFootage(entry, err, what) {
|
|
4756
|
+
this.deps.logger.debug(`${what} attachment render failed`, {
|
|
4757
|
+
tags: { deviceId: entry.payload.subject.deviceId },
|
|
4758
|
+
meta: {
|
|
4759
|
+
error: String(err),
|
|
4760
|
+
action: "send-without-video"
|
|
4761
|
+
}
|
|
4762
|
+
});
|
|
4763
|
+
}
|
|
4764
|
+
async withArtifactUrls(attachments) {
|
|
4765
|
+
const publish = this.deps.publishArtifact;
|
|
4766
|
+
if (publish === void 0) return [...attachments];
|
|
4767
|
+
const out = [];
|
|
4768
|
+
for (const att of attachments) {
|
|
4769
|
+
const url = await publish(att.bytes, att.mime).catch(() => null);
|
|
4770
|
+
out.push(url === null ? { ...att } : {
|
|
4771
|
+
...att,
|
|
4772
|
+
url
|
|
4773
|
+
});
|
|
4774
|
+
}
|
|
4775
|
+
return out;
|
|
4776
|
+
}
|
|
4777
|
+
/** The full attachment list: the policy still (zone-cropped when the rule
|
|
4778
|
+
* froze zone ids) plus the footage cut from the broker's clip ring when
|
|
4779
|
+
* requested. Each part is best-effort — a failed crop falls back to the
|
|
4780
|
+
* uncropped still, a failed render just omits the video. */
|
|
4781
|
+
async resolveAttachments(entry) {
|
|
4782
|
+
const out = [];
|
|
4783
|
+
const zoneIdsWanted = entry.payload.mediaZoneIds;
|
|
4784
|
+
const still = await this.resolveAttachment(entry, zoneIdsWanted !== void 0 && zoneIdsWanted.length > 0 ? "keyFrame" : void 0);
|
|
4785
|
+
if (still !== null) {
|
|
4786
|
+
const zoneIds = zoneIdsWanted;
|
|
4787
|
+
if (zoneIds !== void 0 && zoneIds.length > 0) {
|
|
4788
|
+
const cropped = await this.zoneCrop(entry.payload.subject.deviceId, zoneIds, still.bytes);
|
|
4789
|
+
out.push(cropped !== null ? {
|
|
4790
|
+
...still,
|
|
4791
|
+
bytes: cropped,
|
|
4792
|
+
name: "zone.jpg"
|
|
4793
|
+
} : still);
|
|
4794
|
+
} else out.push(still);
|
|
4795
|
+
}
|
|
4796
|
+
for (const want of FOOTAGE_FORMATS) {
|
|
4797
|
+
if (entry.payload[want.flag] !== true || !this.deps.renderFootage) continue;
|
|
4798
|
+
try {
|
|
4799
|
+
const rendered = await this.deps.renderFootage({
|
|
4800
|
+
deviceId: entry.payload.subject.deviceId,
|
|
4801
|
+
aroundMs: entry.payload.subject.timestamp,
|
|
4802
|
+
format: want.format,
|
|
4803
|
+
...entry.payload.mediaClipPreRollSec !== void 0 ? { preRollSec: entry.payload.mediaClipPreRollSec } : {},
|
|
4804
|
+
...entry.payload.mediaClipPostRollSec !== void 0 ? { postRollSec: entry.payload.mediaClipPostRollSec } : {},
|
|
4805
|
+
...entry.payload.mediaProfile !== void 0 ? { profile: entry.payload.mediaProfile } : {}
|
|
4806
|
+
});
|
|
4807
|
+
if (rendered !== null && rendered.byteLength > 0) {
|
|
4808
|
+
const bytes = new Uint8Array(rendered.byteLength);
|
|
4809
|
+
bytes.set(rendered);
|
|
4810
|
+
out.push({
|
|
4811
|
+
mediaType: want.mediaType,
|
|
4812
|
+
bytes,
|
|
4813
|
+
mime: want.mime,
|
|
4814
|
+
name: want.name
|
|
4815
|
+
});
|
|
4816
|
+
}
|
|
4817
|
+
} catch (err) {
|
|
4818
|
+
this.notePendingFootage(entry, err, want.format === "gif" ? "gif" : "clip");
|
|
4819
|
+
}
|
|
4820
|
+
}
|
|
4821
|
+
return out;
|
|
4822
|
+
}
|
|
4823
|
+
/** Crop a JPEG to the padded bbox of the given zones (normalized polygons →
|
|
4824
|
+
* pixel rect via sharp metadata). Null on any failure — caller falls back
|
|
4825
|
+
* to the uncropped still. */
|
|
4826
|
+
async zoneCrop(deviceId, zoneIds, jpeg) {
|
|
4827
|
+
try {
|
|
4828
|
+
const points = (await this.deps.getZonePolygons?.(deviceId, zoneIds) ?? []).flat();
|
|
4829
|
+
if (points.length === 0) return null;
|
|
4830
|
+
const { default: sharp } = await import("sharp");
|
|
4831
|
+
const img = sharp(Buffer.from(jpeg));
|
|
4832
|
+
const meta = await img.metadata();
|
|
4833
|
+
const W = meta.width ?? 0;
|
|
4834
|
+
const H = meta.height ?? 0;
|
|
4835
|
+
if (W === 0 || H === 0) return null;
|
|
4836
|
+
let minX = 1;
|
|
4837
|
+
let minY = 1;
|
|
4838
|
+
let maxX = 0;
|
|
4839
|
+
let maxY = 0;
|
|
4840
|
+
for (const p of points) {
|
|
4841
|
+
if (p.x < minX) minX = p.x;
|
|
4842
|
+
if (p.y < minY) minY = p.y;
|
|
4843
|
+
if (p.x > maxX) maxX = p.x;
|
|
4844
|
+
if (p.y > maxY) maxY = p.y;
|
|
4845
|
+
}
|
|
4846
|
+
if (maxX <= minX || maxY <= minY) return null;
|
|
4847
|
+
const padX = (maxX - minX) * .1;
|
|
4848
|
+
const padY = (maxY - minY) * .1;
|
|
4849
|
+
const left = Math.max(0, Math.floor((minX - padX) * W));
|
|
4850
|
+
const top = Math.max(0, Math.floor((minY - padY) * H));
|
|
4851
|
+
const width = Math.min(W - left, Math.ceil((maxX - minX + 2 * padX) * W));
|
|
4852
|
+
const height = Math.min(H - top, Math.ceil((maxY - minY + 2 * padY) * H));
|
|
4853
|
+
if (width < 16 || height < 16) return null;
|
|
4854
|
+
const outBuf = await img.extract({
|
|
4855
|
+
left,
|
|
4856
|
+
top,
|
|
4857
|
+
width,
|
|
4858
|
+
height
|
|
4859
|
+
}).jpeg({ quality: 82 }).toBuffer();
|
|
4860
|
+
const bytes = new Uint8Array(outBuf.byteLength);
|
|
4861
|
+
bytes.set(outBuf);
|
|
4862
|
+
return bytes;
|
|
4863
|
+
} catch (err) {
|
|
4864
|
+
this.deps.logger.debug("zone crop failed — attaching uncropped still", { meta: {
|
|
4865
|
+
deviceId,
|
|
4866
|
+
error: String(err)
|
|
4867
|
+
} });
|
|
4868
|
+
return null;
|
|
4869
|
+
}
|
|
4870
|
+
}
|
|
4871
|
+
async resolveAttachment(entry, policyOverride) {
|
|
4872
|
+
const policy = policyOverride ?? entry.payload.media;
|
|
4302
4873
|
if (policy === "none") return null;
|
|
4874
|
+
const frame = policyOverride === void 0 ? entry.payload.mediaFrame : void 0;
|
|
4303
4875
|
const subject = entry.payload.subject;
|
|
4304
4876
|
const signal = policy === "best-matching" ? matchSignal(entry.payload.matchedOn) : null;
|
|
4305
4877
|
const owners = [];
|
|
@@ -4311,11 +4883,11 @@ var NcDispatcher = class {
|
|
|
4311
4883
|
kind: "track",
|
|
4312
4884
|
id: subject.trackId
|
|
4313
4885
|
});
|
|
4314
|
-
if (policy === "keyFrame") owners.reverse();
|
|
4886
|
+
if (policy === "keyFrame" || frame === "full" || frame === "boxed") owners.reverse();
|
|
4315
4887
|
for (const owner of owners) try {
|
|
4316
4888
|
const files = await this.deps.getMediaForOwner(owner.kind, owner.id);
|
|
4317
4889
|
if (files.length === 0) continue;
|
|
4318
|
-
const preference = policy === "best-matching" ? bestMatchingKindPreference(signal, owner.kind) : attachmentKindPreference(policy, owner.kind);
|
|
4890
|
+
const preference = frame !== void 0 ? framePreference(frame, owner.kind) : policy === "best-matching" ? bestMatchingKindPreference(signal, owner.kind) : attachmentKindPreference(policy, owner.kind);
|
|
4319
4891
|
for (const kind of preference) {
|
|
4320
4892
|
const file = files.find((f) => f.kind === kind);
|
|
4321
4893
|
if (file === void 0) continue;
|
|
@@ -4340,15 +4912,37 @@ var NcDispatcher = class {
|
|
|
4340
4912
|
return null;
|
|
4341
4913
|
}
|
|
4342
4914
|
};
|
|
4343
|
-
|
|
4915
|
+
/**
|
|
4916
|
+
* Map admin zone IDs to their display names for rendering only.
|
|
4917
|
+
*
|
|
4918
|
+
* Order follows `zoneIds` (the order the track visited them), not the zone
|
|
4919
|
+
* catalog. Every failure mode degrades to the ID rather than dropping the
|
|
4920
|
+
* zone: an unknown id, a blank name, a throwing lookup, or no lookup wired at
|
|
4921
|
+
* all. A body that silently loses a zone is worse than one that shows a UUID.
|
|
4922
|
+
*/
|
|
4923
|
+
async function resolveZoneLabels(getZoneNames, deviceId, zoneIds) {
|
|
4924
|
+
if (zoneIds.length === 0) return [];
|
|
4925
|
+
if (getZoneNames === void 0) return [...zoneIds];
|
|
4926
|
+
try {
|
|
4927
|
+
const zones = await getZoneNames(deviceId);
|
|
4928
|
+
const byId = new Map(zones.map((z) => [z.id, z.name]));
|
|
4929
|
+
return zoneIds.map((id) => {
|
|
4930
|
+
const name = byId.get(id);
|
|
4931
|
+
return name !== void 0 && name.trim().length > 0 ? name : id;
|
|
4932
|
+
});
|
|
4933
|
+
} catch {
|
|
4934
|
+
return [...zoneIds];
|
|
4935
|
+
}
|
|
4936
|
+
}
|
|
4937
|
+
function buildTemplateVars(entry, deviceName, zoneLabels) {
|
|
4344
4938
|
const subject = entry.payload.subject;
|
|
4345
4939
|
const occupancy = subject.occupancy;
|
|
4346
4940
|
return {
|
|
4347
4941
|
camera: deviceName,
|
|
4348
4942
|
class: subject.className,
|
|
4349
4943
|
label: subject.label ?? "",
|
|
4350
|
-
zones:
|
|
4351
|
-
zone: occupancy?.zone ??
|
|
4944
|
+
zones: zoneLabels.join(", "),
|
|
4945
|
+
zone: occupancy?.zone ?? zoneLabels[0] ?? "",
|
|
4352
4946
|
confidence: subject.confidence !== void 0 ? `${Math.round(subject.confidence * 100)}%` : "",
|
|
4353
4947
|
time: new Date(subject.timestamp).toLocaleTimeString(),
|
|
4354
4948
|
rule: entry.payload.ruleName,
|
|
@@ -4366,12 +4960,12 @@ function renderTemplate(template, vars) {
|
|
|
4366
4960
|
if (template === void 0 || template.trim().length === 0) return null;
|
|
4367
4961
|
return template.replace(/\{\{\s*(\w+)\s*\}\}/g, (_m, name) => vars[name] ?? "");
|
|
4368
4962
|
}
|
|
4369
|
-
function defaultBody(entry, deviceName) {
|
|
4963
|
+
function defaultBody(entry, deviceName, zoneLabels) {
|
|
4370
4964
|
const subject = entry.payload.subject;
|
|
4371
4965
|
const occupancy = subject.occupancy;
|
|
4372
4966
|
if (occupancy !== void 0) return `${occupancy.zone ?? deviceName} ${occupancyOpWord(occupancy.occupied)} (${occupancy.count}/${occupancy.capacity})`;
|
|
4373
4967
|
const label = subject.label !== void 0 ? ` (${subject.label})` : "";
|
|
4374
|
-
const zones =
|
|
4968
|
+
const zones = zoneLabels.length > 0 ? ` in ${zoneLabels.join(", ")}` : "";
|
|
4375
4969
|
const suffix = entry.recordKind === "track-end" ? " — visit ended" : "";
|
|
4376
4970
|
return `${subject.className}${label} on ${deviceName}${zones}${suffix}`;
|
|
4377
4971
|
}
|
|
@@ -4637,7 +5231,7 @@ var NC_OCCUPANCY_INDEXES = [{
|
|
|
4637
5231
|
}];
|
|
4638
5232
|
/** Query cap — a per-(device, zone, class) key set is small; this is a
|
|
4639
5233
|
* generous ceiling that still bounds a pathological read. */
|
|
4640
|
-
var LOAD_LIMIT = 1e5;
|
|
5234
|
+
var LOAD_LIMIT$1 = 1e5;
|
|
4641
5235
|
var OccupancyStore = class {
|
|
4642
5236
|
cache = /* @__PURE__ */ new Map();
|
|
4643
5237
|
store;
|
|
@@ -4664,7 +5258,7 @@ var OccupancyStore = class {
|
|
|
4664
5258
|
try {
|
|
4665
5259
|
const records = await this.store.query.query({
|
|
4666
5260
|
collection: NC_OCCUPANCY_COLLECTION,
|
|
4667
|
-
filter: { limit: LOAD_LIMIT }
|
|
5261
|
+
filter: { limit: LOAD_LIMIT$1 }
|
|
4668
5262
|
});
|
|
4669
5263
|
this.cache.clear();
|
|
4670
5264
|
let skipped = 0;
|
|
@@ -5449,6 +6043,254 @@ var NcRuleStore = class {
|
|
|
5449
6043
|
}
|
|
5450
6044
|
};
|
|
5451
6045
|
//#endregion
|
|
6046
|
+
//#region src/notification-center/timelapse/timelapse-store.ts
|
|
6047
|
+
var NC_TIMELAPSE_RULES_COLLECTION = "notification-center:timelapse-rules";
|
|
6048
|
+
var NC_TIMELAPSE_RULES_COLUMNS = [
|
|
6049
|
+
{
|
|
6050
|
+
name: "id",
|
|
6051
|
+
type: "TEXT",
|
|
6052
|
+
primaryKey: true,
|
|
6053
|
+
notNull: true
|
|
6054
|
+
},
|
|
6055
|
+
{
|
|
6056
|
+
name: "name",
|
|
6057
|
+
type: "TEXT",
|
|
6058
|
+
notNull: true
|
|
6059
|
+
},
|
|
6060
|
+
{
|
|
6061
|
+
name: "enabled",
|
|
6062
|
+
type: "BOOLEAN",
|
|
6063
|
+
notNull: true
|
|
6064
|
+
},
|
|
6065
|
+
{
|
|
6066
|
+
name: "updatedAt",
|
|
6067
|
+
type: "INTEGER",
|
|
6068
|
+
notNull: true
|
|
6069
|
+
},
|
|
6070
|
+
(
|
|
6071
|
+
/** The FULL rule object (Zod-validated on read) — scalars above are
|
|
6072
|
+
* indexed projections only. */
|
|
6073
|
+
{
|
|
6074
|
+
name: "rule",
|
|
6075
|
+
type: "JSON",
|
|
6076
|
+
notNull: true
|
|
6077
|
+
})
|
|
6078
|
+
];
|
|
6079
|
+
var NC_TIMELAPSE_RULES_INDEXES = [{
|
|
6080
|
+
name: "idx_nc_timelapse_rules_enabled",
|
|
6081
|
+
columns: ["enabled"]
|
|
6082
|
+
}];
|
|
6083
|
+
/** Query cap — the rule set is operator-authored and tiny; a generous ceiling. */
|
|
6084
|
+
var LOAD_LIMIT = 1e4;
|
|
6085
|
+
/**
|
|
6086
|
+
* Resolve the three-way `template` patch signal onto a merged rule, immutably:
|
|
6087
|
+
* `undefined` (key absent) leaves it as-is, `null` DROPS the key, an object
|
|
6088
|
+
* replaces it. Keeping `null` out of the persisted rule is what lets
|
|
6089
|
+
* `TimelapseRuleSchema` stay a plain `.optional()`.
|
|
6090
|
+
*/
|
|
6091
|
+
function applyTemplatePatch(merged, template) {
|
|
6092
|
+
if (template === void 0) return merged;
|
|
6093
|
+
if (template !== null) return {
|
|
6094
|
+
...merged,
|
|
6095
|
+
template
|
|
6096
|
+
};
|
|
6097
|
+
const { template: _cleared, ...withoutTemplate } = merged;
|
|
6098
|
+
return withoutTemplate;
|
|
6099
|
+
}
|
|
6100
|
+
var TimelapseStore = class {
|
|
6101
|
+
byId = /* @__PURE__ */ new Map();
|
|
6102
|
+
store;
|
|
6103
|
+
logger;
|
|
6104
|
+
now;
|
|
6105
|
+
newId;
|
|
6106
|
+
constructor(deps) {
|
|
6107
|
+
this.store = deps.store;
|
|
6108
|
+
this.logger = deps.logger;
|
|
6109
|
+
this.now = deps.now ?? (() => Date.now());
|
|
6110
|
+
this.newId = deps.newId ?? (() => randomUUID());
|
|
6111
|
+
}
|
|
6112
|
+
static async declare(store) {
|
|
6113
|
+
await store.declareCollection.mutate({
|
|
6114
|
+
collection: NC_TIMELAPSE_RULES_COLLECTION,
|
|
6115
|
+
columns: [...NC_TIMELAPSE_RULES_COLUMNS],
|
|
6116
|
+
indexes: [...NC_TIMELAPSE_RULES_INDEXES]
|
|
6117
|
+
});
|
|
6118
|
+
}
|
|
6119
|
+
/**
|
|
6120
|
+
* (Re)hydrate the FULL rule set from the store — called at boot and on the
|
|
6121
|
+
* periodic refresh tick. Replaces the cache wholesale; a row whose JSON no
|
|
6122
|
+
* longer validates is skipped with a warning (a degraded rule must never
|
|
6123
|
+
* crash the scheduler).
|
|
6124
|
+
*/
|
|
6125
|
+
async load() {
|
|
6126
|
+
try {
|
|
6127
|
+
const rows = await this.store.query.query({
|
|
6128
|
+
collection: NC_TIMELAPSE_RULES_COLLECTION,
|
|
6129
|
+
filter: { limit: LOAD_LIMIT }
|
|
6130
|
+
});
|
|
6131
|
+
this.byId.clear();
|
|
6132
|
+
let skipped = 0;
|
|
6133
|
+
for (const row of rows) {
|
|
6134
|
+
const parsed = TimelapseRuleSchema.safeParse(row.data["rule"]);
|
|
6135
|
+
if (!parsed.success) {
|
|
6136
|
+
skipped += 1;
|
|
6137
|
+
continue;
|
|
6138
|
+
}
|
|
6139
|
+
this.byId.set(parsed.data.id, parsed.data);
|
|
6140
|
+
}
|
|
6141
|
+
this.logger.debug("timelapse rules loaded", { meta: {
|
|
6142
|
+
rules: this.byId.size,
|
|
6143
|
+
...skipped > 0 ? { skippedInvalid: skipped } : {}
|
|
6144
|
+
} });
|
|
6145
|
+
} catch (err) {
|
|
6146
|
+
this.logger.warn("timelapse rules load failed", { meta: { error: String(err) } });
|
|
6147
|
+
}
|
|
6148
|
+
}
|
|
6149
|
+
/** Every rule, newest-first (admin path). */
|
|
6150
|
+
list() {
|
|
6151
|
+
return [...this.byId.values()].sort((a, b) => b.updatedAt - a.updatedAt);
|
|
6152
|
+
}
|
|
6153
|
+
/** The scheduler's read: only rules that should be ticked. */
|
|
6154
|
+
listEnabled() {
|
|
6155
|
+
return this.list().filter((r) => r.enabled);
|
|
6156
|
+
}
|
|
6157
|
+
get(ruleId) {
|
|
6158
|
+
return this.byId.get(ruleId) ?? null;
|
|
6159
|
+
}
|
|
6160
|
+
/**
|
|
6161
|
+
* Rules visible to `userId`: their OWN personal rules (`ownerUserId ===
|
|
6162
|
+
* userId`) plus every admin/global rule (`ownerUserId` absent). Never another
|
|
6163
|
+
* user's personal rows. Newest-first (inherits {@link list}).
|
|
6164
|
+
*
|
|
6165
|
+
* The caller identity is server-derived; an absent/undefined caller must be
|
|
6166
|
+
* resolved to a fail-closed value by the bridge action BEFORE calling here —
|
|
6167
|
+
* this store never treats a missing caller as admin/global.
|
|
6168
|
+
*/
|
|
6169
|
+
listForOwner(userId) {
|
|
6170
|
+
return this.list().filter((r) => r.ownerUserId === void 0 || r.ownerUserId === userId);
|
|
6171
|
+
}
|
|
6172
|
+
/**
|
|
6173
|
+
* Mutation gate for a NON-admin caller: true only for a PERSONAL rule this
|
|
6174
|
+
* user owns. A global rule (no `ownerUserId`) returns false — global rules
|
|
6175
|
+
* are admin-only, and the bridge action grants admins the mutation without
|
|
6176
|
+
* consulting this check. An unknown rule id is false (fail-closed).
|
|
6177
|
+
*/
|
|
6178
|
+
isOwnedBy(ruleId, userId) {
|
|
6179
|
+
const rule = this.byId.get(ruleId);
|
|
6180
|
+
return rule?.ownerUserId !== void 0 && rule.ownerUserId === userId;
|
|
6181
|
+
}
|
|
6182
|
+
/**
|
|
6183
|
+
* Create a rule. `createdBy` is the SERVER-injected caller userId;
|
|
6184
|
+
* `ownerUserId` is the server-derived owner (omit for an admin/global rule).
|
|
6185
|
+
* Neither is ever read from `input`.
|
|
6186
|
+
*
|
|
6187
|
+
* The input is re-parsed through {@link TimelapseRuleInputSchema} BEFORE the
|
|
6188
|
+
* spread — that schema carries no ownership/provenance keys, so it strips any
|
|
6189
|
+
* that rode in on the blob. Without it, an `ownerUserId` on `input` would
|
|
6190
|
+
* survive whenever the `ownerUserId` ARGUMENT is omitted (the admin/global
|
|
6191
|
+
* path): a `TimelapseRule` is structurally assignable to `TimelapseRuleInput`,
|
|
6192
|
+
* so a future "duplicate rule" action (`create(existingRule, caller)`) would
|
|
6193
|
+
* compile cleanly and silently clone the ORIGINAL owner.
|
|
6194
|
+
*/
|
|
6195
|
+
async create(input, createdBy, ownerUserId) {
|
|
6196
|
+
const now = this.now();
|
|
6197
|
+
const rule = TimelapseRuleSchema.parse({
|
|
6198
|
+
...TimelapseRuleInputSchema.parse(input),
|
|
6199
|
+
id: this.newId(),
|
|
6200
|
+
...ownerUserId !== void 0 ? { ownerUserId } : {},
|
|
6201
|
+
createdBy,
|
|
6202
|
+
createdAt: now,
|
|
6203
|
+
updatedAt: now
|
|
6204
|
+
});
|
|
6205
|
+
await this.persist(rule);
|
|
6206
|
+
this.byId.set(rule.id, rule);
|
|
6207
|
+
return rule;
|
|
6208
|
+
}
|
|
6209
|
+
/**
|
|
6210
|
+
* Apply a partial patch. Immutable: returns the NEW rule object. Identity,
|
|
6211
|
+
* ownership and generation state are re-pinned from the existing rule AFTER
|
|
6212
|
+
* the spread — the patch schema carries none of them, and this makes a
|
|
6213
|
+
* hand-built (unparsed) patch object equally unable to re-own a rule.
|
|
6214
|
+
*
|
|
6215
|
+
* `template` is the one clearable field: an absent key leaves it unchanged,
|
|
6216
|
+
* an explicit `null` CLEARS it (the persisted rule loses the key — `null`
|
|
6217
|
+
* never reaches {@link TimelapseRuleSchema}). See the patch schema's wire
|
|
6218
|
+
* note.
|
|
6219
|
+
*/
|
|
6220
|
+
async update(ruleId, patch) {
|
|
6221
|
+
const existing = this.byId.get(ruleId);
|
|
6222
|
+
if (!existing) throw new Error(`timelapse rule not found: ${ruleId}`);
|
|
6223
|
+
const { template, ...rest } = patch;
|
|
6224
|
+
const merged = {
|
|
6225
|
+
...existing,
|
|
6226
|
+
...rest,
|
|
6227
|
+
id: existing.id,
|
|
6228
|
+
ownerUserId: existing.ownerUserId,
|
|
6229
|
+
lastGeneratedAt: existing.lastGeneratedAt,
|
|
6230
|
+
createdBy: existing.createdBy,
|
|
6231
|
+
createdAt: existing.createdAt,
|
|
6232
|
+
updatedAt: this.now()
|
|
6233
|
+
};
|
|
6234
|
+
return this.write(applyTemplatePatch(merged, template));
|
|
6235
|
+
}
|
|
6236
|
+
async setEnabled(ruleId, enabled) {
|
|
6237
|
+
return this.update(ruleId, { enabled });
|
|
6238
|
+
}
|
|
6239
|
+
/**
|
|
6240
|
+
* Record a successful generation. `at` is the generation epoch-ms — the
|
|
6241
|
+
* durable state behind the 1-hour re-generation guard. Does NOT bump
|
|
6242
|
+
* `updatedAt` (generation is not an edit of the rule definition).
|
|
6243
|
+
*/
|
|
6244
|
+
async markGenerated(ruleId, at) {
|
|
6245
|
+
const existing = this.byId.get(ruleId);
|
|
6246
|
+
if (!existing) throw new Error(`timelapse rule not found: ${ruleId}`);
|
|
6247
|
+
return this.write({
|
|
6248
|
+
...existing,
|
|
6249
|
+
lastGeneratedAt: at
|
|
6250
|
+
});
|
|
6251
|
+
}
|
|
6252
|
+
/** Idempotent delete — unknown ids are a no-op. */
|
|
6253
|
+
async delete(ruleId) {
|
|
6254
|
+
this.byId.delete(ruleId);
|
|
6255
|
+
try {
|
|
6256
|
+
await this.store.delete.mutate({
|
|
6257
|
+
collection: NC_TIMELAPSE_RULES_COLLECTION,
|
|
6258
|
+
key: ruleId
|
|
6259
|
+
});
|
|
6260
|
+
} catch (err) {
|
|
6261
|
+
this.logger.warn("timelapse rule delete failed", { meta: {
|
|
6262
|
+
ruleId,
|
|
6263
|
+
error: String(err)
|
|
6264
|
+
} });
|
|
6265
|
+
throw err instanceof Error ? err : new Error(String(err));
|
|
6266
|
+
}
|
|
6267
|
+
}
|
|
6268
|
+
/**
|
|
6269
|
+
* Re-validate a merged candidate, persist it, then cache it. Re-validating
|
|
6270
|
+
* means a patch can never persist a rule that would be skipped at the next
|
|
6271
|
+
* `load()`; provenance/ownership survive because they are spread from the
|
|
6272
|
+
* existing rule and never present on a patch.
|
|
6273
|
+
*/
|
|
6274
|
+
async write(candidate) {
|
|
6275
|
+
const rule = TimelapseRuleSchema.parse(candidate);
|
|
6276
|
+
await this.persist(rule);
|
|
6277
|
+
this.byId.set(rule.id, rule);
|
|
6278
|
+
return rule;
|
|
6279
|
+
}
|
|
6280
|
+
async persist(rule) {
|
|
6281
|
+
await this.store.set.mutate({
|
|
6282
|
+
collection: NC_TIMELAPSE_RULES_COLLECTION,
|
|
6283
|
+
key: rule.id,
|
|
6284
|
+
value: {
|
|
6285
|
+
name: rule.name,
|
|
6286
|
+
enabled: rule.enabled,
|
|
6287
|
+
updatedAt: rule.updatedAt,
|
|
6288
|
+
rule
|
|
6289
|
+
}
|
|
6290
|
+
});
|
|
6291
|
+
}
|
|
6292
|
+
};
|
|
6293
|
+
//#endregion
|
|
5452
6294
|
//#region src/notification-center/index.ts
|
|
5453
6295
|
var DEFAULT_DRAIN_INTERVAL_MS = 2e3;
|
|
5454
6296
|
var DEFAULT_RULE_RELOAD_INTERVAL_MS = 3e4;
|
|
@@ -5543,7 +6385,7 @@ function outboxEntryToHistory(entry) {
|
|
|
5543
6385
|
}
|
|
5544
6386
|
};
|
|
5545
6387
|
}
|
|
5546
|
-
var NotificationCenter = class {
|
|
6388
|
+
var NotificationCenter = class NotificationCenter {
|
|
5547
6389
|
logger;
|
|
5548
6390
|
rules;
|
|
5549
6391
|
outbox;
|
|
@@ -5605,11 +6447,19 @@ var NotificationCenter = class {
|
|
|
5605
6447
|
get ruleStore() {
|
|
5606
6448
|
return this.rules;
|
|
5607
6449
|
}
|
|
5608
|
-
/**
|
|
6450
|
+
/**
|
|
6451
|
+
* Declare every Notification Center collection (idempotent, boot-time).
|
|
6452
|
+
*
|
|
6453
|
+
* EVERY store the module can touch belongs here, including ones whose
|
|
6454
|
+
* feature is not wired yet: an UNDECLARED collection answers 412 on first
|
|
6455
|
+
* use and takes the whole runner down with it. The timelapse store shipped
|
|
6456
|
+
* without this line and was one enable away from doing exactly that.
|
|
6457
|
+
*/
|
|
5609
6458
|
static async declare(store) {
|
|
5610
6459
|
await NcRuleStore.declare(store);
|
|
5611
6460
|
await NcOutbox.declare(store);
|
|
5612
6461
|
await OccupancyStore.declare(store);
|
|
6462
|
+
await TimelapseStore.declare(store);
|
|
5613
6463
|
}
|
|
5614
6464
|
/**
|
|
5615
6465
|
* Load rules (every node — the cap provider serves CRUD from any node).
|
|
@@ -5794,6 +6644,7 @@ var NotificationCenter = class {
|
|
|
5794
6644
|
listRules: async () => ({ rules: [...this.rules.list()] }),
|
|
5795
6645
|
getRule: async ({ ruleId }) => ({ rule: this.rules.get(ruleId) }),
|
|
5796
6646
|
createRule: async ({ rule, caller }) => {
|
|
6647
|
+
NotificationCenter.assertHasAddressee(rule.targets, rule.targetUsers);
|
|
5797
6648
|
await this.validateTargetRefs(rule.targets.map((t) => t.targetId));
|
|
5798
6649
|
const created = await this.rules.create(rule, caller.userId);
|
|
5799
6650
|
this.logger.info("notification rule created", { meta: {
|
|
@@ -5805,6 +6656,10 @@ var NotificationCenter = class {
|
|
|
5805
6656
|
},
|
|
5806
6657
|
updateRule: async ({ ruleId, patch, caller }) => {
|
|
5807
6658
|
if (patch.targets !== void 0) await this.validateTargetRefs(patch.targets.map((t) => t.targetId));
|
|
6659
|
+
if (patch.targets !== void 0 || patch.targetUsers !== void 0) {
|
|
6660
|
+
const existing = this.rules.get(ruleId);
|
|
6661
|
+
NotificationCenter.assertHasAddressee(patch.targets ?? existing?.targets ?? [], patch.targetUsers ?? existing?.targetUsers);
|
|
6662
|
+
}
|
|
5808
6663
|
const { disabledTargetIds: _optOut, ...safePatch } = patch;
|
|
5809
6664
|
const updated = await this.rules.update(ruleId, safePatch);
|
|
5810
6665
|
this.logger.info("notification rule updated", { meta: {
|
|
@@ -5822,7 +6677,10 @@ var NotificationCenter = class {
|
|
|
5822
6677
|
return { success: true };
|
|
5823
6678
|
},
|
|
5824
6679
|
testRule: async ({ rule, lookbackMinutes }) => ({ results: [...await this.dryRun(rule, lookbackMinutes)] }),
|
|
5825
|
-
getConditionCatalog: async () => ({
|
|
6680
|
+
getConditionCatalog: async () => ({
|
|
6681
|
+
catalog: [...NC_CONDITION_CATALOG],
|
|
6682
|
+
taxonomy: NC_TAXONOMY
|
|
6683
|
+
}),
|
|
5826
6684
|
getHistory: async ({ filter }) => {
|
|
5827
6685
|
const entries = await this.outbox.queryHistory({
|
|
5828
6686
|
...filter.ruleId !== void 0 ? { ruleId: filter.ruleId } : {},
|
|
@@ -5863,26 +6721,84 @@ var NotificationCenter = class {
|
|
|
5863
6721
|
async evaluateAndEnqueue(subject, kind) {
|
|
5864
6722
|
const delivery = kind === "object-event" || kind === "audio-event" ? "immediate" : kind === "occupancy-event" ? "device-event" : kind;
|
|
5865
6723
|
const candidates = this.rules.listEnabled(delivery);
|
|
5866
|
-
if (candidates.length === 0)
|
|
6724
|
+
if (candidates.length === 0) {
|
|
6725
|
+
this.logger.debug("no enabled rule for this trigger", {
|
|
6726
|
+
tags: { deviceId: subject.deviceId },
|
|
6727
|
+
meta: {
|
|
6728
|
+
delivery,
|
|
6729
|
+
kind,
|
|
6730
|
+
rulesLoaded: this.rules.list().length
|
|
6731
|
+
}
|
|
6732
|
+
});
|
|
6733
|
+
return;
|
|
6734
|
+
}
|
|
5867
6735
|
const now = this.now();
|
|
5868
6736
|
for (const rule of candidates) {
|
|
5869
6737
|
const evaluation = evaluateRule(rule, subject);
|
|
5870
|
-
if (!evaluation.matched)
|
|
6738
|
+
if (!evaluation.matched) {
|
|
6739
|
+
this.logger.debug("rule did not match", {
|
|
6740
|
+
tags: { deviceId: subject.deviceId },
|
|
6741
|
+
meta: {
|
|
6742
|
+
ruleId: rule.id,
|
|
6743
|
+
rule: rule.name,
|
|
6744
|
+
kind,
|
|
6745
|
+
failed: evaluation.failedCondition,
|
|
6746
|
+
classes: subject.classNames,
|
|
6747
|
+
eventId: subject.recordId,
|
|
6748
|
+
...subject.trackId !== void 0 ? { trackId: subject.trackId } : {}
|
|
6749
|
+
}
|
|
6750
|
+
});
|
|
6751
|
+
continue;
|
|
6752
|
+
}
|
|
5871
6753
|
const key = cooldownKey(rule, subject);
|
|
5872
|
-
if (isCoolingDown(rule, this.lastFiredAt.get(key), now))
|
|
5873
|
-
|
|
6754
|
+
if (isCoolingDown(rule, this.lastFiredAt.get(key), now)) {
|
|
6755
|
+
this.logger.debug("rule matched but is cooling down", {
|
|
6756
|
+
tags: { deviceId: subject.deviceId },
|
|
6757
|
+
meta: {
|
|
6758
|
+
ruleId: rule.id,
|
|
6759
|
+
rule: rule.name,
|
|
6760
|
+
key,
|
|
6761
|
+
eventId: subject.recordId,
|
|
6762
|
+
...subject.trackId !== void 0 ? { trackId: subject.trackId } : {}
|
|
6763
|
+
}
|
|
6764
|
+
});
|
|
6765
|
+
continue;
|
|
6766
|
+
}
|
|
6767
|
+
this.logger.info("rule matched — enqueueing", {
|
|
6768
|
+
tags: { deviceId: subject.deviceId },
|
|
6769
|
+
meta: {
|
|
6770
|
+
ruleId: rule.id,
|
|
6771
|
+
rule: rule.name,
|
|
6772
|
+
kind,
|
|
6773
|
+
targets: rule.targets.length,
|
|
6774
|
+
eventId: subject.recordId,
|
|
6775
|
+
...subject.trackId !== void 0 ? { trackId: subject.trackId } : {},
|
|
6776
|
+
...subject.confidence !== void 0 ? { confidence: subject.confidence } : {},
|
|
6777
|
+
...subject.zones.length > 0 ? { zones: subject.zones } : {}
|
|
6778
|
+
}
|
|
6779
|
+
});
|
|
6780
|
+
const userTargets = await this.resolveUserTargets(rule, subject.deviceId);
|
|
6781
|
+
if (await this.outbox.enqueue(this.buildEntries(rule, subject, kind, evaluation.matchedOn, userTargets)) > 0) this.lastFiredAt.set(key, now);
|
|
5874
6782
|
}
|
|
5875
6783
|
}
|
|
5876
|
-
buildEntries(rule, subject, kind, matchedOn) {
|
|
6784
|
+
buildEntries(rule, subject, kind, matchedOn, userTargets = []) {
|
|
5877
6785
|
const hasEventMedia = kind === "object-event" || kind === "package-event";
|
|
5878
6786
|
const isTrackScoped = kind === "object-event" || kind === "track-end";
|
|
5879
|
-
|
|
6787
|
+
const direct = new Set(rule.targets.map((t) => t.targetId));
|
|
6788
|
+
return [...rule.targets, ...userTargets.filter((t) => !direct.has(t.targetId))].filter((target) => !rule.disabledTargetIds.includes(target.targetId)).map((target) => {
|
|
5880
6789
|
const payload = {
|
|
5881
6790
|
ruleName: rule.name,
|
|
5882
6791
|
delivery: rule.delivery,
|
|
5883
6792
|
priority: rule.priority,
|
|
5884
6793
|
...rule.template !== void 0 ? { template: rule.template } : {},
|
|
5885
6794
|
media: rule.media.attach,
|
|
6795
|
+
...rule.media.zoneCrop === true && rule.conditions.zones !== void 0 ? { mediaZoneIds: [...rule.conditions.zones.ids] } : {},
|
|
6796
|
+
...rule.media.frame !== void 0 ? { mediaFrame: rule.media.frame } : {},
|
|
6797
|
+
...rule.media.profile !== void 0 ? { mediaProfile: rule.media.profile } : {},
|
|
6798
|
+
...rule.media.gif === true ? { mediaGif: true } : {},
|
|
6799
|
+
...rule.media.clip === true ? { mediaClip: true } : {},
|
|
6800
|
+
...rule.media.clipPreRollSec !== void 0 ? { mediaClipPreRollSec: rule.media.clipPreRollSec } : {},
|
|
6801
|
+
...rule.media.clipPostRollSec !== void 0 ? { mediaClipPostRollSec: rule.media.clipPostRollSec } : {},
|
|
5886
6802
|
...matchedOn !== void 0 && matchedOn.length > 0 ? { matchedOn } : {},
|
|
5887
6803
|
...target.params !== void 0 ? { params: target.params } : {},
|
|
5888
6804
|
subject: {
|
|
@@ -5908,6 +6824,63 @@ var NotificationCenter = class {
|
|
|
5908
6824
|
};
|
|
5909
6825
|
});
|
|
5910
6826
|
}
|
|
6827
|
+
/** rule→user fan-out caches (60 s users/targets; device routes immutable). */
|
|
6828
|
+
userGrantsCache = null;
|
|
6829
|
+
userGrantsAt = 0;
|
|
6830
|
+
ownedTargetsCache = null;
|
|
6831
|
+
ownedTargetsAt = 0;
|
|
6832
|
+
deviceRouteCache = /* @__PURE__ */ new Map();
|
|
6833
|
+
userFanoutUnavailableLogged = false;
|
|
6834
|
+
/**
|
|
6835
|
+
* The personal targets a rule's `targetUsers` resolve to for THIS device:
|
|
6836
|
+
* each addressed user contributes the enabled targets they own — but only
|
|
6837
|
+
* when their `allowedDevices` grant covers the firing camera (admin users
|
|
6838
|
+
* always pass). A user must never be notified about a device they cannot
|
|
6839
|
+
* open.
|
|
6840
|
+
*/
|
|
6841
|
+
async resolveUserTargets(rule, deviceId) {
|
|
6842
|
+
const users = rule.targetUsers;
|
|
6843
|
+
if (users === void 0 || users.length === 0) return [];
|
|
6844
|
+
const { listUsers, getDeviceRoute } = this.deps;
|
|
6845
|
+
if (listUsers === void 0 || getDeviceRoute === void 0) {
|
|
6846
|
+
if (!this.userFanoutUnavailableLogged) {
|
|
6847
|
+
this.userFanoutUnavailableLogged = true;
|
|
6848
|
+
this.logger.warn("rule addresses users but the user fan-out deps are not wired", { meta: { ruleId: rule.id } });
|
|
6849
|
+
}
|
|
6850
|
+
return [];
|
|
6851
|
+
}
|
|
6852
|
+
const now = this.now();
|
|
6853
|
+
if (this.userGrantsCache === null || now - this.userGrantsAt > 6e4) {
|
|
6854
|
+
this.userGrantsCache = await listUsers();
|
|
6855
|
+
this.userGrantsAt = now;
|
|
6856
|
+
}
|
|
6857
|
+
if (this.ownedTargetsCache === null || now - this.ownedTargetsAt > 6e4) {
|
|
6858
|
+
const targets = await this.deps.dispatcher.listTargets();
|
|
6859
|
+
const owned = /* @__PURE__ */ new Map();
|
|
6860
|
+
for (const t of targets) {
|
|
6861
|
+
if (t.ownerUserId === void 0 || !t.enabled) continue;
|
|
6862
|
+
const list = owned.get(t.ownerUserId) ?? [];
|
|
6863
|
+
list.push(t.id);
|
|
6864
|
+
owned.set(t.ownerUserId, list);
|
|
6865
|
+
}
|
|
6866
|
+
this.ownedTargetsCache = owned;
|
|
6867
|
+
this.ownedTargetsAt = now;
|
|
6868
|
+
}
|
|
6869
|
+
if (!this.deviceRouteCache.has(deviceId)) this.deviceRouteCache.set(deviceId, await getDeviceRoute(deviceId));
|
|
6870
|
+
const route = this.deviceRouteCache.get(deviceId) ?? null;
|
|
6871
|
+
const out = [];
|
|
6872
|
+
for (const userId of users) {
|
|
6873
|
+
const user = this.userGrantsCache.find((u) => u.id === userId);
|
|
6874
|
+
if (user === void 0) continue;
|
|
6875
|
+
if (!user.isAdmin) {
|
|
6876
|
+
if (route === null) continue;
|
|
6877
|
+
const grant = user.allowedDevices[route.addonId];
|
|
6878
|
+
if (!(grant === "*" || Array.isArray(grant) && grant.includes(route.stableId))) continue;
|
|
6879
|
+
}
|
|
6880
|
+
for (const targetId of this.ownedTargetsCache.get(userId) ?? []) out.push({ targetId });
|
|
6881
|
+
}
|
|
6882
|
+
return out;
|
|
6883
|
+
}
|
|
5911
6884
|
/** Rebuild the cooldown map from persisted outbox rows (restart-proof). */
|
|
5912
6885
|
async seedCooldowns() {
|
|
5913
6886
|
const entries = await this.outbox.queryRecentPersisted(this.now() - 864e5);
|
|
@@ -6021,10 +6994,21 @@ var NotificationCenter = class {
|
|
|
6021
6994
|
this.drainTicks += 1;
|
|
6022
6995
|
if (this.drainTicks % WATERMARK_EVERY_TICKS === 0) await this.outbox.setWatermark(this.now());
|
|
6023
6996
|
}
|
|
6997
|
+
/** The "at least one addressee" invariant lives here, not in Zod: the
|
|
6998
|
+
* schema allows empty `targets` (a users-only rule) and a cross-field
|
|
6999
|
+
* refine would break `NcRulePatchSchema.partial()`. */
|
|
7000
|
+
static assertHasAddressee(targets, targetUsers) {
|
|
7001
|
+
if (targets.length === 0 && (targetUsers?.length ?? 0) === 0) throw new Error("a rule needs at least one delivery target or user");
|
|
7002
|
+
}
|
|
6024
7003
|
/** Rule-save referential check: every targetId must resolve in the
|
|
6025
7004
|
* live notification-output catalog (spec §2.3 save-time validation). */
|
|
6026
7005
|
async validateTargetRefs(targetIds) {
|
|
7006
|
+
if (targetIds.length === 0) return;
|
|
6027
7007
|
const targets = await this.deps.dispatcher.listTargets();
|
|
7008
|
+
if (targets.length === 0) {
|
|
7009
|
+
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] } });
|
|
7010
|
+
return;
|
|
7011
|
+
}
|
|
6028
7012
|
const known = new Set(targets.map((t) => t.id));
|
|
6029
7013
|
for (const id of targetIds) if (!known.has(id)) throw new Error(`unknown notification target: ${id}`);
|
|
6030
7014
|
}
|
|
@@ -6139,15 +7123,28 @@ function makeNcActionHandlers(deps) {
|
|
|
6139
7123
|
if (rule === null) throw new Error(`forbidden: rule not found: ${ruleId}`);
|
|
6140
7124
|
return rule;
|
|
6141
7125
|
};
|
|
6142
|
-
|
|
7126
|
+
/**
|
|
7127
|
+
* A rule the caller may EDIT. Ownership binds non-admins; an ADMIN
|
|
7128
|
+
* administers every rule — global ones (`ownerUserId: undefined`, what the
|
|
7129
|
+
* admin UI creates) and, for support, a user's personal rule.
|
|
7130
|
+
*
|
|
7131
|
+
* Without the admin bypass the comparison `rule.ownerUserId === userId` is
|
|
7132
|
+
* false for EVERY caller on a global rule, so the viewer refused to let an
|
|
7133
|
+
* admin edit an admin rule (operator report, 2026-07-30). Same bypass shape
|
|
7134
|
+
* as `assertTargetsOwned`.
|
|
7135
|
+
*/
|
|
7136
|
+
const assertRuleEditable = (ruleId, caller) => {
|
|
6143
7137
|
const rule = assertOwnsRule(ruleId);
|
|
6144
|
-
if (
|
|
7138
|
+
if (caller.isAdmin) return rule;
|
|
7139
|
+
if (rule.ownerUserId !== caller.userId) throw new Error(`forbidden: rule not owned: ${ruleId}`);
|
|
6145
7140
|
return rule;
|
|
6146
7141
|
};
|
|
6147
|
-
/** A rule the caller may SEE — his own personal rule
|
|
6148
|
-
|
|
7142
|
+
/** A rule the caller may SEE — his own personal rule, a global/admin rule, or
|
|
7143
|
+
* (for an admin) any rule at all. */
|
|
7144
|
+
const assertRuleVisible = (ruleId, caller) => {
|
|
6149
7145
|
const rule = assertOwnsRule(ruleId);
|
|
6150
|
-
if (
|
|
7146
|
+
if (caller.isAdmin) return rule;
|
|
7147
|
+
if (rule.ownerUserId !== void 0 && rule.ownerUserId !== caller.userId) throw new Error(`forbidden: rule not owned: ${ruleId}`);
|
|
6151
7148
|
return rule;
|
|
6152
7149
|
};
|
|
6153
7150
|
const assertTargetsOwned = async (targetIds, caller) => {
|
|
@@ -6158,9 +7155,9 @@ function makeNcActionHandlers(deps) {
|
|
|
6158
7155
|
return {
|
|
6159
7156
|
"nc.listRules": async (_input, caller) => {
|
|
6160
7157
|
const c = requireCaller(caller);
|
|
6161
|
-
return { rules: deps.ruleStore.listForOwner(c.userId).map((r) => ({
|
|
7158
|
+
return { rules: (c.isAdmin ? deps.ruleStore.list() : deps.ruleStore.listForOwner(c.userId)).map((r) => ({
|
|
6162
7159
|
...r,
|
|
6163
|
-
readOnly: r.ownerUserId !== c.userId
|
|
7160
|
+
readOnly: !c.isAdmin && r.ownerUserId !== c.userId
|
|
6164
7161
|
})) };
|
|
6165
7162
|
},
|
|
6166
7163
|
"nc.getConditionCatalog": async () => ({
|
|
@@ -6183,7 +7180,7 @@ function makeNcActionHandlers(deps) {
|
|
|
6183
7180
|
},
|
|
6184
7181
|
"nc.updateRule": async (input, caller) => {
|
|
6185
7182
|
const c = requireCaller(caller);
|
|
6186
|
-
|
|
7183
|
+
assertRuleEditable(input.ruleId, c);
|
|
6187
7184
|
const patch = NcRulePatchSchema.parse(input.patch);
|
|
6188
7185
|
if (patch.targets !== void 0) await assertTargetsOwned(patch.targets.map((t) => t.targetId), c);
|
|
6189
7186
|
const { ownerUserId: _owner, disabledTargetIds: _optOut, ...safe } = patch;
|
|
@@ -6196,7 +7193,7 @@ function makeNcActionHandlers(deps) {
|
|
|
6196
7193
|
},
|
|
6197
7194
|
"nc.deleteRule": async (input, caller) => {
|
|
6198
7195
|
const c = requireCaller(caller);
|
|
6199
|
-
|
|
7196
|
+
assertRuleEditable(input.ruleId, c);
|
|
6200
7197
|
await deps.ruleStore.delete(input.ruleId);
|
|
6201
7198
|
deps.logger.info("nc rule deleted", { meta: {
|
|
6202
7199
|
ruleId: input.ruleId,
|
|
@@ -6206,7 +7203,7 @@ function makeNcActionHandlers(deps) {
|
|
|
6206
7203
|
},
|
|
6207
7204
|
"nc.setRuleTargetEnabled": async (input, caller) => {
|
|
6208
7205
|
const c = requireCaller(caller);
|
|
6209
|
-
assertRuleVisible(input.ruleId, c
|
|
7206
|
+
assertRuleVisible(input.ruleId, c);
|
|
6210
7207
|
await assertTargetsOwned([input.targetId], c);
|
|
6211
7208
|
await deps.ruleStore.setRuleTargetEnabled(input.ruleId, input.targetId, input.enabled);
|
|
6212
7209
|
return { success: true };
|
|
@@ -8858,7 +9855,15 @@ var MEDIA_COLUMNS = [
|
|
|
8858
9855
|
name: "sizeBytes",
|
|
8859
9856
|
type: "INTEGER",
|
|
8860
9857
|
notNull: true
|
|
8861
|
-
}
|
|
9858
|
+
},
|
|
9859
|
+
(
|
|
9860
|
+
/** Storage location holding the blob. NULL = the default `eventMedia`
|
|
9861
|
+
* location (every pre-Phase-3 row, and every write until multi-location
|
|
9862
|
+
* events exist) — the relocate mover stamps real ids as it moves blobs. */
|
|
9863
|
+
{
|
|
9864
|
+
name: "locationId",
|
|
9865
|
+
type: "TEXT"
|
|
9866
|
+
})
|
|
8862
9867
|
];
|
|
8863
9868
|
var MEDIA_INDEXES = [{
|
|
8864
9869
|
name: "idx_media_owner",
|
|
@@ -8867,11 +9872,18 @@ var MEDIA_INDEXES = [{
|
|
|
8867
9872
|
name: "idx_media_device_ts",
|
|
8868
9873
|
columns: ["deviceId", "timestamp"]
|
|
8869
9874
|
}];
|
|
9875
|
+
/** The storage location of a media row/record: its stamped `locationId`, or
|
|
9876
|
+
* the default `eventMedia` location for NULL (pre-multi-location) rows. */
|
|
9877
|
+
var DEFAULT_MEDIA_LOCATION = "eventMedia";
|
|
9878
|
+
function mediaRowLocation(data) {
|
|
9879
|
+
const id = data?.["locationId"];
|
|
9880
|
+
return typeof id === "string" && id.length > 0 ? id : DEFAULT_MEDIA_LOCATION;
|
|
9881
|
+
}
|
|
8870
9882
|
function buildKey(params) {
|
|
8871
9883
|
return isSingleInstanceKind(params.kind) ? `${params.ownerKind}:${params.ownerId}:${params.kind}` : `${params.ownerKind}:${params.ownerId}:${params.kind}:${params.timestamp}`;
|
|
8872
9884
|
}
|
|
8873
9885
|
function buildPath(params) {
|
|
8874
|
-
const base =
|
|
9886
|
+
const base = `${params.deviceId}/events/${params.ownerKind}/${params.ownerId}`;
|
|
8875
9887
|
return isSingleInstanceKind(params.kind) ? `${base}/${params.kind}.jpg` : `${base}/${params.kind}-${params.timestamp}.jpg`;
|
|
8876
9888
|
}
|
|
8877
9889
|
var MediaStore = class {
|
|
@@ -8902,7 +9914,8 @@ var MediaStore = class {
|
|
|
8902
9914
|
kind: params.kind,
|
|
8903
9915
|
timestamp: params.timestamp,
|
|
8904
9916
|
path,
|
|
8905
|
-
sizeBytes: params.data.length
|
|
9917
|
+
sizeBytes: params.data.length,
|
|
9918
|
+
locationId: null
|
|
8906
9919
|
};
|
|
8907
9920
|
try {
|
|
8908
9921
|
await this.storage.write({
|
|
@@ -8959,10 +9972,11 @@ var MediaStore = class {
|
|
|
8959
9972
|
const newKey = await this.put(params);
|
|
8960
9973
|
for (const row of existing) {
|
|
8961
9974
|
if (row.id === newKey) continue;
|
|
8962
|
-
const
|
|
9975
|
+
const rowData = row.data;
|
|
9976
|
+
const path = String(rowData["path"] ?? "");
|
|
8963
9977
|
if (path) try {
|
|
8964
9978
|
await this.storage.delete({
|
|
8965
|
-
location:
|
|
9979
|
+
location: mediaRowLocation(rowData),
|
|
8966
9980
|
relativePath: path
|
|
8967
9981
|
});
|
|
8968
9982
|
} catch {}
|
|
@@ -9030,7 +10044,7 @@ var MediaStore = class {
|
|
|
9030
10044
|
key,
|
|
9031
10045
|
kind,
|
|
9032
10046
|
base64: (await this.storage.read({
|
|
9033
|
-
location:
|
|
10047
|
+
location: mediaRowLocation(data),
|
|
9034
10048
|
relativePath: path
|
|
9035
10049
|
})).toString("base64"),
|
|
9036
10050
|
sizeBytes,
|
|
@@ -9050,10 +10064,11 @@ var MediaStore = class {
|
|
|
9050
10064
|
key
|
|
9051
10065
|
});
|
|
9052
10066
|
if (!row) return;
|
|
9053
|
-
const
|
|
10067
|
+
const data = row;
|
|
10068
|
+
const path = String(data["path"] ?? "");
|
|
9054
10069
|
if (path) try {
|
|
9055
10070
|
await this.storage.delete({
|
|
9056
|
-
location:
|
|
10071
|
+
location: mediaRowLocation(data),
|
|
9057
10072
|
relativePath: path
|
|
9058
10073
|
});
|
|
9059
10074
|
} catch {}
|
|
@@ -9092,7 +10107,7 @@ var MediaStore = class {
|
|
|
9092
10107
|
const kind = String(data["kind"]);
|
|
9093
10108
|
try {
|
|
9094
10109
|
const buf = await this.storage.read({
|
|
9095
|
-
location:
|
|
10110
|
+
location: mediaRowLocation(data),
|
|
9096
10111
|
relativePath: path
|
|
9097
10112
|
});
|
|
9098
10113
|
files.push({
|
|
@@ -9127,10 +10142,11 @@ var MediaStore = class {
|
|
|
9127
10142
|
} }
|
|
9128
10143
|
});
|
|
9129
10144
|
for (const row of rows) {
|
|
9130
|
-
const
|
|
10145
|
+
const rowData = row.data;
|
|
10146
|
+
const path = String(rowData["path"] ?? "");
|
|
9131
10147
|
try {
|
|
9132
10148
|
if (path) await this.storage.delete({
|
|
9133
|
-
location:
|
|
10149
|
+
location: mediaRowLocation(rowData),
|
|
9134
10150
|
relativePath: path
|
|
9135
10151
|
});
|
|
9136
10152
|
} catch {}
|
|
@@ -9226,14 +10242,14 @@ var MediaStore = class {
|
|
|
9226
10242
|
kind,
|
|
9227
10243
|
timestamp,
|
|
9228
10244
|
data: await this.storage.read({
|
|
9229
|
-
location:
|
|
10245
|
+
location: mediaRowLocation(meta),
|
|
9230
10246
|
relativePath: oldPath
|
|
9231
10247
|
})
|
|
9232
10248
|
};
|
|
9233
10249
|
const newKey = await this.put(newParams);
|
|
9234
10250
|
try {
|
|
9235
10251
|
await this.storage.delete({
|
|
9236
|
-
location:
|
|
10252
|
+
location: mediaRowLocation(meta),
|
|
9237
10253
|
relativePath: oldPath
|
|
9238
10254
|
});
|
|
9239
10255
|
} catch (err) {
|
|
@@ -9303,10 +10319,11 @@ var MediaStore = class {
|
|
|
9303
10319
|
if (rows.length === 0) break;
|
|
9304
10320
|
let deletedInPage = 0;
|
|
9305
10321
|
for (const row of rows) {
|
|
9306
|
-
const
|
|
10322
|
+
const rowData = row.data;
|
|
10323
|
+
const path = String(rowData["path"] ?? "");
|
|
9307
10324
|
try {
|
|
9308
10325
|
if (path) await this.storage.delete({
|
|
9309
|
-
location:
|
|
10326
|
+
location: mediaRowLocation(rowData),
|
|
9310
10327
|
relativePath: path
|
|
9311
10328
|
});
|
|
9312
10329
|
} catch {}
|
|
@@ -9862,6 +10879,7 @@ var EventStore = class {
|
|
|
9862
10879
|
const rows = await this.store.query.query({
|
|
9863
10880
|
collection,
|
|
9864
10881
|
filter: {
|
|
10882
|
+
...params.deviceId !== void 0 ? { where: { deviceId: params.deviceId } } : {},
|
|
9865
10883
|
whereBetween: { timestamp: [0, cutoffMs] },
|
|
9866
10884
|
limit: 500
|
|
9867
10885
|
}
|
|
@@ -10262,6 +11280,183 @@ function stripNulls(data) {
|
|
|
10262
11280
|
return out;
|
|
10263
11281
|
}
|
|
10264
11282
|
//#endregion
|
|
11283
|
+
//#region src/pipeline-analytics/location-aware-media-storage.ts
|
|
11284
|
+
/**
|
|
11285
|
+
* Location-aware blob storage for event media (entity-routing spec, Phase 3).
|
|
11286
|
+
*
|
|
11287
|
+
* Media rows may carry a `locationId` (stamped by the relocate mover when a
|
|
11288
|
+
* blob is moved off the default location). The write-rate bypass provider
|
|
11289
|
+
* only knows the default media root — this wrapper routes any OTHER location
|
|
11290
|
+
* id through a resolver (the storage cap's `resolve`, cached forever: a
|
|
11291
|
+
* location's root only changes via operator reconfig, which restarts us) and
|
|
11292
|
+
* does direct fs I/O against that root, keeping the bypass's
|
|
11293
|
+
* no-RPC-per-blob property for every location.
|
|
11294
|
+
*/
|
|
11295
|
+
function createLocationAwareMediaStorage(deps) {
|
|
11296
|
+
const roots = /* @__PURE__ */ new Map();
|
|
11297
|
+
const rootOf = async (locationId) => {
|
|
11298
|
+
const cached = roots.get(locationId);
|
|
11299
|
+
if (cached !== void 0) return cached;
|
|
11300
|
+
const root = await deps.resolveRoot(locationId);
|
|
11301
|
+
roots.set(locationId, root);
|
|
11302
|
+
return root;
|
|
11303
|
+
};
|
|
11304
|
+
const absOf = async (locationId, relativePath) => path.join(await rootOf(locationId), relativePath);
|
|
11305
|
+
return {
|
|
11306
|
+
write: async (input) => {
|
|
11307
|
+
if (input.location === deps.defaultLocation) return deps.base.write(input);
|
|
11308
|
+
const abs = await absOf(input.location, input.relativePath);
|
|
11309
|
+
await promises.mkdir(path.dirname(abs), { recursive: true });
|
|
11310
|
+
await promises.writeFile(abs, input.data);
|
|
11311
|
+
},
|
|
11312
|
+
read: async (input) => {
|
|
11313
|
+
if (input.location === deps.defaultLocation) return deps.base.read(input);
|
|
11314
|
+
return promises.readFile(await absOf(input.location, input.relativePath));
|
|
11315
|
+
},
|
|
11316
|
+
delete: async (input) => {
|
|
11317
|
+
if (input.location === deps.defaultLocation) return deps.base.delete(input);
|
|
11318
|
+
await promises.rm(await absOf(input.location, input.relativePath), { force: true });
|
|
11319
|
+
}
|
|
11320
|
+
};
|
|
11321
|
+
}
|
|
11322
|
+
//#endregion
|
|
11323
|
+
//#region src/pipeline-analytics/media-relocate-engine.ts
|
|
11324
|
+
var PAGE_SIZE = 200;
|
|
11325
|
+
var DEFAULT_THROTTLE_MBPS = 40;
|
|
11326
|
+
function snapshot(j) {
|
|
11327
|
+
return {
|
|
11328
|
+
jobId: j.jobId,
|
|
11329
|
+
state: j.state,
|
|
11330
|
+
fromLocationId: j.fromLocationId,
|
|
11331
|
+
toLocationId: j.toLocationId,
|
|
11332
|
+
deviceId: j.deviceId,
|
|
11333
|
+
entities: ["media"],
|
|
11334
|
+
filesMoved: j.filesMoved,
|
|
11335
|
+
bytesMoved: j.bytesMoved,
|
|
11336
|
+
filesTotal: null,
|
|
11337
|
+
startedAt: j.startedAt,
|
|
11338
|
+
finishedAt: j.finishedAt,
|
|
11339
|
+
error: j.error
|
|
11340
|
+
};
|
|
11341
|
+
}
|
|
11342
|
+
var MediaRelocateEngine = class {
|
|
11343
|
+
deps;
|
|
11344
|
+
jobs = /* @__PURE__ */ new Map();
|
|
11345
|
+
constructor(deps) {
|
|
11346
|
+
this.deps = deps;
|
|
11347
|
+
}
|
|
11348
|
+
list() {
|
|
11349
|
+
return [...this.jobs.values()].sort((a, b) => b.startedAt - a.startedAt).map(snapshot);
|
|
11350
|
+
}
|
|
11351
|
+
cancel(jobId) {
|
|
11352
|
+
const job = this.jobs.get(jobId);
|
|
11353
|
+
if (!job || job.state !== "running") return false;
|
|
11354
|
+
job.cancelRequested = true;
|
|
11355
|
+
return true;
|
|
11356
|
+
}
|
|
11357
|
+
start(input) {
|
|
11358
|
+
for (const j of this.jobs.values()) if (j.state === "running") throw new Error(`a media relocation is already running (${j.jobId})`);
|
|
11359
|
+
const job = {
|
|
11360
|
+
jobId: this.deps.newId(),
|
|
11361
|
+
state: "running",
|
|
11362
|
+
fromLocationId: "*",
|
|
11363
|
+
toLocationId: input.toLocationId,
|
|
11364
|
+
deviceId: input.deviceId ?? null,
|
|
11365
|
+
filesMoved: 0,
|
|
11366
|
+
bytesMoved: 0,
|
|
11367
|
+
startedAt: this.deps.now(),
|
|
11368
|
+
finishedAt: null,
|
|
11369
|
+
error: null,
|
|
11370
|
+
cancelRequested: false
|
|
11371
|
+
};
|
|
11372
|
+
this.jobs.set(job.jobId, job);
|
|
11373
|
+
this.run(job, input.throttleMbps ?? DEFAULT_THROTTLE_MBPS);
|
|
11374
|
+
return job.jobId;
|
|
11375
|
+
}
|
|
11376
|
+
async run(job, throttleMbps) {
|
|
11377
|
+
const sleep = this.deps.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
|
|
11378
|
+
const bytesPerMs = throttleMbps * 1024 * 1024 / 1e3;
|
|
11379
|
+
try {
|
|
11380
|
+
await this.deps.resolveTargetRoot(job.toLocationId);
|
|
11381
|
+
let cursor = 0;
|
|
11382
|
+
let seenAtCursor = /* @__PURE__ */ new Set();
|
|
11383
|
+
for (;;) {
|
|
11384
|
+
if (job.cancelRequested) break;
|
|
11385
|
+
const fresh = (await this.deps.store.query.query({
|
|
11386
|
+
collection: MEDIA_COLLECTION,
|
|
11387
|
+
filter: {
|
|
11388
|
+
...job.deviceId !== null ? { where: { deviceId: job.deviceId } } : {},
|
|
11389
|
+
whereBetween: { timestamp: [cursor, Number.MAX_SAFE_INTEGER] },
|
|
11390
|
+
orderBy: {
|
|
11391
|
+
field: "timestamp",
|
|
11392
|
+
direction: "asc"
|
|
11393
|
+
},
|
|
11394
|
+
limit: PAGE_SIZE
|
|
11395
|
+
}
|
|
11396
|
+
})).filter((r) => !seenAtCursor.has(r.id));
|
|
11397
|
+
if (fresh.length === 0) break;
|
|
11398
|
+
for (const row of fresh) {
|
|
11399
|
+
if (job.cancelRequested) break;
|
|
11400
|
+
const data = row.data;
|
|
11401
|
+
const from = mediaRowLocation(data);
|
|
11402
|
+
if (from === job.toLocationId) continue;
|
|
11403
|
+
const relativePath = String(data["path"] ?? "");
|
|
11404
|
+
if (relativePath.length === 0) continue;
|
|
11405
|
+
try {
|
|
11406
|
+
const bytes = await this.deps.storage.read({
|
|
11407
|
+
location: from,
|
|
11408
|
+
relativePath
|
|
11409
|
+
});
|
|
11410
|
+
await this.deps.storage.write({
|
|
11411
|
+
location: job.toLocationId,
|
|
11412
|
+
relativePath,
|
|
11413
|
+
data: bytes
|
|
11414
|
+
});
|
|
11415
|
+
await this.deps.store.set.mutate({
|
|
11416
|
+
collection: MEDIA_COLLECTION,
|
|
11417
|
+
key: row.id,
|
|
11418
|
+
value: {
|
|
11419
|
+
...data,
|
|
11420
|
+
locationId: job.toLocationId
|
|
11421
|
+
}
|
|
11422
|
+
});
|
|
11423
|
+
await this.deps.storage.delete({
|
|
11424
|
+
location: from,
|
|
11425
|
+
relativePath
|
|
11426
|
+
});
|
|
11427
|
+
job.filesMoved++;
|
|
11428
|
+
job.bytesMoved += bytes.length;
|
|
11429
|
+
await sleep(bytes.length / bytesPerMs);
|
|
11430
|
+
} catch (err) {
|
|
11431
|
+
this.deps.logger.debug("media relocate row failed", { meta: {
|
|
11432
|
+
key: row.id,
|
|
11433
|
+
error: String(err)
|
|
11434
|
+
} });
|
|
11435
|
+
}
|
|
11436
|
+
}
|
|
11437
|
+
const last = fresh[fresh.length - 1];
|
|
11438
|
+
const lastTs = Number(last.data["timestamp"] ?? cursor);
|
|
11439
|
+
if (lastTs === cursor) for (const r of fresh) seenAtCursor.add(r.id);
|
|
11440
|
+
else {
|
|
11441
|
+
cursor = lastTs;
|
|
11442
|
+
seenAtCursor = new Set(fresh.filter((r) => Number(r.data["timestamp"]) === lastTs).map((r) => r.id));
|
|
11443
|
+
}
|
|
11444
|
+
}
|
|
11445
|
+
job.state = job.cancelRequested ? "cancelled" : "done";
|
|
11446
|
+
} catch (err) {
|
|
11447
|
+
job.state = "failed";
|
|
11448
|
+
job.error = err instanceof Error ? err.message : String(err);
|
|
11449
|
+
this.deps.logger.warn("media relocate job failed", { meta: {
|
|
11450
|
+
jobId: job.jobId,
|
|
11451
|
+
error: job.error
|
|
11452
|
+
} });
|
|
11453
|
+
} finally {
|
|
11454
|
+
job.finishedAt = this.deps.now();
|
|
11455
|
+
this.deps.onFinished?.(snapshot(job));
|
|
11456
|
+
}
|
|
11457
|
+
}
|
|
11458
|
+
};
|
|
11459
|
+
//#endregion
|
|
10265
11460
|
//#region src/pipeline-analytics/store/sensor-event-store.ts
|
|
10266
11461
|
var SENSOR_EVENTS_COLLECTION = "pipeline-analytics:sensor-events";
|
|
10267
11462
|
var SENSOR_EVENT_COLUMNS = [
|
|
@@ -11388,7 +12583,7 @@ var EventMediaDispatcher = class {
|
|
|
11388
12583
|
if (sn.rollingLastFrame && boxed) lastFrameWritten = await this.replaceKind(deviceId, sn.trackId, "lastFrame", sn.timestamp, boxed);
|
|
11389
12584
|
let thumbnailWritten = false;
|
|
11390
12585
|
if (sn.bestThumbnail) {
|
|
11391
|
-
const variants = await this.cropSubjectVariants(frameHandle, fw, fh, sn.bbox);
|
|
12586
|
+
const variants = await this.cropSubjectVariants(deviceId, frameHandle, fw, fh, sn.bbox);
|
|
11392
12587
|
if (variants) {
|
|
11393
12588
|
thumbnailWritten = await this.replaceKind(deviceId, sn.trackId, "thumbnail", sn.timestamp, variants.thumbnail);
|
|
11394
12589
|
await this.replaceKind(deviceId, sn.trackId, "thumbnailSmall", sn.timestamp, variants.thumbnailSmall);
|
|
@@ -11490,12 +12685,15 @@ var EventMediaDispatcher = class {
|
|
|
11490
12685
|
* per-frame retry lands a real native crop later. Never a local resize
|
|
11491
12686
|
* upscale of a ≤640 tile (a blurred lie).
|
|
11492
12687
|
*/
|
|
11493
|
-
async cropSubjectVariants(frameHandle, fw, fh, bbox) {
|
|
12688
|
+
async cropSubjectVariants(deviceId, frameHandle, fw, fh, bbox) {
|
|
11494
12689
|
if (!this.deps.getNativeCropJpeg) {
|
|
11495
|
-
this.deps.logger.warn("native subject crop miss — will retry, no upscale", {
|
|
11496
|
-
|
|
11497
|
-
|
|
11498
|
-
|
|
12690
|
+
this.deps.logger.warn("native subject crop miss — will retry, no upscale", {
|
|
12691
|
+
tags: { deviceId },
|
|
12692
|
+
meta: {
|
|
12693
|
+
shmId: frameHandle.shmId,
|
|
12694
|
+
reason: "no-native-cap"
|
|
12695
|
+
}
|
|
12696
|
+
});
|
|
11499
12697
|
return null;
|
|
11500
12698
|
}
|
|
11501
12699
|
try {
|
|
@@ -11503,12 +12701,19 @@ var EventMediaDispatcher = class {
|
|
|
11503
12701
|
W: fw,
|
|
11504
12702
|
H: fh
|
|
11505
12703
|
});
|
|
12704
|
+
const askedAt = Date.now();
|
|
11506
12705
|
const slab = await this.deps.getNativeCropJpeg(frameHandle, layout.fetch);
|
|
11507
12706
|
if (!slab) {
|
|
11508
|
-
this.deps.logger.warn("native subject crop miss — will retry, no upscale", {
|
|
11509
|
-
|
|
11510
|
-
|
|
11511
|
-
|
|
12707
|
+
this.deps.logger.warn("native subject crop miss — will retry, no upscale", {
|
|
12708
|
+
tags: { deviceId },
|
|
12709
|
+
meta: {
|
|
12710
|
+
shmId: frameHandle.shmId,
|
|
12711
|
+
handle: `${frameHandle.shmId}#${frameHandle.slot}#${frameHandle.seq}`,
|
|
12712
|
+
handleNodeId: frameHandle.nodeId,
|
|
12713
|
+
roundTripMs: Date.now() - askedAt,
|
|
12714
|
+
reason: "native-miss"
|
|
12715
|
+
}
|
|
12716
|
+
});
|
|
11512
12717
|
return null;
|
|
11513
12718
|
}
|
|
11514
12719
|
const thumbnail = await composeWideCentralSquareThumbnail(slab, layout);
|
|
@@ -11517,10 +12722,14 @@ var EventMediaDispatcher = class {
|
|
|
11517
12722
|
thumbnailSmall: await deriveThumbnailSmall(thumbnail)
|
|
11518
12723
|
};
|
|
11519
12724
|
} catch (err) {
|
|
11520
|
-
this.deps.logger.warn("native subject crop miss — will retry, no upscale", {
|
|
11521
|
-
|
|
11522
|
-
|
|
11523
|
-
|
|
12725
|
+
this.deps.logger.warn("native subject crop miss — will retry, no upscale", {
|
|
12726
|
+
tags: { deviceId },
|
|
12727
|
+
meta: {
|
|
12728
|
+
shmId: frameHandle.shmId,
|
|
12729
|
+
reason: "threw",
|
|
12730
|
+
error: err instanceof Error ? err.message : String(err)
|
|
12731
|
+
}
|
|
12732
|
+
});
|
|
11524
12733
|
return null;
|
|
11525
12734
|
}
|
|
11526
12735
|
}
|
|
@@ -12712,6 +13921,23 @@ function resolveDetectionSensitivitySettings(raw) {
|
|
|
12712
13921
|
};
|
|
12713
13922
|
}
|
|
12714
13923
|
var TrackingSettingsSchema = object({
|
|
13924
|
+
/**
|
|
13925
|
+
* How much of a detection's box must lie inside a zone (0-1 fraction of the
|
|
13926
|
+
* box's own area) for that zone to be STAMPED onto the detection.
|
|
13927
|
+
*
|
|
13928
|
+
* This is the field a zone-scoped notification rule ultimately depends on: a
|
|
13929
|
+
* rule's `zones` condition is a plain set test over the stamped zone ids, so
|
|
13930
|
+
* a subject that merely clips a zone edge satisfies it. Measured on
|
|
13931
|
+
* 2026-07-30: a dog overlapping `Aiuola` by 4.8% would have counted as
|
|
13932
|
+
* inside it.
|
|
13933
|
+
*
|
|
13934
|
+
* DEFAULT 0 — byte-identical to the behaviour before 2026-07-31. Raising it
|
|
13935
|
+
* is deliberately an operator decision: the overlap fractions are now logged
|
|
13936
|
+
* (`zone membership` lines), so the bar can be chosen from the distribution
|
|
13937
|
+
* instead of guessed. Distinct from a zone RULE's `bboxInclusionPct`, which
|
|
13938
|
+
* gates the DETECTION stage, not what gets stamped.
|
|
13939
|
+
*/
|
|
13940
|
+
zoneMembershipMinOverlap: number().min(0).max(1).default(0),
|
|
12715
13941
|
/** IoU required to match a (predicted) track to a detection. */
|
|
12716
13942
|
iouThreshold: number().min(0).max(1).default(.3),
|
|
12717
13943
|
/** Wall-clock coasting budget (ms) before a missed track is dropped. Time-
|
|
@@ -12848,6 +14074,7 @@ function resolveTrackingSettings(raw) {
|
|
|
12848
14074
|
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;
|
|
12849
14075
|
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;
|
|
12850
14076
|
return {
|
|
14077
|
+
zoneMembershipMinOverlap: s.zoneMembershipMinOverlap.catch(TRACKING_DEFAULTS.zoneMembershipMinOverlap).parse(raw.zoneMembershipMinOverlap),
|
|
12851
14078
|
iouThreshold: s.iouThreshold.catch(TRACKING_DEFAULTS.iouThreshold).parse(raw.iouThreshold),
|
|
12852
14079
|
maxMissedMs,
|
|
12853
14080
|
minTrackAgeMs: s.minTrackAgeMs.catch(TRACKING_DEFAULTS.minTrackAgeMs).parse(raw.minTrackAgeMs),
|
|
@@ -12902,7 +14129,11 @@ function resolveTrackingSettings(raw) {
|
|
|
12902
14129
|
* low-res detection frame (a static "person" phantom, a parked-truck ghost, a
|
|
12903
14130
|
* misclassified static object).
|
|
12904
14131
|
*
|
|
12905
|
-
*
|
|
14132
|
+
* ON by default (`enabled` defaults to TRUE). An earlier version of this line
|
|
14133
|
+
* said "Ships DORMANT: `enabled` defaults to `false`" — that was stale, and on
|
|
14134
|
+
* 2026-07-30 it nearly produced the conclusion that the gate was not running at
|
|
14135
|
+
* all. It is: it suppressed several phantom births on device 615 that same day.
|
|
14136
|
+
* Read the schema, not this paragraph. Historically the intent was byte-identical
|
|
12906
14137
|
* to today until an operator opts in per camera. The gate is fail-OPEN — any
|
|
12907
14138
|
* missing frame handle, unavailable inference cap, crop-fetch miss, re-detection
|
|
12908
14139
|
* error, or timeout ALLOWS the birth (a real track is never suppressed because
|
|
@@ -12998,10 +14229,11 @@ function isConfirmationCompatible(trackClassName, detectionMacroClass) {
|
|
|
12998
14229
|
if (track === "other" || det === "other") return true;
|
|
12999
14230
|
return track === det;
|
|
13000
14231
|
}
|
|
13001
|
-
var failOpen = (
|
|
13002
|
-
trackId,
|
|
14232
|
+
var failOpen = (candidate, reason) => ({
|
|
14233
|
+
trackId: candidate.trackId,
|
|
13003
14234
|
confirmed: true,
|
|
13004
|
-
reason
|
|
14235
|
+
reason,
|
|
14236
|
+
className: candidate.className
|
|
13005
14237
|
});
|
|
13006
14238
|
function withTimeout(promise, timeoutMs) {
|
|
13007
14239
|
return new Promise((resolve, reject) => {
|
|
@@ -13017,23 +14249,34 @@ function withTimeout(promise, timeoutMs) {
|
|
|
13017
14249
|
}
|
|
13018
14250
|
async function runConfirmation(candidate, config, deps) {
|
|
13019
14251
|
const crop = await deps.fetchCrop(candidate);
|
|
13020
|
-
if (!crop) return failOpen(candidate
|
|
14252
|
+
if (!crop) return failOpen(candidate, "no-crop");
|
|
13021
14253
|
const detections = await deps.redetect(crop);
|
|
13022
|
-
if (detections === null) return failOpen(candidate
|
|
13023
|
-
|
|
14254
|
+
if (detections === null) return failOpen(candidate, "redetect-error");
|
|
14255
|
+
let best;
|
|
14256
|
+
let bestIncompatible;
|
|
14257
|
+
for (const d of detections) if (isConfirmationCompatible(candidate.className, d.macroClass)) {
|
|
14258
|
+
if (!best || d.score > best.score) best = d;
|
|
14259
|
+
} else if (!bestIncompatible || d.score > bestIncompatible.score) bestIncompatible = d;
|
|
14260
|
+
const confirmed = best !== void 0 && best.score >= config.minConfidence;
|
|
13024
14261
|
return {
|
|
13025
14262
|
trackId: candidate.trackId,
|
|
13026
14263
|
confirmed,
|
|
13027
|
-
reason: confirmed ? "confirmed" : "suppressed"
|
|
14264
|
+
reason: confirmed ? "confirmed" : "suppressed",
|
|
14265
|
+
className: candidate.className,
|
|
14266
|
+
...best ? { bestScore: best.score } : {},
|
|
14267
|
+
...bestIncompatible ? {
|
|
14268
|
+
bestIncompatibleClass: bestIncompatible.macroClass,
|
|
14269
|
+
bestIncompatibleScore: bestIncompatible.score
|
|
14270
|
+
} : {}
|
|
13028
14271
|
};
|
|
13029
14272
|
}
|
|
13030
14273
|
async function confirmOne(candidate, config, deps) {
|
|
13031
14274
|
const cropPx = Math.max(candidate.bbox.w, candidate.bbox.h);
|
|
13032
|
-
if (config.minCropPx > 0 && cropPx < config.minCropPx) return failOpen(candidate
|
|
14275
|
+
if (config.minCropPx > 0 && cropPx < config.minCropPx) return failOpen(candidate, "below-min-crop");
|
|
13033
14276
|
try {
|
|
13034
14277
|
return await withTimeout(runConfirmation(candidate, config, deps), config.timeoutMs);
|
|
13035
14278
|
} catch {
|
|
13036
|
-
return failOpen(candidate
|
|
14279
|
+
return failOpen(candidate, "timeout");
|
|
13037
14280
|
}
|
|
13038
14281
|
}
|
|
13039
14282
|
/**
|
|
@@ -13216,8 +14459,23 @@ function resolveMediaSettings(raw) {
|
|
|
13216
14459
|
* §3.3 draft said 45s).
|
|
13217
14460
|
*/
|
|
13218
14461
|
var PackageDropSettingsSchema = object({
|
|
13219
|
-
/**
|
|
13220
|
-
|
|
14462
|
+
/**
|
|
14463
|
+
* Explicit per-camera OFF. **On by default: the ZONE RULE is what enables
|
|
14464
|
+
* package detection**, not a second switch.
|
|
14465
|
+
*
|
|
14466
|
+
* As an opt-in default-false this was a parallel source of truth. Device 615
|
|
14467
|
+
* on 2026-07-30 had the zone (`Uscio`), an enabled `package`-stage zone rule
|
|
14468
|
+
* (`Pacchetti`, classFilter `['package']`), and a notification rule on
|
|
14469
|
+
* `delivery: 'package-event'` — three layers of operator intent, all defeated
|
|
14470
|
+
* silently by a boolean none of them mentions.
|
|
14471
|
+
*
|
|
14472
|
+
* Defaulting to true costs nothing on cameras nobody configured:
|
|
14473
|
+
* `PackageDropDetector.onAppeared` still returns early when the device has no
|
|
14474
|
+
* enabled `package`-stage rule, so the work is a class check plus one cached
|
|
14475
|
+
* lookup. Set this false to force the feature off on a camera that HAS a zone
|
|
14476
|
+
* rule.
|
|
14477
|
+
*/
|
|
14478
|
+
packageDropEnabled: boolean().default(true),
|
|
13221
14479
|
/**
|
|
13222
14480
|
* Minimum OBSERVED dwell (seconds, since first-seen) before a newly
|
|
13223
14481
|
* promoted stationary package counts as a delivery. Kills a bag briefly
|
|
@@ -13581,37 +14839,7 @@ var AnalyticsQueryFacade = class {
|
|
|
13581
14839
|
}));
|
|
13582
14840
|
}
|
|
13583
14841
|
};
|
|
13584
|
-
|
|
13585
|
-
//#region src/pipeline-analytics/track-retention-sweep.ts
|
|
13586
|
-
/**
|
|
13587
|
-
* Periodic track retention sweep (design §6) — the piece that makes retention
|
|
13588
|
-
* actually BOUNDED. Without it, `pruneTracksBefore` only runs when something
|
|
13589
|
-
* calls it; this sweep ages persisted tracks out on the provider's existing
|
|
13590
|
-
* retention interval.
|
|
13591
|
-
*
|
|
13592
|
-
* `retentionMs` is a PER-DEVICE setting (`trackRetentionDays`, default 7 days;
|
|
13593
|
-
* `0` = keep forever → the device is skipped). Enrolled gallery is exempt by the
|
|
13594
|
-
* cascade store layer (design §4), not here.
|
|
13595
|
-
*
|
|
13596
|
-
* Kept as a pure, dependency-injected function so the loop is unit-testable
|
|
13597
|
-
* without booting the addon.
|
|
13598
|
-
*/
|
|
13599
|
-
var DAY_MS = 1440 * 60 * 1e3;
|
|
13600
|
-
/** Per-device track retention setting. Default 7 days; 0 = keep forever. */
|
|
13601
|
-
var TrackRetentionSettingsSchema = object({ trackRetentionDays: number().min(0).default(7) });
|
|
13602
|
-
var TRACK_RETENTION_DEFAULT_DAYS = TrackRetentionSettingsSchema.parse({}).trackRetentionDays;
|
|
13603
|
-
/** Resolve `trackRetentionDays` from a raw per-device store blob — an invalid or
|
|
13604
|
-
* missing value falls back to the default (parse never throws). */
|
|
13605
|
-
function resolveTrackRetentionDays(raw) {
|
|
13606
|
-
const parsed = TrackRetentionSettingsSchema.shape.trackRetentionDays.safeParse(raw["trackRetentionDays"]);
|
|
13607
|
-
return parsed.success ? parsed.data : TRACK_RETENTION_DEFAULT_DAYS;
|
|
13608
|
-
}
|
|
13609
|
-
/** Cutoff timestamp for a retention window. `null` when retention is disabled
|
|
13610
|
-
* (`retentionDays <= 0` → keep forever, the sweep skips the device). */
|
|
13611
|
-
function trackRetentionCutoff(nowMs, retentionDays) {
|
|
13612
|
-
if (retentionDays <= 0) return null;
|
|
13613
|
-
return nowMs - retentionDays * DAY_MS;
|
|
13614
|
-
}
|
|
14842
|
+
var TRACK_RETENTION_DEFAULT_DAYS = object({ trackRetentionDays: number().min(0).default(7) }).parse({}).trackRetentionDays;
|
|
13615
14843
|
/**
|
|
13616
14844
|
* Sweep every device with persisted tracks: skip retention-disabled devices,
|
|
13617
14845
|
* prune the rest at `now − retentionMs`. Per-device ISOLATED — one bad device
|
|
@@ -13622,7 +14850,7 @@ async function sweepTrackRetention(deps) {
|
|
|
13622
14850
|
const nowMs = deps.now();
|
|
13623
14851
|
let totalTracks = 0;
|
|
13624
14852
|
for (const deviceId of devices) try {
|
|
13625
|
-
const cutoffMs =
|
|
14853
|
+
const cutoffMs = await deps.resolveCutoffMs(deviceId, nowMs);
|
|
13626
14854
|
if (cutoffMs === null) continue;
|
|
13627
14855
|
const counts = await deps.pruneTracksBefore(deviceId, cutoffMs);
|
|
13628
14856
|
totalTracks += counts.tracks;
|
|
@@ -14187,6 +15415,19 @@ function retagDetectionSections(sections) {
|
|
|
14187
15415
|
} : s);
|
|
14188
15416
|
}
|
|
14189
15417
|
/**
|
|
15418
|
+
* Re-home the analytics `retention` section onto the recorder's `recording`
|
|
15419
|
+
* top-tab (operator ask, 2026-07-29): footage and analytics retention are ONE
|
|
15420
|
+
* unified policy since the follow-recordings default, so their controls
|
|
15421
|
+
* belong on ONE tab. Same pure-retag mechanism as the detection sections —
|
|
15422
|
+
* `DeviceDetail` folds matching-tab sections together, no admin-ui change.
|
|
15423
|
+
*/
|
|
15424
|
+
function retagRetentionSection(sections) {
|
|
15425
|
+
return sections.map((s) => s.id === "retention" ? {
|
|
15426
|
+
...s,
|
|
15427
|
+
tab: "recording"
|
|
15428
|
+
} : s);
|
|
15429
|
+
}
|
|
15430
|
+
/**
|
|
14190
15431
|
* Fields that live ONLY on the global settings page and must never surface in a
|
|
14191
15432
|
* per-device contribution. The face-recognition `enabled` switch is the GLOBAL
|
|
14192
15433
|
* master kill for the whole subsystem — per-camera face production is governed
|
|
@@ -14365,9 +15606,23 @@ function buildGlobalSettingsSchema() {
|
|
|
14365
15606
|
{
|
|
14366
15607
|
id: "retention",
|
|
14367
15608
|
title: "Retention",
|
|
14368
|
-
description: "How long analytics history is kept
|
|
15609
|
+
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.",
|
|
14369
15610
|
columns: 3,
|
|
14370
15611
|
fields: [
|
|
15612
|
+
{
|
|
15613
|
+
type: "select",
|
|
15614
|
+
key: "retentionMode",
|
|
15615
|
+
label: "Mode",
|
|
15616
|
+
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.",
|
|
15617
|
+
default: "follow-recordings",
|
|
15618
|
+
options: [{
|
|
15619
|
+
value: "follow-recordings",
|
|
15620
|
+
label: "Follow recordings (default)"
|
|
15621
|
+
}, {
|
|
15622
|
+
value: "custom",
|
|
15623
|
+
label: "Custom windows"
|
|
15624
|
+
}]
|
|
15625
|
+
},
|
|
14371
15626
|
{
|
|
14372
15627
|
type: "number",
|
|
14373
15628
|
key: "trackRetentionDays",
|
|
@@ -14902,6 +16157,86 @@ async function encodeKeyFrameVariants(native) {
|
|
|
14902
16157
|
};
|
|
14903
16158
|
}
|
|
14904
16159
|
//#endregion
|
|
16160
|
+
//#region src/pipeline-analytics/retention-policy.ts
|
|
16161
|
+
/**
|
|
16162
|
+
* Unified analytics retention policy (storage entity-routing spec, Phase 1).
|
|
16163
|
+
*
|
|
16164
|
+
* Two modes, per device:
|
|
16165
|
+
*
|
|
16166
|
+
* - `follow-recordings` (the DEFAULT): tracks and events live exactly as long
|
|
16167
|
+
* as the camera's retained footage — ONE cutoff, the instant of the oldest
|
|
16168
|
+
* segment still on disk. That follows the recorder's age retention AND its
|
|
16169
|
+
* disk-pressure eviction automatically, with no second policy to keep in
|
|
16170
|
+
* sync. A camera with no footage at all (recording off / brand new) falls
|
|
16171
|
+
* back to the custom day windows below, so analytics stay bounded either
|
|
16172
|
+
* way.
|
|
16173
|
+
*
|
|
16174
|
+
* - `custom`: the per-kind day windows (tracks / motion / object / audio),
|
|
16175
|
+
* exactly the pre-unification behaviour — except the knobs are now actually
|
|
16176
|
+
* READ (they were exposed in settings and silently ignored by the sweep,
|
|
16177
|
+
* which used hardcoded 14/30/7).
|
|
16178
|
+
*
|
|
16179
|
+
* Follow-mode floor: events younger than {@link FOLLOW_FLOOR_DAYS} are never
|
|
16180
|
+
* pruned, even when footage is younger (a camera that STARTED recording
|
|
16181
|
+
* yesterday must not nuke its whole event history back to the first segment).
|
|
16182
|
+
*/
|
|
16183
|
+
var DAY_MS = 1440 * 60 * 1e3;
|
|
16184
|
+
/** Per-device analytics retention settings as stored in the device blob.
|
|
16185
|
+
* `retentionMode` absent = follow-recordings (the new default). The day
|
|
16186
|
+
* windows keep their historical defaults and double as the no-footage
|
|
16187
|
+
* fallback in follow mode. */
|
|
16188
|
+
var RetentionSettingsSchema = object({
|
|
16189
|
+
retentionMode: _enum(["follow-recordings", "custom"]).default("follow-recordings"),
|
|
16190
|
+
trackRetentionDays: number().min(0).default(7),
|
|
16191
|
+
retentionMotionDays: number().min(1).default(14),
|
|
16192
|
+
retentionObjectDays: number().min(1).default(30),
|
|
16193
|
+
retentionAudioDays: number().min(1).default(7)
|
|
16194
|
+
});
|
|
16195
|
+
/** Resolve the settings from a raw device-store blob — invalid or missing
|
|
16196
|
+
* fields fall back to defaults, never throw. */
|
|
16197
|
+
function resolveRetentionSettings(raw) {
|
|
16198
|
+
const parsed = RetentionSettingsSchema.safeParse(raw);
|
|
16199
|
+
if (parsed.success) return parsed.data;
|
|
16200
|
+
const shape = RetentionSettingsSchema.shape;
|
|
16201
|
+
const field = (k) => shape[k].safeParse(raw[k]).success ? shape[k].parse(raw[k]) : RetentionSettingsSchema.parse({})[k];
|
|
16202
|
+
return {
|
|
16203
|
+
retentionMode: field("retentionMode"),
|
|
16204
|
+
trackRetentionDays: field("trackRetentionDays"),
|
|
16205
|
+
retentionMotionDays: field("retentionMotionDays"),
|
|
16206
|
+
retentionObjectDays: field("retentionObjectDays"),
|
|
16207
|
+
retentionAudioDays: field("retentionAudioDays")
|
|
16208
|
+
};
|
|
16209
|
+
}
|
|
16210
|
+
/**
|
|
16211
|
+
* Follow-mode cutoff: the oldest retained footage instant, floored so
|
|
16212
|
+
* anything younger than {@link FOLLOW_FLOOR_DAYS} survives. `null` footage
|
|
16213
|
+
* (none on disk) → `null`, the caller falls back to custom windows.
|
|
16214
|
+
*/
|
|
16215
|
+
function followCutoffMs(nowMs, earliestFootageMs) {
|
|
16216
|
+
if (earliestFootageMs === null) return null;
|
|
16217
|
+
return Math.min(earliestFootageMs, nowMs - 7 * DAY_MS);
|
|
16218
|
+
}
|
|
16219
|
+
/** Compute the effective cutoffs for one device at `nowMs`. */
|
|
16220
|
+
function resolveRetentionCutoffs(settings, nowMs, earliestFootageMs) {
|
|
16221
|
+
if (settings.retentionMode === "follow-recordings") {
|
|
16222
|
+
const cutoff = followCutoffMs(nowMs, earliestFootageMs);
|
|
16223
|
+
if (cutoff !== null) return {
|
|
16224
|
+
trackCutoffMs: cutoff,
|
|
16225
|
+
motionCutoffMs: cutoff,
|
|
16226
|
+
objectCutoffMs: cutoff,
|
|
16227
|
+
audioCutoffMs: cutoff,
|
|
16228
|
+
effectiveMode: "follow-recordings"
|
|
16229
|
+
};
|
|
16230
|
+
}
|
|
16231
|
+
return {
|
|
16232
|
+
trackCutoffMs: settings.trackRetentionDays <= 0 ? null : nowMs - settings.trackRetentionDays * DAY_MS,
|
|
16233
|
+
motionCutoffMs: nowMs - settings.retentionMotionDays * DAY_MS,
|
|
16234
|
+
objectCutoffMs: nowMs - settings.retentionObjectDays * DAY_MS,
|
|
16235
|
+
audioCutoffMs: nowMs - settings.retentionAudioDays * DAY_MS,
|
|
16236
|
+
effectiveMode: "custom"
|
|
16237
|
+
};
|
|
16238
|
+
}
|
|
16239
|
+
//#endregion
|
|
14905
16240
|
//#region src/pipeline-analytics/store/identity-store.ts
|
|
14906
16241
|
/**
|
|
14907
16242
|
* IdentityStore — per-person identity registry for face recognition.
|
|
@@ -18288,6 +19623,17 @@ var KEY_EVENT_DEFAULT_LIMIT = 50;
|
|
|
18288
19623
|
* Absent / empty / non-string all fall back to the hub default — the exact
|
|
18289
19624
|
* narrowing the old raw read applied inline. */
|
|
18290
19625
|
var PostProcessingNodeIdSchema = string().min(1);
|
|
19626
|
+
/**
|
|
19627
|
+
* Footage-attachment window + geometry, used when the rule states none. The
|
|
19628
|
+
* window is CENTRED on the event, so the recipient sees the approach and what
|
|
19629
|
+
* followed rather than one side of it.
|
|
19630
|
+
*/
|
|
19631
|
+
var NC_FOOTAGE_PRE_ROLL_SEC = 3;
|
|
19632
|
+
var NC_FOOTAGE_POST_ROLL_SEC = 5;
|
|
19633
|
+
var NC_FOOTAGE_MAX_WIDTH = 480;
|
|
19634
|
+
var NC_FOOTAGE_FPS = 5;
|
|
19635
|
+
/** Per-install HMAC secret behind the signed artifact links (minted once). */
|
|
19636
|
+
var NcArtifactSecretSchema = string();
|
|
18291
19637
|
var EmbeddingEnabledSchema = boolean();
|
|
18292
19638
|
var SILENCE_FLOOR_DBFS = -55;
|
|
18293
19639
|
var RETENTION_SWEEP_INTERVAL_MS = 5 * 6e4;
|
|
@@ -18348,7 +19694,21 @@ function decodeEmbeddingBase64(base64) {
|
|
|
18348
19694
|
const view = new Float32Array(bytes.buffer, bytes.byteOffset, Math.floor(bytes.byteLength / 4));
|
|
18349
19695
|
return Array.from(view);
|
|
18350
19696
|
}
|
|
19697
|
+
/** A track shorter than this is a candidate phantom, not a subject that came
|
|
19698
|
+
* and went. Brackets the observed plant tracks (3.8-19.0 s). */
|
|
19699
|
+
var SHORT_TRACK_MAX_MS = 25e3;
|
|
19700
|
+
/** Total displacement under this is "did not move at all" (observed: 0-2.5 px). */
|
|
19701
|
+
var MOTIONLESS_MAX_PX = 8;
|
|
19702
|
+
/** Grid the spawn point is quantised to, so respawns whose boxes never repeat
|
|
19703
|
+
* to the pixel still land in one cell. */
|
|
19704
|
+
var PHANTOM_CELL_PX = 32;
|
|
19705
|
+
/** How long a cell remembers its closes. */
|
|
19706
|
+
var PHANTOM_CELL_WINDOW_MS = 360 * 6e4;
|
|
18351
19707
|
var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
19708
|
+
/** Recent SHORT+MOTIONLESS track closes per `<device>:<class>:<cell>` —
|
|
19709
|
+
* see {@link noteShortMotionlessTrack}. Measurement only; each entry is
|
|
19710
|
+
* filtered against the 6-hour window on write, so it stays bounded. */
|
|
19711
|
+
shortMotionlessCells = /* @__PURE__ */ new Map();
|
|
18352
19712
|
processors = /* @__PURE__ */ new Map();
|
|
18353
19713
|
trackStore = null;
|
|
18354
19714
|
/** Parked-object registry: promotes a track that stopped moving into a
|
|
@@ -18359,6 +19719,8 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
18359
19719
|
eventStore = null;
|
|
18360
19720
|
/** Events-domain ops-log (footprint/prune/manual-delete audit). Null until onInitialize. */
|
|
18361
19721
|
eventsOpsLog = null;
|
|
19722
|
+
/** Event-media relocation engine (entity-routing Phase 4). */
|
|
19723
|
+
mediaRelocate = null;
|
|
18362
19724
|
/** Per-camera history of LINKED-device sensor state changes (Part B). */
|
|
18363
19725
|
sensorEventStore = null;
|
|
18364
19726
|
/** Ingest-side reverse index (sensor device → linked camera ids), TTL-cached
|
|
@@ -18417,6 +19779,88 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
18417
19779
|
retentionSweepTimer = null;
|
|
18418
19780
|
/** Handle for the event-media data-plane listener (dispose on shutdown). */
|
|
18419
19781
|
eventMediaDataPlane = null;
|
|
19782
|
+
/** The NC artifact plane (signed public links for notification media) and its
|
|
19783
|
+
* data-plane handle. Null until served / when the facility is absent. */
|
|
19784
|
+
ncArtifactPlane = null;
|
|
19785
|
+
ncArtifactDataPlane = null;
|
|
19786
|
+
/** The operator's marked notification endpoint, or null (AUTO / unavailable). */
|
|
19787
|
+
async markedNotificationEndpoint() {
|
|
19788
|
+
try {
|
|
19789
|
+
return (await this.ctx.api.localNetwork.getNotificationEndpoint.query()).baseUrl ?? void 0;
|
|
19790
|
+
} catch {
|
|
19791
|
+
return;
|
|
19792
|
+
}
|
|
19793
|
+
}
|
|
19794
|
+
/**
|
|
19795
|
+
* Serve the NC artifact plane: a PUBLIC route whose authority is the HMAC on
|
|
19796
|
+
* each link (see `notification-center/artifact-url.ts`). It exists because
|
|
19797
|
+
* attachments used to carry BYTES only, and the degrade engine drops a
|
|
19798
|
+
* bytes-only attachment for a url-mode backend — WhatsApp and gotify were
|
|
19799
|
+
* receiving no media at all.
|
|
19800
|
+
*
|
|
19801
|
+
* Best-effort at every step: no facility, no secret or no reachable base URL
|
|
19802
|
+
* ⇒ no plane, and the dispatcher keeps shipping bytes exactly as before.
|
|
19803
|
+
*/
|
|
19804
|
+
async serveNcArtifactPlane() {
|
|
19805
|
+
try {
|
|
19806
|
+
const secretState = this.state("ncArtifactSecret", NcArtifactSecretSchema, "");
|
|
19807
|
+
let secret = await secretState.get();
|
|
19808
|
+
if (secret === "") {
|
|
19809
|
+
secret = randomUUID().replace(/-/g, "");
|
|
19810
|
+
await secretState.set(secret);
|
|
19811
|
+
}
|
|
19812
|
+
const store = new NcArtifactStore({
|
|
19813
|
+
dir: path.join(this.ctx.dataDir, "nc-artifacts"),
|
|
19814
|
+
logger: this.ctx.logger.child("nc-artifacts")
|
|
19815
|
+
});
|
|
19816
|
+
await store.start();
|
|
19817
|
+
const plane = new NcArtifactPlane({
|
|
19818
|
+
store,
|
|
19819
|
+
secret,
|
|
19820
|
+
logger: this.ctx.logger.child("nc-artifacts"),
|
|
19821
|
+
routePrefix: `/addon/${this.ctx.id}/nc-artifact`,
|
|
19822
|
+
listEndpoints: async () => collectArtifactEndpoints({
|
|
19823
|
+
markedBaseUrl: await this.markedNotificationEndpoint(),
|
|
19824
|
+
configuredPublicUrl: process.env["CAMSTACK_HUB_PUBLIC_URL"],
|
|
19825
|
+
getConnected: async () => {
|
|
19826
|
+
const status = await this.ctx.api.networkAccess.getStatus.query();
|
|
19827
|
+
this.ctx.logger.debug("artifact base-url: connected ingress", { meta: {
|
|
19828
|
+
connected: status.connected,
|
|
19829
|
+
url: status.endpoint?.url ?? null,
|
|
19830
|
+
protocol: status.endpoint?.protocol ?? null
|
|
19831
|
+
} });
|
|
19832
|
+
return status.connected && status.endpoint !== null ? {
|
|
19833
|
+
url: status.endpoint.url,
|
|
19834
|
+
protocol: status.endpoint.protocol
|
|
19835
|
+
} : null;
|
|
19836
|
+
},
|
|
19837
|
+
listExternal: async () => {
|
|
19838
|
+
return (await this.ctx.api.networkAccess.listEndpoints.query()).map((e) => ({
|
|
19839
|
+
url: e.url,
|
|
19840
|
+
protocol: e.protocol
|
|
19841
|
+
}));
|
|
19842
|
+
},
|
|
19843
|
+
listLan: async (port) => {
|
|
19844
|
+
return (await this.ctx.api.localNetwork.getConnectionEndpoints.query({ port })).endpoints.map((e) => ({
|
|
19845
|
+
baseUrl: e.baseUrl,
|
|
19846
|
+
kind: e.kind,
|
|
19847
|
+
priority: e.priority
|
|
19848
|
+
}));
|
|
19849
|
+
},
|
|
19850
|
+
logger: this.ctx.logger
|
|
19851
|
+
})
|
|
19852
|
+
});
|
|
19853
|
+
this.ncArtifactDataPlane = await this.ctx.dataPlane?.serve({
|
|
19854
|
+
prefix: "nc-artifact",
|
|
19855
|
+
access: "public",
|
|
19856
|
+
handler: plane.handler
|
|
19857
|
+
}) ?? null;
|
|
19858
|
+
this.ncArtifactPlane = this.ncArtifactDataPlane !== null ? plane : null;
|
|
19859
|
+
this.ctx.logger.info("nc-artifact data-plane served", { meta: { served: this.ncArtifactPlane !== null } });
|
|
19860
|
+
} catch (err) {
|
|
19861
|
+
this.ctx.logger.warn("nc-artifact data-plane failed to serve", { meta: { error: errMsg(err) } });
|
|
19862
|
+
}
|
|
19863
|
+
}
|
|
18420
19864
|
/** Public base URL for event thumbnails: `/addon/<addonId>/event-media`.
|
|
18421
19865
|
* Set once the data-plane is registered; null until then (e.g. no
|
|
18422
19866
|
* dataPlane facility in the current environment). */
|
|
@@ -18574,7 +20018,10 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
18574
20018
|
});
|
|
18575
20019
|
},
|
|
18576
20020
|
emitTrackLifecycle: (payload, timestampMs) => this.emitTrackLifecycle(payload, timestampMs),
|
|
18577
|
-
onTrackClosed: (track, ownedMedia, info) =>
|
|
20021
|
+
onTrackClosed: (track, ownedMedia, info) => {
|
|
20022
|
+
this.noteShortMotionlessTrack(track);
|
|
20023
|
+
return this.notificationCenter?.onTrackClosed(track, ownedMedia, info);
|
|
20024
|
+
},
|
|
18578
20025
|
deriveThumbnailFromKeyFrame: async (input) => {
|
|
18579
20026
|
const derived = await deriveKeyFrameThumbnailJpeg({
|
|
18580
20027
|
...input,
|
|
@@ -18720,12 +20167,19 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
18720
20167
|
let storage = this.ctx.kernel.storage;
|
|
18721
20168
|
const mediaRoot = process.env.CAMSTACK_MEDIA_ROOT?.trim();
|
|
18722
20169
|
if (mediaRoot) {
|
|
18723
|
-
const { FilesystemStorageProvider } = await import("../node-
|
|
18724
|
-
storage = new FilesystemStorageProvider(mediaRoot);
|
|
20170
|
+
const { FilesystemStorageProvider } = await import("../node-BmVBZiOw.mjs");
|
|
20171
|
+
storage = new FilesystemStorageProvider(mediaRoot, { eventMedia: mediaRoot });
|
|
18725
20172
|
logger.info("pipeline-analytics: event media rooted at CAMSTACK_MEDIA_ROOT", { meta: { mediaRoot } });
|
|
18726
20173
|
}
|
|
18727
20174
|
if (!storage) throw new Error("pipeline-analytics requires ctx.kernel.storage");
|
|
18728
|
-
return
|
|
20175
|
+
return createLocationAwareMediaStorage({
|
|
20176
|
+
base: storage,
|
|
20177
|
+
defaultLocation: "eventMedia",
|
|
20178
|
+
resolveRoot: (locationId) => this.ctx.api.storage.resolve.query({
|
|
20179
|
+
location: locationId,
|
|
20180
|
+
relativePath: ""
|
|
20181
|
+
})
|
|
20182
|
+
});
|
|
18729
20183
|
}
|
|
18730
20184
|
/** Constructs every SQLite-backed store plus the stationary/package-drop/
|
|
18731
20185
|
* sensor plumbing, in the exact pre-S7 order. Returns the non-null bundle
|
|
@@ -18773,6 +20227,27 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
18773
20227
|
logger: logger.child("MediaStore")
|
|
18774
20228
|
});
|
|
18775
20229
|
this.mediaStore = mediaStore;
|
|
20230
|
+
this.mediaRelocate = new MediaRelocateEngine({
|
|
20231
|
+
store: api.settingsStore,
|
|
20232
|
+
storage,
|
|
20233
|
+
logger: logger.child("MediaRelocate"),
|
|
20234
|
+
now: () => Date.now(),
|
|
20235
|
+
newId: () => randomUUID(),
|
|
20236
|
+
resolveTargetRoot: (locationId) => api.storage.resolve.query({
|
|
20237
|
+
location: locationId,
|
|
20238
|
+
relativePath: ""
|
|
20239
|
+
}),
|
|
20240
|
+
onFinished: (job) => {
|
|
20241
|
+
this.eventsOpsLog?.append({
|
|
20242
|
+
op: "relocate",
|
|
20243
|
+
reason: "operator",
|
|
20244
|
+
deviceId: job.deviceId,
|
|
20245
|
+
itemsAffected: job.filesMoved,
|
|
20246
|
+
bytesReclaimed: 0,
|
|
20247
|
+
detail: `${job.state}: media → ${job.toLocationId} (${job.bytesMoved} bytes${job.error ? `; ${job.error}` : ""})`
|
|
20248
|
+
});
|
|
20249
|
+
}
|
|
20250
|
+
});
|
|
18776
20251
|
const eventStore = new EventStore({
|
|
18777
20252
|
store: api.settingsStore,
|
|
18778
20253
|
logger: logger.child("EventStore"),
|
|
@@ -19018,16 +20493,66 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
19018
20493
|
this.notificationCenter = new NotificationCenter({
|
|
19019
20494
|
store: api.settingsStore,
|
|
19020
20495
|
logger: logger.child("NotificationCenter"),
|
|
20496
|
+
listUsers: async () => {
|
|
20497
|
+
return (await api.userManagement.listUsers.query()).map((u) => ({
|
|
20498
|
+
id: u.id,
|
|
20499
|
+
isAdmin: u.isAdmin,
|
|
20500
|
+
allowedDevices: u.allowedDevices
|
|
20501
|
+
}));
|
|
20502
|
+
},
|
|
20503
|
+
getDeviceRoute: async (deviceId) => {
|
|
20504
|
+
const device = await api.deviceManager.getDevice.query({ deviceId });
|
|
20505
|
+
return device === null ? null : {
|
|
20506
|
+
stableId: device.stableId,
|
|
20507
|
+
addonId: device.addonId
|
|
20508
|
+
};
|
|
20509
|
+
},
|
|
19021
20510
|
dispatcher: {
|
|
19022
|
-
|
|
19023
|
-
return (await api.
|
|
19024
|
-
id:
|
|
19025
|
-
|
|
19026
|
-
name: t.name,
|
|
19027
|
-
kind: t.kind,
|
|
19028
|
-
enabled: t.enabled
|
|
20511
|
+
getZoneNames: async (deviceId) => {
|
|
20512
|
+
return (await api.zones.listZones.query({ deviceId })).map((z) => ({
|
|
20513
|
+
id: z.id,
|
|
20514
|
+
name: z.name
|
|
19029
20515
|
}));
|
|
19030
20516
|
},
|
|
20517
|
+
getZonePolygons: async (deviceId, zoneIds) => {
|
|
20518
|
+
const zones = await api.zones.listZones.query({ deviceId });
|
|
20519
|
+
const wanted = new Set(zoneIds);
|
|
20520
|
+
return zones.filter((z) => wanted.has(z.id) && Array.isArray(z.polygon)).map((z) => (z.polygon ?? []).map((pt) => ({
|
|
20521
|
+
x: pt.x,
|
|
20522
|
+
y: pt.y
|
|
20523
|
+
})));
|
|
20524
|
+
},
|
|
20525
|
+
renderFootage: async (req) => {
|
|
20526
|
+
const res = await api.streamBroker.renderPreBufferClip.mutate({
|
|
20527
|
+
deviceId: req.deviceId,
|
|
20528
|
+
aroundMs: req.aroundMs,
|
|
20529
|
+
format: req.format,
|
|
20530
|
+
preRollSec: req.preRollSec ?? NC_FOOTAGE_PRE_ROLL_SEC,
|
|
20531
|
+
postRollSec: req.postRollSec ?? NC_FOOTAGE_POST_ROLL_SEC,
|
|
20532
|
+
maxWidth: NC_FOOTAGE_MAX_WIDTH,
|
|
20533
|
+
fps: NC_FOOTAGE_FPS,
|
|
20534
|
+
...req.profile === "high" || req.profile === "mid" || req.profile === "low" ? { profile: req.profile } : {}
|
|
20535
|
+
});
|
|
20536
|
+
const buf = Buffer.from(res.base64, "base64");
|
|
20537
|
+
if (buf.byteLength === 0) return null;
|
|
20538
|
+
const bytes = new Uint8Array(buf.byteLength);
|
|
20539
|
+
bytes.set(buf);
|
|
20540
|
+
return bytes;
|
|
20541
|
+
},
|
|
20542
|
+
publishArtifact: async (bytes, mime) => await this.ncArtifactPlane?.publish(bytes, mime) ?? null,
|
|
20543
|
+
listTargets: async () => {
|
|
20544
|
+
return (await api.notificationOutput.listTargets.query({})).map((t) => {
|
|
20545
|
+
const owner = t.config["ownerUserId"];
|
|
20546
|
+
return {
|
|
20547
|
+
id: t.id,
|
|
20548
|
+
addonId: t.addonId,
|
|
20549
|
+
name: t.name,
|
|
20550
|
+
kind: t.kind,
|
|
20551
|
+
enabled: t.enabled,
|
|
20552
|
+
...typeof owner === "string" && owner.length > 0 ? { ownerUserId: owner } : {}
|
|
20553
|
+
};
|
|
20554
|
+
});
|
|
20555
|
+
},
|
|
19031
20556
|
send: async (input) => {
|
|
19032
20557
|
const { attachments, ...notification } = input.notification;
|
|
19033
20558
|
return api.notificationOutput.send.mutate({
|
|
@@ -19080,6 +20605,7 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
19080
20605
|
handler
|
|
19081
20606
|
}) ?? null;
|
|
19082
20607
|
this.eventMediaBaseUrl = this.eventMediaDataPlane !== null ? `/addon/${this.ctx.id}/event-media` : null;
|
|
20608
|
+
await this.serveNcArtifactPlane();
|
|
19083
20609
|
this.ctx.logger.info("event-media data-plane served", { meta: { baseUrl: this.eventMediaBaseUrl ?? "(no dataPlane facility)" } });
|
|
19084
20610
|
} catch (err) {
|
|
19085
20611
|
this.ctx.logger.warn("event-media data-plane failed to serve", { meta: { error: errMsg(err) } });
|
|
@@ -19365,6 +20891,7 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
19365
20891
|
const liveRules = proxy?.state.zoneRules.value?.detection ?? [];
|
|
19366
20892
|
processor.setZones(liveZones);
|
|
19367
20893
|
processor.setDetectionRules(liveRules);
|
|
20894
|
+
processor.setZoneMembershipMinOverlap(trk.zoneMembershipMinOverlap);
|
|
19368
20895
|
const result = processor.process({
|
|
19369
20896
|
timestamp: frame.timestamp,
|
|
19370
20897
|
frame
|
|
@@ -19595,7 +21122,29 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
19595
21122
|
} });
|
|
19596
21123
|
}
|
|
19597
21124
|
await Promise.all([...result.objectEvents, ...result.appearanceEvents].map((e) => this.eventStore.insertObject(e)));
|
|
19598
|
-
if (this.notificationCenter !== null)
|
|
21125
|
+
if (this.notificationCenter !== null) {
|
|
21126
|
+
const overlaps = processor.getLastZoneOverlaps();
|
|
21127
|
+
for (const e of result.objectEvents) {
|
|
21128
|
+
if (e.zones && e.zones.length > 0) {
|
|
21129
|
+
const m = e.trackId ? overlaps.get(e.trackId) : void 0;
|
|
21130
|
+
this.ctx.logger.info("zone membership stamped on event", {
|
|
21131
|
+
tags: { deviceId },
|
|
21132
|
+
meta: {
|
|
21133
|
+
eventId: e.id,
|
|
21134
|
+
trackId: e.trackId,
|
|
21135
|
+
className: e.className,
|
|
21136
|
+
minOverlap: trk.zoneMembershipMinOverlap,
|
|
21137
|
+
zones: (m ?? []).map((z) => ({
|
|
21138
|
+
id: z.zoneId,
|
|
21139
|
+
name: z.zoneName,
|
|
21140
|
+
overlapPct: Math.round(z.overlap * 1e3) / 10
|
|
21141
|
+
}))
|
|
21142
|
+
}
|
|
21143
|
+
});
|
|
21144
|
+
}
|
|
21145
|
+
this.notificationCenter.onObjectEventPersisted(e);
|
|
21146
|
+
}
|
|
21147
|
+
}
|
|
19599
21148
|
const objectEmbeddingBests = [];
|
|
19600
21149
|
if (this.objectEmbeddingStore) for (const t of result.tracked) {
|
|
19601
21150
|
if (!isClipObjectEmbedding(t)) continue;
|
|
@@ -19947,19 +21496,28 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
19947
21496
|
}),
|
|
19948
21497
|
redetect: (cropJpeg) => this.redetectCropForConfirmation(nodeId, deviceId, cropJpeg),
|
|
19949
21498
|
onDecision: (decision) => {
|
|
21499
|
+
const meta = {
|
|
21500
|
+
trackId: decision.trackId,
|
|
21501
|
+
reason: decision.reason,
|
|
21502
|
+
className: decision.className,
|
|
21503
|
+
...decision.bestScore !== void 0 ? { bestScore: decision.bestScore } : {},
|
|
21504
|
+
...decision.bestIncompatibleClass !== void 0 ? {
|
|
21505
|
+
bestIncompatibleClass: decision.bestIncompatibleClass,
|
|
21506
|
+
bestIncompatibleScore: decision.bestIncompatibleScore
|
|
21507
|
+
} : {},
|
|
21508
|
+
minConfidence: config.minConfidence
|
|
21509
|
+
};
|
|
19950
21510
|
if (!decision.confirmed) this.ctx.logger.info("confirmation gate: birth suppressed (false positive)", {
|
|
19951
21511
|
tags: { deviceId },
|
|
19952
|
-
meta
|
|
19953
|
-
trackId: decision.trackId,
|
|
19954
|
-
reason: decision.reason
|
|
19955
|
-
}
|
|
21512
|
+
meta
|
|
19956
21513
|
});
|
|
19957
21514
|
else if (decision.reason !== "confirmed") this.ctx.logger.debug("confirmation gate: birth allowed (fail-open)", {
|
|
19958
21515
|
tags: { deviceId },
|
|
19959
|
-
meta
|
|
19960
|
-
|
|
19961
|
-
|
|
19962
|
-
}
|
|
21516
|
+
meta
|
|
21517
|
+
});
|
|
21518
|
+
else this.ctx.logger.info("confirmation gate: birth confirmed", {
|
|
21519
|
+
tags: { deviceId },
|
|
21520
|
+
meta
|
|
19963
21521
|
});
|
|
19964
21522
|
}
|
|
19965
21523
|
});
|
|
@@ -19976,6 +21534,48 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
19976
21534
|
async resolveDeviceStationarySettings(deviceId) {
|
|
19977
21535
|
return this.stationarySettingsCache.get(deviceId, (id) => this.readDeviceSettings(id, resolveStationarySettings));
|
|
19978
21536
|
}
|
|
21537
|
+
/**
|
|
21538
|
+
* Count SHORT + MOTIONLESS track closes per frame cell.
|
|
21539
|
+
*
|
|
21540
|
+
* The stationary registry cannot see this class of phantom. Promotion needs a
|
|
21541
|
+
* track that has EXISTED for `PROMOTION_WINDOW_MS` (30 s), and these die long
|
|
21542
|
+
* before: the dead ornamental grass on device 615 produced tracks of 3.8 s,
|
|
21543
|
+
* 4.1 s, 6.5 s and 19.0 s with 0-2.5 px of total displacement, roughly
|
|
21544
|
+
* fifteen of them in a day, all at the same spot. Each one is individually
|
|
21545
|
+
* innocent; the RECURRENCE is the signal, and nothing survives a track death
|
|
21546
|
+
* to notice it.
|
|
21547
|
+
*
|
|
21548
|
+
* Lowering the promotion window is not the fix — those 30 s exist so someone
|
|
21549
|
+
* standing still at a door is not declared scenery.
|
|
21550
|
+
*
|
|
21551
|
+
* This is the measurement half: it establishes how often a cell repeats
|
|
21552
|
+
* before any suppression is built, so "N closes in what window" comes from
|
|
21553
|
+
* data rather than intuition. It suppresses NOTHING.
|
|
21554
|
+
*/
|
|
21555
|
+
noteShortMotionlessTrack(track) {
|
|
21556
|
+
const lifeMs = track.lastSeen - track.firstSeen;
|
|
21557
|
+
const moved = track.totalDistance ?? 0;
|
|
21558
|
+
if (lifeMs > SHORT_TRACK_MAX_MS || moved > MOTIONLESS_MAX_PX) return;
|
|
21559
|
+
const first = track.positions?.[0];
|
|
21560
|
+
if (!first) return;
|
|
21561
|
+
const cell = `${Math.round(first.x / PHANTOM_CELL_PX)},${Math.round(first.y / PHANTOM_CELL_PX)}`;
|
|
21562
|
+
const key = `${track.deviceId}:${track.className}:${cell}`;
|
|
21563
|
+
const now = Date.now();
|
|
21564
|
+
const seen = this.shortMotionlessCells.get(key)?.filter((t) => now - t < PHANTOM_CELL_WINDOW_MS) ?? [];
|
|
21565
|
+
seen.push(now);
|
|
21566
|
+
this.shortMotionlessCells.set(key, seen);
|
|
21567
|
+
this.ctx.logger.info("short motionless track closed", {
|
|
21568
|
+
tags: { deviceId: track.deviceId },
|
|
21569
|
+
meta: {
|
|
21570
|
+
trackId: track.trackId,
|
|
21571
|
+
className: track.className,
|
|
21572
|
+
lifeMs,
|
|
21573
|
+
movedPx: Math.round(moved * 10) / 10,
|
|
21574
|
+
cell,
|
|
21575
|
+
repeatsInWindow: seen.length
|
|
21576
|
+
}
|
|
21577
|
+
});
|
|
21578
|
+
}
|
|
19979
21579
|
stationarySettingsFromCache(deviceId) {
|
|
19980
21580
|
return this.stationarySettingsCache.peek(deviceId) ?? STATIONARY_DEFAULTS;
|
|
19981
21581
|
}
|
|
@@ -19985,9 +21585,14 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
19985
21585
|
/**
|
|
19986
21586
|
* Resolve a device's ENABLED `package`-stage zone rules independent of the
|
|
19987
21587
|
* live frame path (mirrors `resolveDeviceZones`). Warms the proxy and,
|
|
19988
|
-
* when the cached slice is empty, forces one refresh.
|
|
19989
|
-
*
|
|
19990
|
-
*
|
|
21588
|
+
* when the cached slice is empty, forces one refresh.
|
|
21589
|
+
*
|
|
21590
|
+
* The `package` slice is written through the `zone-rules` capability
|
|
21591
|
+
* (`zoneRules.setRules({stage:'package'})`), which is live. An earlier
|
|
21592
|
+
* version of this comment claimed the provider did not exist yet and that
|
|
21593
|
+
* "no package events fire" — that was stale, and believing it produced a
|
|
21594
|
+
* confidently wrong diagnosis on 2026-07-30. An empty list here means the
|
|
21595
|
+
* operator has drawn no package zone rule, nothing more.
|
|
19991
21596
|
*/
|
|
19992
21597
|
async resolveDevicePackageRules(deviceId) {
|
|
19993
21598
|
const proxy = await this.ensureProxy(deviceId);
|
|
@@ -20724,42 +22329,112 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
20724
22329
|
if (this.shuttingDown || !this.trackStore) return;
|
|
20725
22330
|
await this.trackCloser.sweep();
|
|
20726
22331
|
}
|
|
22332
|
+
/** Unified retention (Phase 1): earliest retained footage per device — the
|
|
22333
|
+
* follow-recordings cutoff source. Cached 10 min: availability answers in
|
|
22334
|
+
* ~70 ms but the sweep asks once per device per pass. `null` = no footage
|
|
22335
|
+
* (or recorder unreachable) → the policy falls back to the custom windows. */
|
|
22336
|
+
earliestFootageCache = /* @__PURE__ */ new Map();
|
|
22337
|
+
async earliestFootageMs(deviceId) {
|
|
22338
|
+
const cached = this.earliestFootageCache.get(deviceId);
|
|
22339
|
+
if (cached && Date.now() - cached.at < 10 * 6e4) return cached.value;
|
|
22340
|
+
let value = null;
|
|
22341
|
+
try {
|
|
22342
|
+
const res = await this.ctx.api.recording.getAvailability.query({
|
|
22343
|
+
deviceId,
|
|
22344
|
+
fromMs: 0,
|
|
22345
|
+
toMs: Date.now()
|
|
22346
|
+
});
|
|
22347
|
+
let min = Number.POSITIVE_INFINITY;
|
|
22348
|
+
for (const r of res.ranges) if (r.startMs < min) min = r.startMs;
|
|
22349
|
+
value = Number.isFinite(min) ? min : null;
|
|
22350
|
+
} catch {
|
|
22351
|
+
value = null;
|
|
22352
|
+
}
|
|
22353
|
+
this.earliestFootageCache.set(deviceId, {
|
|
22354
|
+
at: Date.now(),
|
|
22355
|
+
value
|
|
22356
|
+
});
|
|
22357
|
+
return value;
|
|
22358
|
+
}
|
|
22359
|
+
/** The per-device effective cutoffs (mode + footage → numbers). */
|
|
22360
|
+
async deviceRetentionCutoffs(deviceId, nowMs) {
|
|
22361
|
+
const settings = resolveRetentionSettings(await this.ctx?.settings?.readDeviceStore(deviceId) ?? {});
|
|
22362
|
+
return resolveRetentionCutoffs(settings, nowMs, settings.retentionMode === "follow-recordings" ? await this.earliestFootageMs(deviceId) : null);
|
|
22363
|
+
}
|
|
22364
|
+
/** The devices one retention pass covers: every camera the hub knows plus
|
|
22365
|
+
* any device that still has persisted tracks (covers deleted cameras whose
|
|
22366
|
+
* history must keep aging out). */
|
|
22367
|
+
async retentionDeviceIds() {
|
|
22368
|
+
const ids = /* @__PURE__ */ new Set();
|
|
22369
|
+
try {
|
|
22370
|
+
const all = await this.ctx.api.deviceManager.listAll.query({});
|
|
22371
|
+
for (const d of all) ids.add(d.id);
|
|
22372
|
+
} catch {}
|
|
22373
|
+
try {
|
|
22374
|
+
for (const id of await this.trackStore?.listDeviceIds() ?? []) ids.add(id);
|
|
22375
|
+
} catch {}
|
|
22376
|
+
return [...ids];
|
|
22377
|
+
}
|
|
20727
22378
|
async sweepRetention() {
|
|
20728
22379
|
if (this.shuttingDown || !this.eventStore || !this.mediaStore) return;
|
|
20729
22380
|
const now = Date.now();
|
|
20730
22381
|
const day = 1440 * 60 * 1e3;
|
|
20731
|
-
const OBJECT_RETENTION_DAYS = 30;
|
|
20732
|
-
const motionCutoffMs = now - 14 * day;
|
|
20733
|
-
const objectCutoffMs = now - OBJECT_RETENTION_DAYS * day;
|
|
20734
|
-
const audioCutoffMs = now - 7 * day;
|
|
20735
22382
|
try {
|
|
20736
|
-
const
|
|
20737
|
-
|
|
20738
|
-
|
|
20739
|
-
|
|
20740
|
-
|
|
20741
|
-
|
|
20742
|
-
|
|
20743
|
-
|
|
20744
|
-
|
|
20745
|
-
|
|
20746
|
-
|
|
20747
|
-
|
|
20748
|
-
|
|
20749
|
-
|
|
20750
|
-
|
|
20751
|
-
|
|
20752
|
-
|
|
20753
|
-
|
|
20754
|
-
|
|
20755
|
-
|
|
22383
|
+
const deviceIds = await this.retentionDeviceIds();
|
|
22384
|
+
const evictedIds = [];
|
|
22385
|
+
let minObjectCutoffMs = Number.POSITIVE_INFINITY;
|
|
22386
|
+
for (const deviceId of deviceIds) {
|
|
22387
|
+
if (this.shuttingDown) return;
|
|
22388
|
+
try {
|
|
22389
|
+
const cutoffs = await this.deviceRetentionCutoffs(deviceId, now);
|
|
22390
|
+
if (cutoffs.objectCutoffMs !== null && cutoffs.objectCutoffMs < minObjectCutoffMs) minObjectCutoffMs = cutoffs.objectCutoffMs;
|
|
22391
|
+
if (cutoffs.motionCutoffMs === null && cutoffs.objectCutoffMs === null && cutoffs.audioCutoffMs === null) continue;
|
|
22392
|
+
const evicted = await this.eventStore.evictBefore({
|
|
22393
|
+
deviceId,
|
|
22394
|
+
motionCutoffMs: cutoffs.motionCutoffMs ?? 0,
|
|
22395
|
+
objectCutoffMs: cutoffs.objectCutoffMs ?? 0,
|
|
22396
|
+
audioCutoffMs: cutoffs.audioCutoffMs ?? 0
|
|
22397
|
+
});
|
|
22398
|
+
const ids = [
|
|
22399
|
+
...evicted.motion,
|
|
22400
|
+
...evicted.object,
|
|
22401
|
+
...evicted.audio
|
|
22402
|
+
];
|
|
22403
|
+
if (ids.length > 0) {
|
|
22404
|
+
evictedIds.push(...ids);
|
|
22405
|
+
this.eventsOpsLog?.append({
|
|
22406
|
+
op: "prune",
|
|
22407
|
+
reason: "retention",
|
|
22408
|
+
deviceId,
|
|
22409
|
+
itemsAffected: ids.length,
|
|
22410
|
+
bytesReclaimed: 0,
|
|
22411
|
+
detail: `age sweep (${cutoffs.effectiveMode}): ${evicted.motion.length} motion, ${evicted.object.length} object, ${evicted.audio.length} audio`
|
|
22412
|
+
});
|
|
22413
|
+
this.ctx.logger.info("analytics event eviction (age sweep)", {
|
|
22414
|
+
tags: { deviceId },
|
|
22415
|
+
meta: {
|
|
22416
|
+
motion: evicted.motion.length,
|
|
22417
|
+
object: evicted.object.length,
|
|
22418
|
+
audio: evicted.audio.length,
|
|
22419
|
+
mode: cutoffs.effectiveMode,
|
|
22420
|
+
objectCutoffMs: cutoffs.objectCutoffMs
|
|
22421
|
+
}
|
|
22422
|
+
});
|
|
22423
|
+
}
|
|
22424
|
+
} catch (err) {
|
|
22425
|
+
this.ctx.logger.debug("event retention sweep (device) failed", {
|
|
22426
|
+
tags: { deviceId },
|
|
22427
|
+
meta: { error: String(err) }
|
|
22428
|
+
});
|
|
22429
|
+
}
|
|
20756
22430
|
}
|
|
20757
|
-
await this.mediaStore.
|
|
20758
|
-
if (
|
|
20759
|
-
|
|
22431
|
+
if (evictedIds.length > 0) await this.mediaStore.deleteForEvents(evictedIds);
|
|
22432
|
+
if (Number.isFinite(minObjectCutoffMs)) await this.mediaStore.evictBefore(minObjectCutoffMs - 1 * day);
|
|
22433
|
+
if (this.sensorEventStore && Number.isFinite(minObjectCutoffMs)) try {
|
|
22434
|
+
const sensorDeleted = await this.sensorEventStore.evictBefore(minObjectCutoffMs);
|
|
20760
22435
|
if (sensorDeleted > 0) this.ctx.logger.info("sensor-event retention prune", { meta: {
|
|
20761
22436
|
deleted: sensorDeleted,
|
|
20762
|
-
cutoffMs:
|
|
22437
|
+
cutoffMs: minObjectCutoffMs
|
|
20763
22438
|
} });
|
|
20764
22439
|
} catch (err) {
|
|
20765
22440
|
this.ctx.logger.debug("sensor-event prune failed", { meta: { error: String(err) } });
|
|
@@ -20790,8 +22465,8 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
20790
22465
|
} catch (err) {
|
|
20791
22466
|
this.ctx.logger.debug("plate buffer prune failed", { meta: { error: String(err) } });
|
|
20792
22467
|
}
|
|
20793
|
-
if (this.objectEmbeddingStore) try {
|
|
20794
|
-
const deletedEmbIds = await this.objectEmbeddingStore.pruneAll(
|
|
22468
|
+
if (this.objectEmbeddingStore && Number.isFinite(minObjectCutoffMs)) try {
|
|
22469
|
+
const deletedEmbIds = await this.objectEmbeddingStore.pruneAll(minObjectCutoffMs);
|
|
20795
22470
|
if (deletedEmbIds.length > 0) this.ctx.logger.info("object embedding retention prune", { meta: { deleted: deletedEmbIds.length } });
|
|
20796
22471
|
} catch (err) {
|
|
20797
22472
|
this.ctx.logger.debug("object embedding prune failed", { meta: { error: String(err) } });
|
|
@@ -21457,6 +23132,16 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
21457
23132
|
return this.queryFacade.deleteDeviceEvents(input);
|
|
21458
23133
|
}
|
|
21459
23134
|
/** The events ops-log rows (newest-first), optionally scoped to one camera. */
|
|
23135
|
+
async relocateMedia(input) {
|
|
23136
|
+
if (!this.mediaRelocate) throw new Error("media relocation unavailable");
|
|
23137
|
+
return { jobId: this.mediaRelocate.start(input) };
|
|
23138
|
+
}
|
|
23139
|
+
async getMediaRelocateStatus() {
|
|
23140
|
+
return this.mediaRelocate?.list() ?? [];
|
|
23141
|
+
}
|
|
23142
|
+
async cancelMediaRelocate(input) {
|
|
23143
|
+
return { cancelled: this.mediaRelocate?.cancel(input.jobId) ?? false };
|
|
23144
|
+
}
|
|
21460
23145
|
async listOpsLog(input) {
|
|
21461
23146
|
return this.queryFacade.listOpsLog(input);
|
|
21462
23147
|
}
|
|
@@ -21536,13 +23221,22 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
21536
23221
|
try {
|
|
21537
23222
|
const total = await sweepTrackRetention({
|
|
21538
23223
|
listDeviceIds: () => trackStore.listDeviceIds(),
|
|
21539
|
-
|
|
21540
|
-
|
|
23224
|
+
resolveCutoffMs: async (deviceId, nowMs) => (await this.deviceRetentionCutoffs(deviceId, nowMs)).trackCutoffMs,
|
|
23225
|
+
pruneTracksBefore: async (deviceId, cutoffMs) => {
|
|
23226
|
+
const counts = await this.pruneTracksBefore({
|
|
23227
|
+
deviceId,
|
|
23228
|
+
cutoffMs
|
|
23229
|
+
});
|
|
23230
|
+
if (counts.tracks > 0) this.eventsOpsLog?.append({
|
|
23231
|
+
op: "prune",
|
|
23232
|
+
reason: "retention",
|
|
23233
|
+
deviceId,
|
|
23234
|
+
itemsAffected: counts.tracks,
|
|
23235
|
+
bytesReclaimed: 0,
|
|
23236
|
+
detail: `track retention cascade: ${counts.tracks} tracks, ${counts.events} events, ${counts.media} media`
|
|
23237
|
+
});
|
|
23238
|
+
return counts;
|
|
21541
23239
|
},
|
|
21542
|
-
pruneTracksBefore: (deviceId, cutoffMs) => this.pruneTracksBefore({
|
|
21543
|
-
deviceId,
|
|
21544
|
-
cutoffMs
|
|
21545
|
-
}),
|
|
21546
23240
|
now: () => Date.now(),
|
|
21547
23241
|
onError: (deviceId, err) => {
|
|
21548
23242
|
this.ctx.logger.debug("track retention sweep (device) failed", { meta: {
|
|
@@ -21636,7 +23330,7 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
21636
23330
|
const raw = await this.ctx?.settings?.readDeviceStore(input.deviceId) ?? {};
|
|
21637
23331
|
const baseSections = schema ? hydrateSchema({
|
|
21638
23332
|
...schema,
|
|
21639
|
-
sections: retagDetectionSections(stripGlobalOnlyFields(toAnalyticsDeviceSections(schema.sections)))
|
|
23333
|
+
sections: retagRetentionSection(retagDetectionSections(stripGlobalOnlyFields(toAnalyticsDeviceSections(schema.sections))))
|
|
21640
23334
|
}, raw).sections : [];
|
|
21641
23335
|
const liveStatsSection = {
|
|
21642
23336
|
id: "live-stats",
|
|
@@ -21686,4 +23380,4 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
21686
23380
|
}
|
|
21687
23381
|
};
|
|
21688
23382
|
//#endregion
|
|
21689
|
-
export { DETECTION_PIPELINE_SECTION_IDS, ncActions as customActions, ncActions, PipelineAnalyticsAddon as default, pickCleanMedia, retagDetectionSections, stripGlobalOnlyFields, toAnalyticsDeviceSections };
|
|
23383
|
+
export { DETECTION_PIPELINE_SECTION_IDS, ncActions as customActions, ncActions, PipelineAnalyticsAddon as default, pickCleanMedia, retagDetectionSections, retagRetentionSection, stripGlobalOnlyFields, toAnalyticsDeviceSections };
|