@camstack/addon-post-analysis 1.2.7 → 1.2.8
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-BW-IoQDL.js → dist-Bezh0l-m.js} +1905 -1457
- package/dist/{dist-D5fcvkof.mjs → dist-Dm1I_IVp.mjs} +1388 -988
- package/dist/embedding-encoder/index.js +1 -1
- package/dist/embedding-encoder/index.mjs +1 -1
- package/dist/{node-AdCpDGpz.js → node-DsYWuKgE.js} +1 -1
- package/dist/pipeline-analytics/_stub.js +1 -1
- package/dist/pipeline-analytics/{_virtual_mf-localSharedImportMap___mfe_internal__addon_pipeline_analytics_widgets-6_8G5GUI.mjs → _virtual_mf-localSharedImportMap___mfe_internal__addon_pipeline_analytics_widgets-DX9SlV1u.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-CDgDsFf_.mjs → _virtual_mf___mfe_internal__addon_pipeline_analytics_widgets__loadShare___mf_0_camstack_mf_1_ui_mf_2_library__loadShare__.js-CbYZbG3H.mjs} +1 -1
- package/dist/pipeline-analytics/{hostInit-DAKHmSht.mjs → hostInit-DQ6-5P8q.mjs} +2 -2
- package/dist/pipeline-analytics/index.js +2475 -712
- package/dist/pipeline-analytics/index.mjs +2473 -712
- package/dist/pipeline-analytics/remoteEntry.js +1 -1
- package/package.json +1 -1
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { A as
|
|
1
|
+
import { A as BaseAddon, B as EventCategory, C as notificationRulesCapability, D as videoclipsCapability, E as subKindsOf, F as boolean, I as literal, L as number, M as createEvent, N as hydrateSchema, O as zoneAnalyticsCapability, P as array, R as object, S as nodePin, T as plateGalleryCapability, _ as customAction, a as NC_CONDITION_CATALOG, b as faceGalleryCapability, c as NcRuleInputSchema, d as NcTaxonomySchema, f as OpsLogEntrySchema, g as cosineSimilarity, h as buildEventKindDescriptor, i as MACRO_LABELS, j as DeviceType, k as errMsg, l as NcRulePatchSchema, m as audioMetricsCapability, n as EVENT_KIND_BY_CAP, o as NC_TAXONOMY, p as addonWidgetsSourceCapability, r as EVENT_PAD_MS, s as NcConditionDescriptorSchema, t as DEFAULT_EVENT_COLOR, u as NcRuleSchema, v as defineCustomActions, w as pipelineAnalyticsCapability, z as string } from "../dist-Dm1I_IVp.mjs";
|
|
2
2
|
import { randomUUID } from "node:crypto";
|
|
3
3
|
import sharp from "sharp";
|
|
4
4
|
//#region src/pipeline-analytics/videoclips-provider.ts
|
|
@@ -395,7 +395,11 @@ var FaceGalleryProvider = class {
|
|
|
395
395
|
assigned: face.assigned,
|
|
396
396
|
...base64 !== void 0 ? { base64 } : {},
|
|
397
397
|
...face.faceBbox !== void 0 ? { faceBbox: face.faceBbox } : {},
|
|
398
|
-
...face.keyFrameMediaKey !== void 0 ? { keyFrameMediaKey: face.keyFrameMediaKey } : {}
|
|
398
|
+
...face.keyFrameMediaKey !== void 0 ? { keyFrameMediaKey: face.keyFrameMediaKey } : {},
|
|
399
|
+
...face.bestMatchScore != null ? { bestMatchScore: face.bestMatchScore } : {},
|
|
400
|
+
...face.nativeFaceShortSidePx != null ? { nativeFaceShortSidePx: face.nativeFaceShortSidePx } : {},
|
|
401
|
+
...face.suggestedIdentityId != null ? { suggestedIdentityId: face.suggestedIdentityId } : {},
|
|
402
|
+
...face.suggestedMatchScore != null ? { suggestedMatchScore: face.suggestedMatchScore } : {}
|
|
399
403
|
});
|
|
400
404
|
}
|
|
401
405
|
return result;
|
|
@@ -425,7 +429,11 @@ var FaceGalleryProvider = class {
|
|
|
425
429
|
assigned: face.assigned,
|
|
426
430
|
...base64 !== void 0 ? { base64 } : {},
|
|
427
431
|
...face.faceBbox !== void 0 ? { faceBbox: face.faceBbox } : {},
|
|
428
|
-
...face.keyFrameMediaKey !== void 0 ? { keyFrameMediaKey: face.keyFrameMediaKey } : {}
|
|
432
|
+
...face.keyFrameMediaKey !== void 0 ? { keyFrameMediaKey: face.keyFrameMediaKey } : {},
|
|
433
|
+
...face.bestMatchScore != null ? { bestMatchScore: face.bestMatchScore } : {},
|
|
434
|
+
...face.nativeFaceShortSidePx != null ? { nativeFaceShortSidePx: face.nativeFaceShortSidePx } : {},
|
|
435
|
+
...face.suggestedIdentityId != null ? { suggestedIdentityId: face.suggestedIdentityId } : {},
|
|
436
|
+
...face.suggestedMatchScore != null ? { suggestedMatchScore: face.suggestedMatchScore } : {}
|
|
429
437
|
};
|
|
430
438
|
}
|
|
431
439
|
/**
|
|
@@ -627,16 +635,25 @@ var FaceGalleryProvider = class {
|
|
|
627
635
|
function normalizePlate$1(text) {
|
|
628
636
|
return text.toUpperCase().replace(/[^A-Z0-9]/g, "");
|
|
629
637
|
}
|
|
638
|
+
/** Raw-read glyphs a real plate can never contain: anything outside letters,
|
|
639
|
+
* digits, whitespace and the hyphen (display separator). The OCR charset
|
|
640
|
+
* includes bracket/punctuation glyphs, so a blurred crop can hallucinate
|
|
641
|
+
* reads like "[miv4o)" / "6ou.2" (live-observed 2026-07-22) that a
|
|
642
|
+
* length-only gate lets through. */
|
|
643
|
+
var PLATE_SYMBOL_GLYPH = /[^A-Za-z0-9\s-]/;
|
|
630
644
|
/**
|
|
631
645
|
* Quality gate for a raw OCR plate read BEFORE it becomes a track label or a
|
|
632
646
|
* gallery row. Distant/oblique parked plates produce junk reads ("N", "Idag",
|
|
633
647
|
* "@em") that otherwise flood track labels (live-observed on the parking
|
|
634
648
|
* camera, 2026-07-16). A plausible European plate is ≥{@link PLATE_MIN_LENGTH}
|
|
635
|
-
* alphanumerics
|
|
649
|
+
* alphanumerics, mixes letters AND digits, and carries NO symbol glyph in the
|
|
650
|
+
* RAW read (spaces/hyphens allowed — normalization would silently strip a
|
|
651
|
+
* hallucinated "[" and launder the junk); anything else — or a read below
|
|
636
652
|
* {@link PLATE_MIN_SCORE} — is discarded, not stored.
|
|
637
653
|
*/
|
|
638
654
|
function isPlausiblePlateRead(text, score) {
|
|
639
655
|
if (score < .4) return false;
|
|
656
|
+
if (PLATE_SYMBOL_GLYPH.test(text)) return false;
|
|
640
657
|
const norm = normalizePlate$1(text);
|
|
641
658
|
if (norm.length < 4) return false;
|
|
642
659
|
return /[0-9]/.test(norm) && /[A-Z]/.test(norm);
|
|
@@ -2867,6 +2884,7 @@ var TrackResidentState = class {
|
|
|
2867
2884
|
const created = {
|
|
2868
2885
|
deviceId,
|
|
2869
2886
|
firstFramePending: false,
|
|
2887
|
+
firstFrameLanded: false,
|
|
2870
2888
|
thumbnailLanded: false,
|
|
2871
2889
|
confirmed: false
|
|
2872
2890
|
};
|
|
@@ -2924,6 +2942,17 @@ var TrackResidentState = class {
|
|
|
2924
2942
|
bestFramePeak(trackId) {
|
|
2925
2943
|
return this.bestFrameTracker.peak(trackId);
|
|
2926
2944
|
}
|
|
2945
|
+
/** #27-A: record the track's best-seen subject bbox. Called by the frame
|
|
2946
|
+
* loop on every ACCEPTED (new-best AND plausible-box) observe, so the value
|
|
2947
|
+
* always mirrors the frame the latest keyFrame re-shot was seeded from. */
|
|
2948
|
+
recordBestSeenBbox(deviceId, trackId, bbox) {
|
|
2949
|
+
this.ensure(deviceId, trackId).bestBbox = bbox;
|
|
2950
|
+
}
|
|
2951
|
+
/** The track's best-seen subject bbox (undefined until a plausible new-best
|
|
2952
|
+
* was observed). Backs the retry gate + the close-time keyFrame crop. */
|
|
2953
|
+
bestSeenBbox(trackId) {
|
|
2954
|
+
return this.residents.get(trackId)?.bestBbox;
|
|
2955
|
+
}
|
|
2927
2956
|
/** Record a CLIP-object observation for the track's best-EMBEDDING ranking
|
|
2928
2957
|
* (confidence-only policy). Returns true on a new best. */
|
|
2929
2958
|
observeObjectEmbeddingBest(deviceId, obs) {
|
|
@@ -2967,6 +2996,13 @@ var TrackResidentState = class {
|
|
|
2967
2996
|
const resident = this.residents.get(trackId);
|
|
2968
2997
|
if (resident) resident.firstFramePending = false;
|
|
2969
2998
|
}
|
|
2999
|
+
/** A `firstFrame` write landed for this track (see {@link TrackResident.firstFrameLanded}). */
|
|
3000
|
+
markFirstFrameLanded(deviceId, trackId) {
|
|
3001
|
+
this.ensure(deviceId, trackId).firstFrameLanded = true;
|
|
3002
|
+
}
|
|
3003
|
+
isFirstFrameLanded(trackId) {
|
|
3004
|
+
return this.residents.get(trackId)?.firstFrameLanded ?? false;
|
|
3005
|
+
}
|
|
2970
3006
|
isThumbnailLanded(trackId) {
|
|
2971
3007
|
return this.residents.get(trackId)?.thumbnailLanded ?? false;
|
|
2972
3008
|
}
|
|
@@ -3001,81 +3037,131 @@ var TrackResidentState = class {
|
|
|
3001
3037
|
if (resident.rasterFallback === void 0) resident.rasterFallback = fallback;
|
|
3002
3038
|
}
|
|
3003
3039
|
};
|
|
3004
|
-
var DEFAULT_ZERO_MEDIA_POLICY_CONFIG = {
|
|
3005
|
-
suppressMaxDurationMs: 1e3,
|
|
3006
|
-
nothingToShowMaxDurationMs: 1500
|
|
3007
|
-
};
|
|
3008
|
-
/**
|
|
3009
|
-
* Classify a closing track's persistence outcome. Pure — see the module header
|
|
3010
|
-
* for the full contract.
|
|
3011
|
-
*/
|
|
3012
|
-
function decideZeroMediaPolicy(input, config = DEFAULT_ZERO_MEDIA_POLICY_CONFIG) {
|
|
3013
|
-
if (input.hasMedia) return "persist";
|
|
3014
|
-
if (input.durationMs < config.suppressMaxDurationMs && !input.confirmed) return "suppress";
|
|
3015
|
-
if (!input.hasRasterFallback && input.durationMs < config.nothingToShowMaxDurationMs) return "suppress";
|
|
3016
|
-
return input.hasRasterFallback ? "raster-fallback" : "persist";
|
|
3017
|
-
}
|
|
3018
3040
|
//#endregion
|
|
3019
|
-
//#region src/pipeline-analytics/
|
|
3041
|
+
//#region src/pipeline-analytics/store/zone-geometry.ts
|
|
3020
3042
|
/**
|
|
3021
|
-
*
|
|
3022
|
-
*
|
|
3023
|
-
*
|
|
3024
|
-
* Promote when there is at least one `snapshot` AND either there is no
|
|
3025
|
-
* `lastFrame` yet, or the newest snapshot is strictly newer than the held
|
|
3026
|
-
* `lastFrame`. Otherwise keep the current behaviour (no promotion).
|
|
3043
|
+
* Normalized min/max envelope over every position's bbox. Returns `null`
|
|
3044
|
+
* when the frame dimensions are unknown/degenerate or there are no
|
|
3045
|
+
* positions — the caller persists NULL envelope columns in that case.
|
|
3027
3046
|
*/
|
|
3028
|
-
function
|
|
3029
|
-
|
|
3030
|
-
let
|
|
3031
|
-
|
|
3032
|
-
|
|
3033
|
-
|
|
3034
|
-
|
|
3047
|
+
function computeTrackEnvelope(positions, frameWidth, frameHeight) {
|
|
3048
|
+
if (frameWidth === void 0 || frameHeight === void 0 || !(frameWidth > 0) || !(frameHeight > 0) || positions.length === 0) return null;
|
|
3049
|
+
let minX = Number.POSITIVE_INFINITY;
|
|
3050
|
+
let minY = Number.POSITIVE_INFINITY;
|
|
3051
|
+
let maxX = Number.NEGATIVE_INFINITY;
|
|
3052
|
+
let maxY = Number.NEGATIVE_INFINITY;
|
|
3053
|
+
for (const p of positions) {
|
|
3054
|
+
const x0 = p.bbox.x / frameWidth;
|
|
3055
|
+
const y0 = p.bbox.y / frameHeight;
|
|
3056
|
+
const x1 = (p.bbox.x + p.bbox.w) / frameWidth;
|
|
3057
|
+
const y1 = (p.bbox.y + p.bbox.h) / frameHeight;
|
|
3058
|
+
if (x0 < minX) minX = x0;
|
|
3059
|
+
if (y0 < minY) minY = y0;
|
|
3060
|
+
if (x1 > maxX) maxX = x1;
|
|
3061
|
+
if (y1 > maxY) maxY = y1;
|
|
3035
3062
|
}
|
|
3036
|
-
if (newestSnapshot === void 0) return { promote: false };
|
|
3037
|
-
if (lastFrame !== void 0 && newestSnapshot.timestamp <= lastFrame.timestamp) return { promote: false };
|
|
3038
3063
|
return {
|
|
3039
|
-
|
|
3040
|
-
|
|
3041
|
-
|
|
3064
|
+
minX,
|
|
3065
|
+
minY,
|
|
3066
|
+
maxX,
|
|
3067
|
+
maxY
|
|
3042
3068
|
};
|
|
3043
3069
|
}
|
|
3044
|
-
//#endregion
|
|
3045
|
-
//#region src/pipeline-analytics/pipeline/static-track-gate.ts
|
|
3046
3070
|
/**
|
|
3047
|
-
*
|
|
3048
|
-
*
|
|
3049
|
-
*
|
|
3050
|
-
* leaves the importance score untouched.
|
|
3071
|
+
* Axis-aligned bounds of a zone filter (rect: itself; polygon: vertex
|
|
3072
|
+
* min/max). A degenerate polygon (< 3 points) yields the full frame so the
|
|
3073
|
+
* SQL prefilter never silently drops rows the precise test would keep.
|
|
3051
3074
|
*/
|
|
3052
|
-
function
|
|
3053
|
-
if (
|
|
3054
|
-
|
|
3055
|
-
|
|
3056
|
-
|
|
3057
|
-
|
|
3058
|
-
|
|
3059
|
-
|
|
3060
|
-
|
|
3061
|
-
|
|
3062
|
-
|
|
3063
|
-
|
|
3064
|
-
|
|
3065
|
-
|
|
3075
|
+
function zoneBounds(zone) {
|
|
3076
|
+
if (zone.kind === "rect") return {
|
|
3077
|
+
minX: zone.x,
|
|
3078
|
+
minY: zone.y,
|
|
3079
|
+
maxX: zone.x + zone.width,
|
|
3080
|
+
maxY: zone.y + zone.height
|
|
3081
|
+
};
|
|
3082
|
+
if (zone.points.length < 3) return {
|
|
3083
|
+
minX: 0,
|
|
3084
|
+
minY: 0,
|
|
3085
|
+
maxX: 1,
|
|
3086
|
+
maxY: 1
|
|
3087
|
+
};
|
|
3088
|
+
let minX = Number.POSITIVE_INFINITY;
|
|
3089
|
+
let minY = Number.POSITIVE_INFINITY;
|
|
3090
|
+
let maxX = Number.NEGATIVE_INFINITY;
|
|
3091
|
+
let maxY = Number.NEGATIVE_INFINITY;
|
|
3092
|
+
for (const p of zone.points) {
|
|
3093
|
+
if (p.x < minX) minX = p.x;
|
|
3094
|
+
if (p.y < minY) minY = p.y;
|
|
3095
|
+
if (p.x > maxX) maxX = p.x;
|
|
3096
|
+
if (p.y > maxY) maxY = p.y;
|
|
3066
3097
|
}
|
|
3067
3098
|
return {
|
|
3068
|
-
|
|
3069
|
-
|
|
3099
|
+
minX,
|
|
3100
|
+
minY,
|
|
3101
|
+
maxX,
|
|
3102
|
+
maxY
|
|
3070
3103
|
};
|
|
3071
3104
|
}
|
|
3072
|
-
/**
|
|
3073
|
-
|
|
3074
|
-
|
|
3075
|
-
|
|
3076
|
-
|
|
3077
|
-
|
|
3078
|
-
|
|
3105
|
+
/** Whether two axis-aligned envelopes overlap (touching edges count). */
|
|
3106
|
+
function envelopesOverlap(a, b) {
|
|
3107
|
+
return a.minX <= b.maxX && a.maxX >= b.minX && a.minY <= b.maxY && a.maxY >= b.minY;
|
|
3108
|
+
}
|
|
3109
|
+
/**
|
|
3110
|
+
* Ray-casting point-in-polygon (even-odd rule). Points on an edge may
|
|
3111
|
+
* resolve either way — acceptable for zone filtering. A polygon with
|
|
3112
|
+
* fewer than 3 vertices contains nothing.
|
|
3113
|
+
*/
|
|
3114
|
+
function pointInPolygon(point, polygon) {
|
|
3115
|
+
if (polygon.length < 3) return false;
|
|
3116
|
+
let inside = false;
|
|
3117
|
+
for (let i = 0, j = polygon.length - 1; i < polygon.length; j = i++) {
|
|
3118
|
+
const a = polygon[i];
|
|
3119
|
+
const b = polygon[j];
|
|
3120
|
+
if (a.y > point.y !== b.y > point.y && point.x < (b.x - a.x) * (point.y - a.y) / (b.y - a.y) + a.x) inside = !inside;
|
|
3121
|
+
}
|
|
3122
|
+
return inside;
|
|
3123
|
+
}
|
|
3124
|
+
/**
|
|
3125
|
+
* Precise per-position zone test.
|
|
3126
|
+
*
|
|
3127
|
+
* - rect zone → any position bbox (normalized) intersects the rect.
|
|
3128
|
+
* - polygon zone → any position CENTER (normalized `x`/`y` — positions
|
|
3129
|
+
* store the bbox center) falls inside the polygon.
|
|
3130
|
+
*
|
|
3131
|
+
* Unknown/degenerate frame dims → `true` (the track cannot be tested, so it
|
|
3132
|
+
* PASSES — mirroring the NULL-envelope-matches rule).
|
|
3133
|
+
*/
|
|
3134
|
+
function positionsIntersectZone(positions, frameWidth, frameHeight, zone) {
|
|
3135
|
+
if (frameWidth === void 0 || frameHeight === void 0 || !(frameWidth > 0) || !(frameHeight > 0)) return true;
|
|
3136
|
+
if (zone.kind === "rect") {
|
|
3137
|
+
const rect = zoneBounds(zone);
|
|
3138
|
+
for (const p of positions) if (envelopesOverlap({
|
|
3139
|
+
minX: p.bbox.x / frameWidth,
|
|
3140
|
+
minY: p.bbox.y / frameHeight,
|
|
3141
|
+
maxX: (p.bbox.x + p.bbox.w) / frameWidth,
|
|
3142
|
+
maxY: (p.bbox.y + p.bbox.h) / frameHeight
|
|
3143
|
+
}, rect)) return true;
|
|
3144
|
+
return false;
|
|
3145
|
+
}
|
|
3146
|
+
for (const p of positions) if (pointInPolygon({
|
|
3147
|
+
x: p.x / frameWidth,
|
|
3148
|
+
y: p.y / frameHeight
|
|
3149
|
+
}, zone.points)) return true;
|
|
3150
|
+
return false;
|
|
3151
|
+
}
|
|
3152
|
+
var DEFAULT_ZERO_MEDIA_POLICY_CONFIG = {
|
|
3153
|
+
suppressMaxDurationMs: 1e3,
|
|
3154
|
+
nothingToShowMaxDurationMs: 1500
|
|
3155
|
+
};
|
|
3156
|
+
/**
|
|
3157
|
+
* Classify a closing track's persistence outcome. Pure — see the module header
|
|
3158
|
+
* for the full contract.
|
|
3159
|
+
*/
|
|
3160
|
+
function decideZeroMediaPolicy(input, config = DEFAULT_ZERO_MEDIA_POLICY_CONFIG) {
|
|
3161
|
+
if (input.hasMedia) return "persist";
|
|
3162
|
+
if (input.durationMs < config.suppressMaxDurationMs && !input.confirmed) return "suppress";
|
|
3163
|
+
if (!input.hasRasterFallback && input.durationMs < config.nothingToShowMaxDurationMs) return "suppress";
|
|
3164
|
+
return input.hasRasterFallback ? "raster-fallback" : "persist";
|
|
3079
3165
|
}
|
|
3080
3166
|
//#endregion
|
|
3081
3167
|
//#region src/pipeline-analytics/pipeline/delete-track-cascade.ts
|
|
@@ -3140,6 +3226,83 @@ async function runTrackCascadeBatch(deps, trackIds) {
|
|
|
3140
3226
|
};
|
|
3141
3227
|
}
|
|
3142
3228
|
//#endregion
|
|
3229
|
+
//#region src/pipeline-analytics/pipeline/last-frame-promotion.ts
|
|
3230
|
+
/**
|
|
3231
|
+
* Decide whether a closing track's newest `snapshot` should be promoted to be
|
|
3232
|
+
* its `lastFrame`. Pure — see the module header for the contract.
|
|
3233
|
+
*
|
|
3234
|
+
* Promote (`mode:'move'`) when there is at least one `snapshot` AND either
|
|
3235
|
+
* there is no `lastFrame` yet, or the newest snapshot is strictly newer than
|
|
3236
|
+
* the held `lastFrame`. With NO snapshot AND NO lastFrame at all (a track that
|
|
3237
|
+
* died within one snapshot interval), fall back to `mode:'copy'` from the
|
|
3238
|
+
* newest `keyFrameSmall` so a short track still closes with a full-frame
|
|
3239
|
+
* "Ultimo" view. Otherwise no promotion.
|
|
3240
|
+
*/
|
|
3241
|
+
function decideLastFramePromotion(media) {
|
|
3242
|
+
let newestSnapshot;
|
|
3243
|
+
let lastFrame;
|
|
3244
|
+
let newestKeyFrameSmall;
|
|
3245
|
+
for (const m of media) if (m.kind === "snapshot") {
|
|
3246
|
+
if (newestSnapshot === void 0 || m.timestamp > newestSnapshot.timestamp) newestSnapshot = m;
|
|
3247
|
+
} else if (m.kind === "lastFrame") {
|
|
3248
|
+
if (lastFrame === void 0 || m.timestamp > lastFrame.timestamp) lastFrame = m;
|
|
3249
|
+
} else if (m.kind === "keyFrameSmall") {
|
|
3250
|
+
if (newestKeyFrameSmall === void 0 || m.timestamp > newestKeyFrameSmall.timestamp) newestKeyFrameSmall = m;
|
|
3251
|
+
}
|
|
3252
|
+
if (newestSnapshot === void 0) {
|
|
3253
|
+
if (lastFrame === void 0 && newestKeyFrameSmall !== void 0) return {
|
|
3254
|
+
promote: true,
|
|
3255
|
+
mode: "copy",
|
|
3256
|
+
snapshotKey: newestKeyFrameSmall.key,
|
|
3257
|
+
snapshotTimestamp: newestKeyFrameSmall.timestamp
|
|
3258
|
+
};
|
|
3259
|
+
return { promote: false };
|
|
3260
|
+
}
|
|
3261
|
+
if (lastFrame !== void 0 && newestSnapshot.timestamp <= lastFrame.timestamp) return { promote: false };
|
|
3262
|
+
return {
|
|
3263
|
+
promote: true,
|
|
3264
|
+
mode: "move",
|
|
3265
|
+
snapshotKey: newestSnapshot.key,
|
|
3266
|
+
snapshotTimestamp: newestSnapshot.timestamp
|
|
3267
|
+
};
|
|
3268
|
+
}
|
|
3269
|
+
//#endregion
|
|
3270
|
+
//#region src/pipeline-analytics/pipeline/static-track-gate.ts
|
|
3271
|
+
/**
|
|
3272
|
+
* Net displacement + path span for a track's centroid path, normalized to
|
|
3273
|
+
* `referenceDiagonalPx`. Returns undefined when there is nothing meaningful to
|
|
3274
|
+
* measure (fewer than two points, or a degenerate ≤0 reference) so the caller
|
|
3275
|
+
* leaves the importance score untouched.
|
|
3276
|
+
*/
|
|
3277
|
+
function computeStaticTrackMetrics(centroids, referenceDiagonalPx) {
|
|
3278
|
+
if (!(referenceDiagonalPx > 0) || centroids.length < 2) return void 0;
|
|
3279
|
+
const first = centroids[0];
|
|
3280
|
+
const last = centroids[centroids.length - 1];
|
|
3281
|
+
const netDisplacementFrac = Math.hypot(last.x - first.x, last.y - first.y) / referenceDiagonalPx;
|
|
3282
|
+
let minX = Infinity;
|
|
3283
|
+
let minY = Infinity;
|
|
3284
|
+
let maxX = -Infinity;
|
|
3285
|
+
let maxY = -Infinity;
|
|
3286
|
+
for (const c of centroids) {
|
|
3287
|
+
if (c.x < minX) minX = c.x;
|
|
3288
|
+
if (c.x > maxX) maxX = c.x;
|
|
3289
|
+
if (c.y < minY) minY = c.y;
|
|
3290
|
+
if (c.y > maxY) maxY = c.y;
|
|
3291
|
+
}
|
|
3292
|
+
return {
|
|
3293
|
+
netDisplacementFrac,
|
|
3294
|
+
pathSpanFrac: Math.hypot(maxX - minX, maxY - minY) / referenceDiagonalPx
|
|
3295
|
+
};
|
|
3296
|
+
}
|
|
3297
|
+
/** Average bbox diagonal (px) across a track's positions — the scale reference
|
|
3298
|
+
* when frame dimensions aren't available. Returns 0 for an empty list. */
|
|
3299
|
+
function averageBboxDiagonal(boxes) {
|
|
3300
|
+
if (boxes.length === 0) return 0;
|
|
3301
|
+
let sum = 0;
|
|
3302
|
+
for (const b of boxes) sum += Math.hypot(b.w, b.h);
|
|
3303
|
+
return sum / boxes.length;
|
|
3304
|
+
}
|
|
3305
|
+
//#endregion
|
|
3143
3306
|
//#region src/pipeline-analytics/pipeline/track-close.ts
|
|
3144
3307
|
var TrackCloser = class {
|
|
3145
3308
|
deps;
|
|
@@ -3210,6 +3373,7 @@ var TrackCloser = class {
|
|
|
3210
3373
|
}
|
|
3211
3374
|
if (outcome === "raster-fallback" && closure?.rasterFallback) await this.commitRasterFallback(t, closure.rasterFallback.jpeg, closure.rasterFallback.timestamp);
|
|
3212
3375
|
await this.maybePromoteLastFrame(t, ownedMedia);
|
|
3376
|
+
await this.maybeDeriveThumbnailFromKeyFrame(t, ownedMedia);
|
|
3213
3377
|
this.deps.logger.info("track ended", {
|
|
3214
3378
|
tags: { deviceId: t.deviceId },
|
|
3215
3379
|
meta: {
|
|
@@ -3220,6 +3384,7 @@ var TrackCloser = class {
|
|
|
3220
3384
|
}
|
|
3221
3385
|
});
|
|
3222
3386
|
const keyFrameMediaKey = this.deps.residents.keyFrameKey(t.trackId);
|
|
3387
|
+
const labelConfidence = this.bestLabelMatchConfidence(t.deviceId, t.trackId);
|
|
3223
3388
|
this.fireRecognizerEnds(t.deviceId, t.trackId);
|
|
3224
3389
|
const trackerPeak = this.deps.residents.bestFramePeak(t.trackId);
|
|
3225
3390
|
const importanceSummary = await this.scoreImportance(t, duration, trackerPeak?.confidence);
|
|
@@ -3228,7 +3393,23 @@ var TrackCloser = class {
|
|
|
3228
3393
|
this.deps.overlayState.onTrackEnded(t.deviceId, t.trackId);
|
|
3229
3394
|
this.flushCaptureWindowIfIdle(t.deviceId);
|
|
3230
3395
|
this.emitEndEvents(t, duration, trackerPeak?.confidence, keyFrameMediaKey, importanceSummary);
|
|
3231
|
-
this.deps.
|
|
3396
|
+
const dims = this.deps.frameDims(t.deviceId);
|
|
3397
|
+
const envelope = computeTrackEnvelope(t.positions, dims?.w, dims?.h);
|
|
3398
|
+
this.deps.onTrackClosed?.(t, ownedMedia, {
|
|
3399
|
+
...trackerPeak?.confidence !== void 0 ? { bestConfidence: trackerPeak.confidence } : {},
|
|
3400
|
+
...importanceSummary.importance !== void 0 ? { importance: importanceSummary.importance } : {},
|
|
3401
|
+
...labelConfidence !== void 0 ? { labelConfidence } : {},
|
|
3402
|
+
...envelope !== null ? { envelope } : {}
|
|
3403
|
+
});
|
|
3404
|
+
}
|
|
3405
|
+
/** Max recognition match confidence over the face + plate recognizers for a
|
|
3406
|
+
* closing track (undefined when neither recognized a label). */
|
|
3407
|
+
bestLabelMatchConfidence(_deviceId, trackId) {
|
|
3408
|
+
const face = this.deps.faceRecognizer()?.bestLabelMatchConfidence?.(trackId);
|
|
3409
|
+
const plate = this.deps.plateRecognizer()?.bestLabelMatchConfidence?.(trackId);
|
|
3410
|
+
if (face === void 0) return plate;
|
|
3411
|
+
if (plate === void 0) return face;
|
|
3412
|
+
return Math.max(face, plate);
|
|
3232
3413
|
}
|
|
3233
3414
|
/**
|
|
3234
3415
|
* Undo a zero-media false-birth track (spec §"Zero-media track policy"): tear
|
|
@@ -3295,27 +3476,95 @@ var TrackCloser = class {
|
|
|
3295
3476
|
}
|
|
3296
3477
|
}
|
|
3297
3478
|
/**
|
|
3298
|
-
*
|
|
3299
|
-
*
|
|
3300
|
-
*
|
|
3301
|
-
*
|
|
3302
|
-
*
|
|
3303
|
-
*
|
|
3304
|
-
*
|
|
3479
|
+
* Close-time keyFrame-derived `thumbnail` fallback (#27-A part 3): when the
|
|
3480
|
+
* closing track's owned media has NO `thumbnail` but HAS a native `keyFrame`,
|
|
3481
|
+
* derive the thumbnail (+ `thumbnailSmall`) from the persisted keyFrame,
|
|
3482
|
+
* cropping the BEST-seen subject bbox with the standard 16:9 central-square
|
|
3483
|
+
* framing (the derive is injected — `createKeyFrameCrop` composed with the
|
|
3484
|
+
* wide-central-square layout in the addon wiring). Best-effort with its own
|
|
3485
|
+
* catch: any failure leaves the track as it was and never blocks the close.
|
|
3305
3486
|
*/
|
|
3306
|
-
async
|
|
3307
|
-
const
|
|
3308
|
-
if (!
|
|
3309
|
-
|
|
3310
|
-
|
|
3487
|
+
async maybeDeriveThumbnailFromKeyFrame(t, ownedMedia) {
|
|
3488
|
+
const derive = this.deps.deriveThumbnailFromKeyFrame;
|
|
3489
|
+
if (!derive) return;
|
|
3490
|
+
if (ownedMedia.some((m) => m.kind === "thumbnail")) return;
|
|
3491
|
+
const keyFrame = ownedMedia.find((m) => m.kind === "keyFrame");
|
|
3492
|
+
if (!keyFrame) return;
|
|
3493
|
+
const bestSeen = this.deps.residents.bestSeenBbox(t.trackId);
|
|
3494
|
+
if (!bestSeen) return;
|
|
3311
3495
|
try {
|
|
3312
|
-
await
|
|
3313
|
-
|
|
3314
|
-
|
|
3315
|
-
|
|
3496
|
+
const derived = await derive({
|
|
3497
|
+
mediaKey: keyFrame.key,
|
|
3498
|
+
bbox: {
|
|
3499
|
+
x: bestSeen.x,
|
|
3500
|
+
y: bestSeen.y,
|
|
3501
|
+
w: bestSeen.w,
|
|
3502
|
+
h: bestSeen.h
|
|
3503
|
+
},
|
|
3504
|
+
frameWidth: bestSeen.frameWidth,
|
|
3505
|
+
frameHeight: bestSeen.frameHeight,
|
|
3506
|
+
timestamp: bestSeen.timestamp
|
|
3316
3507
|
});
|
|
3317
|
-
|
|
3318
|
-
this.deps.
|
|
3508
|
+
if (!derived) return;
|
|
3509
|
+
const mediaStore = this.deps.mediaStore();
|
|
3510
|
+
if (!mediaStore) return;
|
|
3511
|
+
await mediaStore.put({
|
|
3512
|
+
deviceId: t.deviceId,
|
|
3513
|
+
ownerKind: "track",
|
|
3514
|
+
ownerId: t.trackId,
|
|
3515
|
+
kind: "thumbnail",
|
|
3516
|
+
timestamp: bestSeen.timestamp,
|
|
3517
|
+
data: derived.thumbnail
|
|
3518
|
+
});
|
|
3519
|
+
await mediaStore.put({
|
|
3520
|
+
deviceId: t.deviceId,
|
|
3521
|
+
ownerKind: "track",
|
|
3522
|
+
ownerId: t.trackId,
|
|
3523
|
+
kind: "thumbnailSmall",
|
|
3524
|
+
timestamp: bestSeen.timestamp,
|
|
3525
|
+
data: derived.thumbnailSmall
|
|
3526
|
+
});
|
|
3527
|
+
this.deps.logger.info("thumbnail derived from keyFrame at close (no live best-shot landed)", {
|
|
3528
|
+
tags: { deviceId: t.deviceId },
|
|
3529
|
+
meta: {
|
|
3530
|
+
trackId: t.trackId,
|
|
3531
|
+
keyFrameKey: keyFrame.key,
|
|
3532
|
+
skewMs: derived.skewMs
|
|
3533
|
+
}
|
|
3534
|
+
});
|
|
3535
|
+
} catch (err) {
|
|
3536
|
+
this.deps.logger.debug("close-time keyFrame thumbnail derive failed", {
|
|
3537
|
+
tags: { deviceId: t.deviceId },
|
|
3538
|
+
meta: {
|
|
3539
|
+
trackId: t.trackId,
|
|
3540
|
+
error: String(err)
|
|
3541
|
+
}
|
|
3542
|
+
});
|
|
3543
|
+
}
|
|
3544
|
+
}
|
|
3545
|
+
/**
|
|
3546
|
+
* Genuinely-last view (operator, 2026-07-22): the rolling `lastFrame` never
|
|
3547
|
+
* fires on a `snapshot` frame and rolls on its own cadence, so at close it
|
|
3548
|
+
* can trail the newest appended snapshot by up to one interval. If a newer
|
|
3549
|
+
* snapshot exists (or there is no lastFrame but ≥1 snapshot), PROMOTE it
|
|
3550
|
+
* into the single lastFrame slot and drop the snapshot row — one
|
|
3551
|
+
* genuinely-last view, no duplicate pair. Reuses the `ownedMedia` list
|
|
3552
|
+
* already fetched by the caller (base64 present → no re-read).
|
|
3553
|
+
*/
|
|
3554
|
+
async maybePromoteLastFrame(t, ownedMedia) {
|
|
3555
|
+
const promotion = decideLastFramePromotion(ownedMedia);
|
|
3556
|
+
if (!promotion.promote) return;
|
|
3557
|
+
const snapshot = ownedMedia.find((m) => m.key === promotion.snapshotKey);
|
|
3558
|
+
if (!snapshot) return;
|
|
3559
|
+
try {
|
|
3560
|
+
await this.deps.mediaStore()?.promoteToLastFrame({
|
|
3561
|
+
deviceId: t.deviceId,
|
|
3562
|
+
trackId: t.trackId,
|
|
3563
|
+
snapshot,
|
|
3564
|
+
keepSource: promotion.mode === "copy"
|
|
3565
|
+
});
|
|
3566
|
+
} catch (err) {
|
|
3567
|
+
this.deps.logger.debug("lastFrame promotion failed", {
|
|
3319
3568
|
tags: { deviceId: t.deviceId },
|
|
3320
3569
|
meta: {
|
|
3321
3570
|
trackId: t.trackId,
|
|
@@ -3416,6 +3665,23 @@ var TrackCloser = class {
|
|
|
3416
3665
|
};
|
|
3417
3666
|
//#endregion
|
|
3418
3667
|
//#region src/notification-center/rule-engine.ts
|
|
3668
|
+
/**
|
|
3669
|
+
* Normalize a PIXEL-space detection bbox onto 0..1 using its detection-frame
|
|
3670
|
+
* dims. Returns `undefined` when the box or the dims are missing/degenerate
|
|
3671
|
+
* (dim ≤ 0) — the caller then omits the subject bbox and `customZones` fails
|
|
3672
|
+
* closed rather than testing pixel coords against a 0..1 polygon.
|
|
3673
|
+
*/
|
|
3674
|
+
function normalizeBbox(bbox, frameWidth, frameHeight) {
|
|
3675
|
+
if (bbox === void 0) return void 0;
|
|
3676
|
+
if (frameWidth === void 0 || frameHeight === void 0) return void 0;
|
|
3677
|
+
if (frameWidth <= 0 || frameHeight <= 0) return void 0;
|
|
3678
|
+
return {
|
|
3679
|
+
x: bbox.x / frameWidth,
|
|
3680
|
+
y: bbox.y / frameHeight,
|
|
3681
|
+
w: bbox.w / frameWidth,
|
|
3682
|
+
h: bbox.h / frameHeight
|
|
3683
|
+
};
|
|
3684
|
+
}
|
|
3419
3685
|
/** Build the subject for an `immediate` (object-event) evaluation. */
|
|
3420
3686
|
function subjectFromObjectEvent(ev) {
|
|
3421
3687
|
return {
|
|
@@ -3427,7 +3693,132 @@ function subjectFromObjectEvent(ev) {
|
|
|
3427
3693
|
...ev.label !== void 0 ? { label: ev.label } : {},
|
|
3428
3694
|
...ev.confidence !== void 0 ? { confidence: ev.confidence } : {},
|
|
3429
3695
|
zones: ev.zones ?? [],
|
|
3430
|
-
...ev.trackId !== void 0 ? { trackId: ev.trackId } : {}
|
|
3696
|
+
...ev.trackId !== void 0 ? { trackId: ev.trackId } : {},
|
|
3697
|
+
source: ev.source ?? "pipeline",
|
|
3698
|
+
...ev.importance !== void 0 ? { importance: ev.importance } : {},
|
|
3699
|
+
...bboxPatch(normalizeBbox(ev.bbox, ev.frameWidth, ev.frameHeight))
|
|
3700
|
+
};
|
|
3701
|
+
}
|
|
3702
|
+
/** Spread an optional normalized bbox onto a subject (present only if set). */
|
|
3703
|
+
function bboxPatch(bbox) {
|
|
3704
|
+
return bbox !== void 0 ? { bbox } : {};
|
|
3705
|
+
}
|
|
3706
|
+
/** Convert a normalized 0..1 track envelope (min/max) into the subject's
|
|
3707
|
+
* top-left+size `BboxRect`. `undefined` in ⇒ `undefined` out (fail closed). */
|
|
3708
|
+
function envelopeToBbox(envelope) {
|
|
3709
|
+
if (envelope === void 0) return void 0;
|
|
3710
|
+
return {
|
|
3711
|
+
x: envelope.minX,
|
|
3712
|
+
y: envelope.minY,
|
|
3713
|
+
w: envelope.maxX - envelope.minX,
|
|
3714
|
+
h: envelope.maxY - envelope.minY
|
|
3715
|
+
};
|
|
3716
|
+
}
|
|
3717
|
+
/**
|
|
3718
|
+
* Read the raw device event-type token off a persisted `SensorEvent.value`
|
|
3719
|
+
* slice. Only the event-emitter cap carries one (`EventEmitterStatus.lastEvent
|
|
3720
|
+
* .eventType`); doorbell-pulse / passive-sensor slices have none. Pure +
|
|
3721
|
+
* defensive over the untyped record snapshot.
|
|
3722
|
+
*/
|
|
3723
|
+
function readSensorEventType(value) {
|
|
3724
|
+
if (value === null) return void 0;
|
|
3725
|
+
const last = value["lastEvent"];
|
|
3726
|
+
if (last === null || typeof last !== "object" || Array.isArray(last)) return void 0;
|
|
3727
|
+
const eventType = last["eventType"];
|
|
3728
|
+
return typeof eventType === "string" && eventType.length > 0 ? eventType : void 0;
|
|
3729
|
+
}
|
|
3730
|
+
/** Build the subject for a `device-event` evaluation (a persisted SensorEvent —
|
|
3731
|
+
* one row per linked camera; `deviceId` is the CAMERA). */
|
|
3732
|
+
function subjectFromSensorEvent(ev) {
|
|
3733
|
+
const eventType = readSensorEventType(ev.value);
|
|
3734
|
+
return {
|
|
3735
|
+
kind: "device-event",
|
|
3736
|
+
recordId: ev.id,
|
|
3737
|
+
deviceId: ev.deviceId,
|
|
3738
|
+
timestamp: ev.timestamp,
|
|
3739
|
+
classNames: [],
|
|
3740
|
+
zones: [],
|
|
3741
|
+
source: "sensor",
|
|
3742
|
+
sensorKind: ev.kind,
|
|
3743
|
+
...eventType !== void 0 ? { eventType } : {}
|
|
3744
|
+
};
|
|
3745
|
+
}
|
|
3746
|
+
/**
|
|
3747
|
+
* Build the subject for an AUDIO evaluation. Audio classification events
|
|
3748
|
+
* (`eventStore.insertAudio`) persist on a SEPARATE path from object events and
|
|
3749
|
+
* never reach the object-event hook — this normalizes one onto the engine
|
|
3750
|
+
* subject so an `immediate` rule that OPTS IN to an `audio-*` class can fire on
|
|
3751
|
+
* it. The class id is the SAME namespaced `audio-<macroClass>` id the NC
|
|
3752
|
+
* taxonomy exposes (see `nc-taxonomy.ts` audioKinds), so a picker selection
|
|
3753
|
+
* matches exactly. `confidence` carries the classification score (so a
|
|
3754
|
+
* `minConfidence` condition composes naturally). A level-path audio event (no
|
|
3755
|
+
* `classification`) yields NO class ⇒ it can never satisfy the audio opt-in
|
|
3756
|
+
* gate, so only classified audio ever notifies (documented boundary). Audio has
|
|
3757
|
+
* no zones / bbox / label / track, so the object/track-specific conditions all
|
|
3758
|
+
* fail closed (see the {@link evaluateRule} audio gate + the matchers below).
|
|
3759
|
+
*/
|
|
3760
|
+
function subjectFromAudioEvent(ev) {
|
|
3761
|
+
const macro = ev.classification?.className;
|
|
3762
|
+
return {
|
|
3763
|
+
kind: "audio-event",
|
|
3764
|
+
recordId: ev.id,
|
|
3765
|
+
deviceId: ev.deviceId,
|
|
3766
|
+
timestamp: ev.timestamp,
|
|
3767
|
+
classNames: macro !== void 0 ? [`audio-${macro}`] : [],
|
|
3768
|
+
...ev.classification?.score !== void 0 ? { confidence: ev.classification.score } : {},
|
|
3769
|
+
zones: [],
|
|
3770
|
+
source: "pipeline"
|
|
3771
|
+
};
|
|
3772
|
+
}
|
|
3773
|
+
/**
|
|
3774
|
+
* Build the subject for an OCCUPANCY evaluation. A committed `OccupancyEdge`
|
|
3775
|
+
* (ZoneAnalytics count crossing) rides the EXISTING `device-event` delivery via
|
|
3776
|
+
* this INTERNAL subject kind (the audio pattern with a device-event landing —
|
|
3777
|
+
* `device-event` is already a native `NcHistoryRecordKind`, so no history
|
|
3778
|
+
* downgrade is needed). The edge scope + count/threshold ride on `occupancy`;
|
|
3779
|
+
* the engine's occupancy branch matches the rule's `occupancy` condition against
|
|
3780
|
+
* it. Admin-zone provenance is NOT copied onto `zones` (kept empty) so the admin
|
|
3781
|
+
* `zones` condition FAILS CLOSED on occupancy — zone scoping is done inside the
|
|
3782
|
+
* occupancy condition (`occupancy.zoneId`). No detection confidence / label /
|
|
3783
|
+
* bbox / importance / sensorKind, so those conditions all fail closed too.
|
|
3784
|
+
*/
|
|
3785
|
+
function subjectFromOccupancyEvent(edge) {
|
|
3786
|
+
return {
|
|
3787
|
+
kind: "occupancy-event",
|
|
3788
|
+
recordId: `occ:${edge.deviceId}:${edge.zoneId ?? "@frame"}:${edge.className ?? "@all"}:t${edge.threshold}:${edge.occupied ? "occ" : "free"}:${edge.timestamp}`,
|
|
3789
|
+
deviceId: edge.deviceId,
|
|
3790
|
+
timestamp: edge.timestamp,
|
|
3791
|
+
classNames: [edge.className ?? "occupancy"],
|
|
3792
|
+
zones: [],
|
|
3793
|
+
source: "pipeline",
|
|
3794
|
+
occupancy: {
|
|
3795
|
+
...edge.zoneId !== void 0 ? { zoneId: edge.zoneId } : {},
|
|
3796
|
+
...edge.zoneName !== void 0 ? { zoneName: edge.zoneName } : {},
|
|
3797
|
+
...edge.className !== void 0 ? { className: edge.className } : {},
|
|
3798
|
+
count: edge.count,
|
|
3799
|
+
previousCount: edge.previousCount,
|
|
3800
|
+
occupied: edge.occupied,
|
|
3801
|
+
threshold: edge.threshold
|
|
3802
|
+
}
|
|
3803
|
+
};
|
|
3804
|
+
}
|
|
3805
|
+
/** Build the subject for a `package-event` evaluation (a persisted `package`
|
|
3806
|
+
* object-event; `phase` derives from the event `state` at the call site). */
|
|
3807
|
+
function subjectFromPackageEvent(ev, phase) {
|
|
3808
|
+
return {
|
|
3809
|
+
kind: "package-event",
|
|
3810
|
+
recordId: ev.id,
|
|
3811
|
+
deviceId: ev.deviceId,
|
|
3812
|
+
timestamp: ev.timestamp,
|
|
3813
|
+
classNames: [ev.className],
|
|
3814
|
+
...ev.label !== void 0 ? { label: ev.label } : {},
|
|
3815
|
+
...ev.confidence !== void 0 ? { confidence: ev.confidence } : {},
|
|
3816
|
+
zones: ev.zones ?? [],
|
|
3817
|
+
...ev.trackId !== void 0 ? { trackId: ev.trackId } : {},
|
|
3818
|
+
source: ev.source ?? "pipeline",
|
|
3819
|
+
...ev.importance !== void 0 ? { importance: ev.importance } : {},
|
|
3820
|
+
...bboxPatch(normalizeBbox(ev.bbox, ev.frameWidth, ev.frameHeight)),
|
|
3821
|
+
packagePhase: phase
|
|
3431
3822
|
};
|
|
3432
3823
|
}
|
|
3433
3824
|
/** Build the subject for a `track-end` evaluation. */
|
|
@@ -3441,20 +3832,105 @@ function subjectFromTrack(track, info) {
|
|
|
3441
3832
|
...track.label !== void 0 ? { label: track.label } : {},
|
|
3442
3833
|
...info?.bestConfidence !== void 0 ? { confidence: info.bestConfidence } : {},
|
|
3443
3834
|
zones: track.zonesVisited,
|
|
3444
|
-
trackId: track.trackId
|
|
3835
|
+
trackId: track.trackId,
|
|
3836
|
+
source: track.source ?? "pipeline",
|
|
3837
|
+
...(info?.importance ?? track.importance) !== void 0 ? { importance: info?.importance ?? track.importance } : {},
|
|
3838
|
+
dwellSeconds: (track.lastSeen - track.firstSeen) / 1e3,
|
|
3839
|
+
...info?.labelConfidence !== void 0 ? { labelConfidence: info.labelConfidence } : {},
|
|
3840
|
+
...bboxPatch(envelopeToBbox(info?.envelope ?? track.envelope))
|
|
3445
3841
|
};
|
|
3446
3842
|
}
|
|
3447
|
-
var PASS = { matched: true };
|
|
3448
3843
|
function fail(condition) {
|
|
3449
3844
|
return {
|
|
3450
3845
|
matched: false,
|
|
3451
3846
|
failedCondition: condition
|
|
3452
3847
|
};
|
|
3453
3848
|
}
|
|
3849
|
+
/**
|
|
3850
|
+
* The condition ids PRESENT on a rule (an empty/absent group contributes
|
|
3851
|
+
* nothing). Order follows the catalog. On a matched rule every id returned
|
|
3852
|
+
* here passed, so the list is the rule's "matched on" summary.
|
|
3853
|
+
*/
|
|
3854
|
+
function presentConditionIds(c) {
|
|
3855
|
+
const ids = [];
|
|
3856
|
+
if (c.devices !== void 0 && c.devices.length > 0) ids.push("devices");
|
|
3857
|
+
if (c.source !== void 0 && c.source !== "any") ids.push("source");
|
|
3858
|
+
if (c.classes !== void 0 && c.classes.length > 0) ids.push("classes");
|
|
3859
|
+
if (c.classesExclude !== void 0 && c.classesExclude.length > 0) ids.push("classesExclude");
|
|
3860
|
+
if (c.minConfidence !== void 0) ids.push("minConfidence");
|
|
3861
|
+
if (c.minImportance !== void 0) ids.push("minImportance");
|
|
3862
|
+
if (c.minDwellSeconds !== void 0) ids.push("minDwellSeconds");
|
|
3863
|
+
if (c.zones !== void 0) ids.push("zones");
|
|
3864
|
+
if (c.zonesExclude !== void 0 && c.zonesExclude.length > 0) ids.push("zonesExclude");
|
|
3865
|
+
if (c.labelEquals !== void 0 && c.labelEquals.length > 0) ids.push("labelEquals");
|
|
3866
|
+
if (c.identities !== void 0 && c.identities.length > 0) ids.push("identities");
|
|
3867
|
+
if (c.identitiesExclude !== void 0 && c.identitiesExclude.length > 0) ids.push("identitiesExclude");
|
|
3868
|
+
if (c.minLabelConfidence !== void 0) ids.push("minLabelConfidence");
|
|
3869
|
+
if (c.plates !== void 0) ids.push("plates");
|
|
3870
|
+
if (c.sensorKinds !== void 0 && c.sensorKinds.length > 0) ids.push("sensorKinds");
|
|
3871
|
+
if (c.eventTypeTokens !== void 0 && c.eventTypeTokens.length > 0) ids.push("eventTypeTokens");
|
|
3872
|
+
if (c.packagePhase !== void 0 && c.packagePhase !== "both") ids.push("packagePhase");
|
|
3873
|
+
if (c.customZones !== void 0 && c.customZones.length > 0) ids.push("customZones");
|
|
3874
|
+
if (c.occupancy !== void 0) ids.push("occupancy");
|
|
3875
|
+
return ids;
|
|
3876
|
+
}
|
|
3877
|
+
/**
|
|
3878
|
+
* Match a rule's occupancy condition against the subject's committed count-edge.
|
|
3879
|
+
* The scope (`zoneId`/`className`) must match EXACTLY (fail-closed), then the
|
|
3880
|
+
* `op` selects the edge polarity + threshold:
|
|
3881
|
+
* - `became-occupied` / `>=` C → the occupied (false→true) edge of the key
|
|
3882
|
+
* whose threshold is C.
|
|
3883
|
+
* - `became-free` C → the un-occupied (true→false) edge of key C.
|
|
3884
|
+
* - `<=` C → the un-occupied edge of key C+1 (count dropped
|
|
3885
|
+
* from >C to ≤C — the predicate `count ≥ C+1` went false).
|
|
3886
|
+
* Per-threshold keying guarantees the subject's `threshold` already equals the
|
|
3887
|
+
* key's, so a rule matches only its OWN edge — a ≥1 and a ≥3 rule never
|
|
3888
|
+
* cross-fire on a gradual accumulation.
|
|
3889
|
+
*/
|
|
3890
|
+
function matchesOccupancy(occ, s) {
|
|
3891
|
+
if ((occ.zoneId ?? void 0) !== (s.zoneId ?? void 0)) return false;
|
|
3892
|
+
if ((occ.className ?? void 0) !== (s.className ?? void 0)) return false;
|
|
3893
|
+
switch (occ.op) {
|
|
3894
|
+
case "became-occupied":
|
|
3895
|
+
case ">=": return s.occupied === true && s.threshold === occ.count;
|
|
3896
|
+
case "became-free": return s.occupied === false && s.threshold === occ.count;
|
|
3897
|
+
case "<=": return s.occupied === false && s.threshold === occ.count + 1;
|
|
3898
|
+
}
|
|
3899
|
+
}
|
|
3454
3900
|
function toLowerSet(values) {
|
|
3455
3901
|
return new Set(values.map((v) => v.trim().toLowerCase()));
|
|
3456
3902
|
}
|
|
3457
3903
|
/**
|
|
3904
|
+
* Expand each selected class id to itself PLUS its taxonomy leaf subs, so a
|
|
3905
|
+
* MACRO selection (`vehicle`) matches its sub classNames (`car`/`truck`) — the
|
|
3906
|
+
* `EVENT_TAXONOMY` tree is the SINGLE source (mirrors the timeline/filter
|
|
3907
|
+
* grouping). A leaf id expands to just itself. Applies to the video class
|
|
3908
|
+
* matchers (`classes` / `classesExclude`); audio ids (`audio-*`) are leaves so
|
|
3909
|
+
* they expand to themselves. Lower-cased for the case-insensitive membership
|
|
3910
|
+
* test.
|
|
3911
|
+
*/
|
|
3912
|
+
function expandClassSelector(classes) {
|
|
3913
|
+
const out = /* @__PURE__ */ new Set();
|
|
3914
|
+
for (const raw of classes) {
|
|
3915
|
+
const c = raw.trim().toLowerCase();
|
|
3916
|
+
if (c.length === 0) continue;
|
|
3917
|
+
out.add(c);
|
|
3918
|
+
for (const subEntry of subKindsOf(c)) out.add(subEntry.kind.trim().toLowerCase());
|
|
3919
|
+
}
|
|
3920
|
+
return out;
|
|
3921
|
+
}
|
|
3922
|
+
/**
|
|
3923
|
+
* True when a rule's `classes` condition explicitly opts in to at least one
|
|
3924
|
+
* audio kind (`audio-*` id). The safety gate for audio subjects: an `immediate`
|
|
3925
|
+
* rule fires on an audio subject ONLY when it names an audio class — a rule with
|
|
3926
|
+
* no classes, or with only video classes, NEVER fires on audio (preserves
|
|
3927
|
+
* today's behavior, where classified audio never reached the engine at all).
|
|
3928
|
+
*/
|
|
3929
|
+
function referencesAudioClass(classes) {
|
|
3930
|
+
if (classes === void 0) return false;
|
|
3931
|
+
return classes.some((c) => c.trim().toLowerCase().startsWith("audio-"));
|
|
3932
|
+
}
|
|
3933
|
+
/**
|
|
3458
3934
|
* Evaluate one rule against one subject. The rule's `delivery` must match
|
|
3459
3935
|
* the subject kind (`immediate` ↔ `object-event`, `track-end` ↔
|
|
3460
3936
|
* `track-end`) — a mismatch fails with `'delivery'`. Schedule is evaluated
|
|
@@ -3465,12 +3941,27 @@ function toLowerSet(values) {
|
|
|
3465
3941
|
* is stateful; see {@link cooldownKey} / {@link isCoolingDown}.
|
|
3466
3942
|
*/
|
|
3467
3943
|
function evaluateRule(rule, subject) {
|
|
3468
|
-
if (
|
|
3944
|
+
if (subject.kind === "occupancy-event") {
|
|
3945
|
+
if (rule.delivery !== "device-event") return fail("delivery");
|
|
3946
|
+
const occ = rule.conditions.occupancy;
|
|
3947
|
+
if (occ === void 0) return fail("occupancy");
|
|
3948
|
+
if (subject.occupancy === void 0 || !matchesOccupancy(occ, subject.occupancy)) return fail("occupancy");
|
|
3949
|
+
} else if (subject.kind === "audio-event") {
|
|
3950
|
+
if (rule.delivery !== "immediate") return fail("delivery");
|
|
3951
|
+
if (!referencesAudioClass(rule.conditions.classes)) return fail("classes");
|
|
3952
|
+
if (rule.conditions.occupancy !== void 0) return fail("occupancy");
|
|
3953
|
+
} else {
|
|
3954
|
+
if ((rule.delivery === "immediate" ? "object-event" : rule.delivery) !== subject.kind) return fail("delivery");
|
|
3955
|
+
if (rule.conditions.occupancy !== void 0) return fail("occupancy");
|
|
3956
|
+
}
|
|
3469
3957
|
const c = rule.conditions;
|
|
3470
3958
|
if (c.devices !== void 0 && c.devices.length > 0 && !c.devices.includes(subject.deviceId)) return fail("devices");
|
|
3959
|
+
if (c.source !== void 0 && c.source !== "any") {
|
|
3960
|
+
if ((subject.source ?? "pipeline") !== c.source) return fail("source");
|
|
3961
|
+
}
|
|
3471
3962
|
const subjectClasses = toLowerSet(subject.classNames);
|
|
3472
3963
|
if (c.classes !== void 0 && c.classes.length > 0) {
|
|
3473
|
-
const wanted =
|
|
3964
|
+
const wanted = expandClassSelector(c.classes);
|
|
3474
3965
|
let overlap = false;
|
|
3475
3966
|
for (const cls of subjectClasses) if (wanted.has(cls)) {
|
|
3476
3967
|
overlap = true;
|
|
@@ -3479,12 +3970,18 @@ function evaluateRule(rule, subject) {
|
|
|
3479
3970
|
if (!overlap) return fail("classes");
|
|
3480
3971
|
}
|
|
3481
3972
|
if (c.classesExclude !== void 0 && c.classesExclude.length > 0) {
|
|
3482
|
-
const vetoed =
|
|
3973
|
+
const vetoed = expandClassSelector(c.classesExclude);
|
|
3483
3974
|
for (const cls of subjectClasses) if (vetoed.has(cls)) return fail("classesExclude");
|
|
3484
3975
|
}
|
|
3485
3976
|
if (c.minConfidence !== void 0) {
|
|
3486
3977
|
if (subject.confidence === void 0 || subject.confidence < c.minConfidence) return fail("minConfidence");
|
|
3487
3978
|
}
|
|
3979
|
+
if (c.minImportance !== void 0) {
|
|
3980
|
+
if (subject.importance === void 0 || subject.importance < c.minImportance) return fail("minImportance");
|
|
3981
|
+
}
|
|
3982
|
+
if (c.minDwellSeconds !== void 0) {
|
|
3983
|
+
if (subject.dwellSeconds === void 0 || subject.dwellSeconds < c.minDwellSeconds) return fail("minDwellSeconds");
|
|
3984
|
+
}
|
|
3488
3985
|
if (c.zones !== void 0) {
|
|
3489
3986
|
const visited = new Set(subject.zones);
|
|
3490
3987
|
if (c.zones.match === "all") {
|
|
@@ -3495,6 +3992,10 @@ function evaluateRule(rule, subject) {
|
|
|
3495
3992
|
const visited = new Set(subject.zones);
|
|
3496
3993
|
if (c.zonesExclude.some((id) => visited.has(id))) return fail("zonesExclude");
|
|
3497
3994
|
}
|
|
3995
|
+
if (c.customZones !== void 0 && c.customZones.length > 0) {
|
|
3996
|
+
const bbox = subject.bbox;
|
|
3997
|
+
if (!(bbox !== void 0 && c.customZones.some((poly) => bboxPolygonOverlap(bbox, poly.points) > 0))) return fail("customZones");
|
|
3998
|
+
}
|
|
3498
3999
|
const label = subject.label?.trim().toLowerCase();
|
|
3499
4000
|
if (c.labelEquals !== void 0 && c.labelEquals.length > 0) {
|
|
3500
4001
|
if (label === void 0 || !toLowerSet(c.labelEquals).has(label)) return fail("labelEquals");
|
|
@@ -3502,11 +4003,29 @@ function evaluateRule(rule, subject) {
|
|
|
3502
4003
|
if (c.identities !== void 0 && c.identities.length > 0) {
|
|
3503
4004
|
if (label === void 0 || !toLowerSet(c.identities).has(label)) return fail("identities");
|
|
3504
4005
|
}
|
|
4006
|
+
if (c.identitiesExclude !== void 0 && c.identitiesExclude.length > 0) {
|
|
4007
|
+
if (label !== void 0 && toLowerSet(c.identitiesExclude).has(label)) return fail("identitiesExclude");
|
|
4008
|
+
}
|
|
4009
|
+
if (c.minLabelConfidence !== void 0) {
|
|
4010
|
+
if (subject.labelConfidence === void 0 || subject.labelConfidence < c.minLabelConfidence) return fail("minLabelConfidence");
|
|
4011
|
+
}
|
|
3505
4012
|
if (c.plates !== void 0) {
|
|
3506
4013
|
if (subject.label === void 0 || !matchesPlate(subject.label, c.plates.values, c.plates.maxDistance)) return fail("plates");
|
|
3507
4014
|
}
|
|
4015
|
+
if (c.sensorKinds !== void 0 && c.sensorKinds.length > 0) {
|
|
4016
|
+
if (subject.sensorKind === void 0 || !toLowerSet(c.sensorKinds).has(subject.sensorKind.trim().toLowerCase())) return fail("sensorKinds");
|
|
4017
|
+
}
|
|
4018
|
+
if (c.eventTypeTokens !== void 0 && c.eventTypeTokens.length > 0) {
|
|
4019
|
+
if (subject.eventType === void 0 || !toLowerSet(c.eventTypeTokens).has(subject.eventType.trim().toLowerCase())) return fail("eventTypeTokens");
|
|
4020
|
+
}
|
|
4021
|
+
if (c.packagePhase !== void 0 && c.packagePhase !== "both") {
|
|
4022
|
+
if (subject.packagePhase !== c.packagePhase) return fail("packagePhase");
|
|
4023
|
+
}
|
|
3508
4024
|
if (!isScheduleActive(rule.schedule, subject.timestamp)) return fail("schedule");
|
|
3509
|
-
return
|
|
4025
|
+
return {
|
|
4026
|
+
matched: true,
|
|
4027
|
+
matchedOn: presentConditionIds(c)
|
|
4028
|
+
};
|
|
3510
4029
|
}
|
|
3511
4030
|
var WEEKDAY_TO_DAY = {
|
|
3512
4031
|
Sun: 0,
|
|
@@ -3601,7 +4120,8 @@ function matchesPlate(label, values, maxDistance) {
|
|
|
3601
4120
|
}
|
|
3602
4121
|
/** Stable cooldown key per the rule's throttle scope. */
|
|
3603
4122
|
function cooldownKey(rule, subject) {
|
|
3604
|
-
|
|
4123
|
+
const audioClass = subject.kind === "audio-event" && subject.classNames[0] !== void 0 ? `:c:${subject.classNames[0]}` : "";
|
|
4124
|
+
return rule.throttle.scope === "rule" ? `r:${rule.id}${audioClass}` : `r:${rule.id}:d:${subject.deviceId}${audioClass}`;
|
|
3605
4125
|
}
|
|
3606
4126
|
/** True when the rule fired within its cooldown window before `now`. */
|
|
3607
4127
|
function isCoolingDown(rule, lastFiredAt, now) {
|
|
@@ -3636,171 +4156,231 @@ function attachmentKindPreference(policy, ownerKind) {
|
|
|
3636
4156
|
"fullFrameBoxed"
|
|
3637
4157
|
];
|
|
3638
4158
|
}
|
|
4159
|
+
/**
|
|
4160
|
+
* Derive the `best-matching` media signal from a matched rule's condition
|
|
4161
|
+
* summary ({@link NcEvaluation.matchedOn}). Identity takes priority over plate
|
|
4162
|
+
* (D-3 ordering: a face rule that ALSO plate-matched attaches the face crop).
|
|
4163
|
+
*/
|
|
4164
|
+
function matchSignal(matchedOn) {
|
|
4165
|
+
if (matchedOn === void 0) return null;
|
|
4166
|
+
if (matchedOn.includes("identities")) return "face";
|
|
4167
|
+
if (matchedOn.includes("plates")) return "plate";
|
|
4168
|
+
return null;
|
|
4169
|
+
}
|
|
4170
|
+
/**
|
|
4171
|
+
* Ordered media-kind preference for a `best-matching` attachment. The
|
|
4172
|
+
* signal-specific crop (`faceCrop` / `plateCrop`, both event-owned) leads;
|
|
4173
|
+
* then the plain `best` subject ladder, then the `keyFrame` clean-scene
|
|
4174
|
+
* ladder — so a missing specific crop degrades to best → keyFrame → none
|
|
4175
|
+
* (the dispatcher returns null when nothing resolves) without ever blocking
|
|
4176
|
+
* the send. A `null` signal is exactly the `best` ladder.
|
|
4177
|
+
*/
|
|
4178
|
+
function bestMatchingKindPreference(signal, ownerKind) {
|
|
4179
|
+
return [
|
|
4180
|
+
...signal === "face" ? ["faceCrop"] : signal === "plate" ? ["plateCrop"] : [],
|
|
4181
|
+
...attachmentKindPreference("best", ownerKind),
|
|
4182
|
+
...attachmentKindPreference("keyFrame", ownerKind)
|
|
4183
|
+
];
|
|
4184
|
+
}
|
|
3639
4185
|
//#endregion
|
|
3640
|
-
//#region src/notification-center/
|
|
3641
|
-
var
|
|
3642
|
-
var
|
|
3643
|
-
|
|
3644
|
-
|
|
3645
|
-
|
|
3646
|
-
primaryKey: true,
|
|
3647
|
-
notNull: true
|
|
3648
|
-
},
|
|
3649
|
-
{
|
|
3650
|
-
name: "name",
|
|
3651
|
-
type: "TEXT",
|
|
3652
|
-
notNull: true
|
|
3653
|
-
},
|
|
3654
|
-
{
|
|
3655
|
-
name: "enabled",
|
|
3656
|
-
type: "BOOLEAN",
|
|
3657
|
-
notNull: true
|
|
3658
|
-
},
|
|
3659
|
-
{
|
|
3660
|
-
name: "delivery",
|
|
3661
|
-
type: "TEXT",
|
|
3662
|
-
notNull: true
|
|
3663
|
-
},
|
|
3664
|
-
{
|
|
3665
|
-
name: "updatedAt",
|
|
3666
|
-
type: "INTEGER",
|
|
3667
|
-
notNull: true
|
|
3668
|
-
},
|
|
3669
|
-
(
|
|
3670
|
-
/** The FULL rule object (Zod-validated on read) — scalars above are
|
|
3671
|
-
* indexed projections only. */
|
|
3672
|
-
{
|
|
3673
|
-
name: "rule",
|
|
3674
|
-
type: "JSON",
|
|
3675
|
-
notNull: true
|
|
3676
|
-
})
|
|
3677
|
-
];
|
|
3678
|
-
var NC_RULES_INDEXES = [{
|
|
3679
|
-
name: "idx_nc_rules_enabled",
|
|
3680
|
-
columns: ["enabled"]
|
|
3681
|
-
}];
|
|
3682
|
-
var NcRuleStore = class {
|
|
3683
|
-
byId = /* @__PURE__ */ new Map();
|
|
3684
|
-
store;
|
|
3685
|
-
logger;
|
|
4186
|
+
//#region src/notification-center/dispatcher.ts
|
|
4187
|
+
var DEFAULT_TARGET_CACHE_TTL_MS = 6e4;
|
|
4188
|
+
var NcDispatcher = class {
|
|
4189
|
+
deps;
|
|
4190
|
+
targetCache = null;
|
|
4191
|
+
targetCacheAt = 0;
|
|
3686
4192
|
now;
|
|
3687
|
-
|
|
4193
|
+
targetCacheTtlMs;
|
|
3688
4194
|
constructor(deps) {
|
|
3689
|
-
this.
|
|
3690
|
-
this.logger = deps.logger;
|
|
4195
|
+
this.deps = deps;
|
|
3691
4196
|
this.now = deps.now ?? (() => Date.now());
|
|
3692
|
-
this.
|
|
3693
|
-
}
|
|
3694
|
-
static async declare(store) {
|
|
3695
|
-
await store.declareCollection.mutate({
|
|
3696
|
-
collection: NC_RULES_COLLECTION,
|
|
3697
|
-
columns: [...NC_RULES_COLUMNS],
|
|
3698
|
-
indexes: [...NC_RULES_INDEXES]
|
|
3699
|
-
});
|
|
4197
|
+
this.targetCacheTtlMs = deps.targetCacheTtlMs ?? DEFAULT_TARGET_CACHE_TTL_MS;
|
|
3700
4198
|
}
|
|
3701
|
-
/**
|
|
3702
|
-
|
|
3703
|
-
|
|
3704
|
-
|
|
3705
|
-
|
|
3706
|
-
|
|
3707
|
-
|
|
4199
|
+
/** The outbox `deliver` executor. */
|
|
4200
|
+
async deliver(entry) {
|
|
4201
|
+
if (this.deps.isRuleTargetDisabled !== void 0) {
|
|
4202
|
+
if (await this.deps.isRuleTargetDisabled(entry.ruleId, entry.targetId)) {
|
|
4203
|
+
this.deps.logger.debug("notification skipped: target opted out of rule", { meta: {
|
|
4204
|
+
ruleId: entry.ruleId,
|
|
4205
|
+
targetId: entry.targetId
|
|
4206
|
+
} });
|
|
4207
|
+
return { ok: true };
|
|
4208
|
+
}
|
|
4209
|
+
}
|
|
4210
|
+
const target = await this.resolveTarget(entry.targetId);
|
|
4211
|
+
if (target === null) return {
|
|
4212
|
+
ok: false,
|
|
4213
|
+
error: `target not found: ${entry.targetId}`,
|
|
4214
|
+
permanent: true
|
|
4215
|
+
};
|
|
4216
|
+
if (!target.enabled) {
|
|
4217
|
+
this.deps.logger.debug("notification skipped: target globally disabled", { meta: {
|
|
4218
|
+
ruleId: entry.ruleId,
|
|
4219
|
+
targetId: target.id,
|
|
4220
|
+
target: target.name
|
|
4221
|
+
} });
|
|
4222
|
+
return { ok: true };
|
|
4223
|
+
}
|
|
4224
|
+
const notification = await this.buildNotification(entry);
|
|
3708
4225
|
try {
|
|
3709
|
-
const
|
|
3710
|
-
|
|
3711
|
-
|
|
4226
|
+
const result = await this.deps.send({
|
|
4227
|
+
addonId: target.addonId,
|
|
4228
|
+
targetId: target.id,
|
|
4229
|
+
notification
|
|
3712
4230
|
});
|
|
3713
|
-
|
|
3714
|
-
|
|
3715
|
-
|
|
3716
|
-
|
|
3717
|
-
|
|
3718
|
-
|
|
3719
|
-
|
|
4231
|
+
if (!result.success) return {
|
|
4232
|
+
ok: false,
|
|
4233
|
+
error: result.error ?? "send failed",
|
|
4234
|
+
permanent: false
|
|
4235
|
+
};
|
|
4236
|
+
this.deps.logger.info("notification delivered", {
|
|
4237
|
+
tags: { deviceId: entry.deviceId },
|
|
4238
|
+
meta: {
|
|
4239
|
+
ruleId: entry.ruleId,
|
|
4240
|
+
target: target.name,
|
|
4241
|
+
kind: target.kind,
|
|
4242
|
+
recordKind: entry.recordKind
|
|
3720
4243
|
}
|
|
3721
|
-
|
|
3722
|
-
}
|
|
3723
|
-
this.logger.debug("notification rules loaded", { meta: {
|
|
3724
|
-
rules: this.byId.size,
|
|
3725
|
-
...skipped > 0 ? { skippedInvalid: skipped } : {}
|
|
3726
|
-
} });
|
|
4244
|
+
});
|
|
4245
|
+
return { ok: true };
|
|
3727
4246
|
} catch (err) {
|
|
3728
|
-
|
|
4247
|
+
return {
|
|
4248
|
+
ok: false,
|
|
4249
|
+
error: String(err),
|
|
4250
|
+
permanent: false
|
|
4251
|
+
};
|
|
3729
4252
|
}
|
|
3730
4253
|
}
|
|
3731
|
-
|
|
3732
|
-
|
|
3733
|
-
|
|
3734
|
-
|
|
3735
|
-
|
|
3736
|
-
|
|
3737
|
-
|
|
3738
|
-
|
|
4254
|
+
async resolveTarget(targetId) {
|
|
4255
|
+
const cached = this.cachedTarget(targetId);
|
|
4256
|
+
if (cached !== null) return cached;
|
|
4257
|
+
try {
|
|
4258
|
+
const targets = await this.deps.listTargets();
|
|
4259
|
+
this.targetCache = new Map(targets.map((t) => [t.id, t]));
|
|
4260
|
+
this.targetCacheAt = this.now();
|
|
4261
|
+
} catch (err) {
|
|
4262
|
+
this.deps.logger.warn("target catalog refresh failed", { meta: { error: String(err) } });
|
|
4263
|
+
throw err instanceof Error ? err : new Error(String(err));
|
|
4264
|
+
}
|
|
4265
|
+
return this.targetCache.get(targetId) ?? null;
|
|
3739
4266
|
}
|
|
3740
|
-
|
|
3741
|
-
|
|
3742
|
-
|
|
3743
|
-
|
|
3744
|
-
...input,
|
|
3745
|
-
id: this.newId(),
|
|
3746
|
-
createdBy,
|
|
3747
|
-
createdAt: now,
|
|
3748
|
-
updatedAt: now
|
|
3749
|
-
};
|
|
3750
|
-
await this.persist(rule);
|
|
3751
|
-
this.byId.set(rule.id, rule);
|
|
3752
|
-
return rule;
|
|
4267
|
+
cachedTarget(targetId) {
|
|
4268
|
+
if (this.targetCache === null) return null;
|
|
4269
|
+
if (this.now() - this.targetCacheAt > this.targetCacheTtlMs) return null;
|
|
4270
|
+
return this.targetCache.get(targetId) ?? null;
|
|
3753
4271
|
}
|
|
3754
|
-
|
|
3755
|
-
|
|
3756
|
-
const
|
|
3757
|
-
|
|
3758
|
-
const
|
|
3759
|
-
|
|
3760
|
-
|
|
3761
|
-
|
|
3762
|
-
|
|
3763
|
-
|
|
3764
|
-
|
|
4272
|
+
async buildNotification(entry) {
|
|
4273
|
+
const subject = entry.payload.subject;
|
|
4274
|
+
const deviceName = await this.deps.getDeviceName(subject.deviceId).catch(() => null) ?? `camera ${subject.deviceId}`;
|
|
4275
|
+
const vars = buildTemplateVars(entry, deviceName);
|
|
4276
|
+
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 attachment = await this.resolveAttachment(entry);
|
|
4279
|
+
const params = pickParams(entry.payload.params);
|
|
4280
|
+
return {
|
|
4281
|
+
body,
|
|
4282
|
+
title,
|
|
4283
|
+
priority: clampPriority(paramNumber(entry.payload.params, "priority") ?? entry.payload.priority),
|
|
4284
|
+
...params,
|
|
4285
|
+
tag: entry.ruleId,
|
|
4286
|
+
deviceId: subject.deviceId,
|
|
4287
|
+
...subject.eventId !== void 0 ? { eventId: subject.eventId } : {},
|
|
4288
|
+
...attachment !== null ? { attachments: [attachment] } : {}
|
|
3765
4289
|
};
|
|
3766
|
-
const updated = NcRuleSchema.parse(candidate);
|
|
3767
|
-
await this.persist(updated);
|
|
3768
|
-
this.byId.set(updated.id, updated);
|
|
3769
|
-
return updated;
|
|
3770
4290
|
}
|
|
3771
|
-
|
|
3772
|
-
|
|
3773
|
-
|
|
3774
|
-
|
|
3775
|
-
|
|
3776
|
-
|
|
3777
|
-
|
|
3778
|
-
|
|
3779
|
-
|
|
3780
|
-
|
|
3781
|
-
|
|
4291
|
+
/**
|
|
4292
|
+
* Resolve ONE image attachment per the rule's media policy, best
|
|
4293
|
+
* AVAILABLE at send time. Preference order comes from the pure
|
|
4294
|
+
* {@link attachmentKindPreference} / {@link bestMatchingKindPreference};
|
|
4295
|
+
* the event owner is tried first for `immediate` entries, falling back to
|
|
4296
|
+
* the parent track's media set. `best-matching` leads with the crop that
|
|
4297
|
+
* explains the fired condition (`faceCrop`/`plateCrop`, both event-owned)
|
|
4298
|
+
* then degrades to the plain `best` → `keyFrame` ladders.
|
|
4299
|
+
*/
|
|
4300
|
+
async resolveAttachment(entry) {
|
|
4301
|
+
const policy = entry.payload.media;
|
|
4302
|
+
if (policy === "none") return null;
|
|
4303
|
+
const subject = entry.payload.subject;
|
|
4304
|
+
const signal = policy === "best-matching" ? matchSignal(entry.payload.matchedOn) : null;
|
|
4305
|
+
const owners = [];
|
|
4306
|
+
if (subject.eventId !== void 0) owners.push({
|
|
4307
|
+
kind: "event",
|
|
4308
|
+
id: subject.eventId
|
|
4309
|
+
});
|
|
4310
|
+
if (subject.trackId !== void 0) owners.push({
|
|
4311
|
+
kind: "track",
|
|
4312
|
+
id: subject.trackId
|
|
4313
|
+
});
|
|
4314
|
+
if (policy === "keyFrame") owners.reverse();
|
|
4315
|
+
for (const owner of owners) try {
|
|
4316
|
+
const files = await this.deps.getMediaForOwner(owner.kind, owner.id);
|
|
4317
|
+
if (files.length === 0) continue;
|
|
4318
|
+
const preference = policy === "best-matching" ? bestMatchingKindPreference(signal, owner.kind) : attachmentKindPreference(policy, owner.kind);
|
|
4319
|
+
for (const kind of preference) {
|
|
4320
|
+
const file = files.find((f) => f.kind === kind);
|
|
4321
|
+
if (file === void 0) continue;
|
|
4322
|
+
const raw = Buffer.from(file.base64, "base64");
|
|
4323
|
+
if (raw.byteLength === 0) continue;
|
|
4324
|
+
const bytes = new Uint8Array(raw.byteLength);
|
|
4325
|
+
bytes.set(raw);
|
|
4326
|
+
return {
|
|
4327
|
+
mediaType: "image",
|
|
4328
|
+
bytes,
|
|
4329
|
+
mime: "image/jpeg",
|
|
4330
|
+
name: `${file.kind}.jpg`
|
|
4331
|
+
};
|
|
4332
|
+
}
|
|
3782
4333
|
} catch (err) {
|
|
3783
|
-
this.logger.
|
|
3784
|
-
|
|
4334
|
+
this.deps.logger.debug("attachment media read failed", { meta: {
|
|
4335
|
+
owner: owner.kind,
|
|
4336
|
+
ownerId: owner.id,
|
|
3785
4337
|
error: String(err)
|
|
3786
4338
|
} });
|
|
3787
|
-
throw err instanceof Error ? err : new Error(String(err));
|
|
3788
4339
|
}
|
|
3789
|
-
|
|
3790
|
-
async persist(rule) {
|
|
3791
|
-
await this.store.set.mutate({
|
|
3792
|
-
collection: NC_RULES_COLLECTION,
|
|
3793
|
-
key: rule.id,
|
|
3794
|
-
value: {
|
|
3795
|
-
name: rule.name,
|
|
3796
|
-
enabled: rule.enabled,
|
|
3797
|
-
delivery: rule.delivery,
|
|
3798
|
-
updatedAt: rule.updatedAt,
|
|
3799
|
-
rule
|
|
3800
|
-
}
|
|
3801
|
-
});
|
|
4340
|
+
return null;
|
|
3802
4341
|
}
|
|
3803
4342
|
};
|
|
4343
|
+
function buildTemplateVars(entry, deviceName) {
|
|
4344
|
+
const subject = entry.payload.subject;
|
|
4345
|
+
return {
|
|
4346
|
+
camera: deviceName,
|
|
4347
|
+
class: subject.className,
|
|
4348
|
+
label: subject.label ?? "",
|
|
4349
|
+
zones: subject.zones.join(", "),
|
|
4350
|
+
zone: subject.zones[0] ?? "",
|
|
4351
|
+
confidence: subject.confidence !== void 0 ? `${Math.round(subject.confidence * 100)}%` : "",
|
|
4352
|
+
time: new Date(subject.timestamp).toLocaleTimeString(),
|
|
4353
|
+
rule: entry.payload.ruleName
|
|
4354
|
+
};
|
|
4355
|
+
}
|
|
4356
|
+
/** `{{var}}` interpolation; missing vars render empty. Null template → null. */
|
|
4357
|
+
function renderTemplate(template, vars) {
|
|
4358
|
+
if (template === void 0 || template.trim().length === 0) return null;
|
|
4359
|
+
return template.replace(/\{\{\s*(\w+)\s*\}\}/g, (_m, name) => vars[name] ?? "");
|
|
4360
|
+
}
|
|
4361
|
+
function defaultBody(entry, deviceName) {
|
|
4362
|
+
const subject = entry.payload.subject;
|
|
4363
|
+
const label = subject.label !== void 0 ? ` (${subject.label})` : "";
|
|
4364
|
+
const zones = subject.zones.length > 0 ? ` in ${subject.zones.join(", ")}` : "";
|
|
4365
|
+
const suffix = entry.recordKind === "track-end" ? " — visit ended" : "";
|
|
4366
|
+
return `${subject.className}${label} on ${deviceName}${zones}${suffix}`;
|
|
4367
|
+
}
|
|
4368
|
+
function pickParams(params) {
|
|
4369
|
+
if (params === void 0) return {};
|
|
4370
|
+
const out = {};
|
|
4371
|
+
if (typeof params["level"] === "string") out.level = params["level"];
|
|
4372
|
+
if (typeof params["sound"] === "string") out.sound = params["sound"];
|
|
4373
|
+
if (typeof params["clickUrl"] === "string") out.clickUrl = params["clickUrl"];
|
|
4374
|
+
if (typeof params["ttl"] === "number" && Number.isFinite(params["ttl"])) out.ttl = params["ttl"];
|
|
4375
|
+
return out;
|
|
4376
|
+
}
|
|
4377
|
+
function paramNumber(params, key) {
|
|
4378
|
+
const v = params?.[key];
|
|
4379
|
+
return typeof v === "number" && Number.isFinite(v) ? v : void 0;
|
|
4380
|
+
}
|
|
4381
|
+
function clampPriority(priority) {
|
|
4382
|
+
return Math.max(1, Math.min(5, Math.round(priority)));
|
|
4383
|
+
}
|
|
3804
4384
|
//#endregion
|
|
3805
4385
|
//#region src/notification-center/outbox.ts
|
|
3806
4386
|
var NC_OUTBOX_COLLECTION = "notification-center:outbox";
|
|
@@ -4046,6 +4626,48 @@ var NcOutbox = class {
|
|
|
4046
4626
|
return [];
|
|
4047
4627
|
}
|
|
4048
4628
|
}
|
|
4629
|
+
/**
|
|
4630
|
+
* Query persisted outbox rows as DELIVERY HISTORY — newest-first (by fire
|
|
4631
|
+
* time `createdAt`), bounded by `limit`. History is a read-only VIEW over
|
|
4632
|
+
* the outbox: the very rows the drain loop drives, in every lifecycle
|
|
4633
|
+
* state (pending / sent / dead). There is no second write path, so a
|
|
4634
|
+
* history row can never diverge from delivery state.
|
|
4635
|
+
*
|
|
4636
|
+
* Filters (`ruleId` / `deviceId` / `status` / `since`..`until`) are pushed
|
|
4637
|
+
* to the store; the in-memory `pending` map is deliberately NOT consulted
|
|
4638
|
+
* (terminal sent / dead rows live only in the store). Best-effort: a store
|
|
4639
|
+
* error yields an empty page rather than throwing into the cap call.
|
|
4640
|
+
*/
|
|
4641
|
+
async queryHistory(query) {
|
|
4642
|
+
const where = {};
|
|
4643
|
+
if (query.ruleId !== void 0) where["ruleId"] = query.ruleId;
|
|
4644
|
+
if (query.deviceId !== void 0) where["deviceId"] = query.deviceId;
|
|
4645
|
+
if (query.status !== void 0) where["status"] = query.status;
|
|
4646
|
+
const hasRange = query.since !== void 0 || query.until !== void 0;
|
|
4647
|
+
try {
|
|
4648
|
+
const rows = await this.store.query.query({
|
|
4649
|
+
collection: NC_OUTBOX_COLLECTION,
|
|
4650
|
+
filter: {
|
|
4651
|
+
...Object.keys(where).length > 0 ? { where } : {},
|
|
4652
|
+
...hasRange ? { whereBetween: { createdAt: [query.since ?? 0, query.until ?? Number.MAX_SAFE_INTEGER] } } : {},
|
|
4653
|
+
orderBy: {
|
|
4654
|
+
field: "createdAt",
|
|
4655
|
+
direction: "desc"
|
|
4656
|
+
},
|
|
4657
|
+
limit: query.limit
|
|
4658
|
+
}
|
|
4659
|
+
});
|
|
4660
|
+
const out = [];
|
|
4661
|
+
for (const row of rows) {
|
|
4662
|
+
const entry = rowToEntry$1(row.id, row.data);
|
|
4663
|
+
if (entry !== null) out.push(entry);
|
|
4664
|
+
}
|
|
4665
|
+
return out;
|
|
4666
|
+
} catch (err) {
|
|
4667
|
+
this.logger.debug("outbox history query failed", { meta: { error: String(err) } });
|
|
4668
|
+
return [];
|
|
4669
|
+
}
|
|
4670
|
+
}
|
|
4049
4671
|
async getWatermark() {
|
|
4050
4672
|
try {
|
|
4051
4673
|
const row = await this.store.get.query({
|
|
@@ -4197,6 +4819,16 @@ function entryToRow(entry) {
|
|
|
4197
4819
|
payload: entry.payload
|
|
4198
4820
|
};
|
|
4199
4821
|
}
|
|
4822
|
+
var OUTBOX_RECORD_KINDS = new Set([
|
|
4823
|
+
"object-event",
|
|
4824
|
+
"track-end",
|
|
4825
|
+
"device-event",
|
|
4826
|
+
"package-event",
|
|
4827
|
+
"audio-event"
|
|
4828
|
+
]);
|
|
4829
|
+
function isOutboxRecordKind(x) {
|
|
4830
|
+
return typeof x === "string" && OUTBOX_RECORD_KINDS.has(x);
|
|
4831
|
+
}
|
|
4200
4832
|
function rowToEntry$1(id, data) {
|
|
4201
4833
|
const ruleId = data["ruleId"];
|
|
4202
4834
|
const targetId = data["targetId"];
|
|
@@ -4205,7 +4837,7 @@ function rowToEntry$1(id, data) {
|
|
|
4205
4837
|
const recordId = data["recordId"];
|
|
4206
4838
|
const status = data["status"];
|
|
4207
4839
|
const payload = data["payload"];
|
|
4208
|
-
if (typeof ruleId !== "string" || typeof targetId !== "string" || !Number.isFinite(deviceId) || recordKind
|
|
4840
|
+
if (typeof ruleId !== "string" || typeof targetId !== "string" || !Number.isFinite(deviceId) || !isOutboxRecordKind(recordKind) || typeof recordId !== "string" || status !== "pending" && status !== "sent" && status !== "dead" || payload === null || typeof payload !== "object") return null;
|
|
4209
4841
|
const trackId = data["trackId"];
|
|
4210
4842
|
const lastError = data["lastError"];
|
|
4211
4843
|
return {
|
|
@@ -4226,189 +4858,587 @@ function rowToEntry$1(id, data) {
|
|
|
4226
4858
|
};
|
|
4227
4859
|
}
|
|
4228
4860
|
//#endregion
|
|
4229
|
-
//#region src/notification-center/
|
|
4230
|
-
|
|
4231
|
-
var
|
|
4232
|
-
|
|
4233
|
-
|
|
4234
|
-
|
|
4235
|
-
|
|
4236
|
-
|
|
4237
|
-
|
|
4238
|
-
|
|
4239
|
-
|
|
4240
|
-
|
|
4861
|
+
//#region src/notification-center/occupancy-watcher.ts
|
|
4862
|
+
/** Sentinel key segments for the "no zone" (whole-frame) and "no class" scopes. */
|
|
4863
|
+
var FRAME_SCOPE = "@frame";
|
|
4864
|
+
var ALL_CLASSES = "@all";
|
|
4865
|
+
/** Prefix marking the trailing threshold segment (`t<n>`) — makes the grammar
|
|
4866
|
+
* self-describing so a legacy three-segment key can never be mis-parsed as a
|
|
4867
|
+
* four-segment one (the last segment of a legacy key is a className, never a
|
|
4868
|
+
* `t<digits>` token). */
|
|
4869
|
+
var THRESHOLD_PREFIX = "t";
|
|
4870
|
+
var THRESHOLD_SEGMENT = /^t(\d+)$/;
|
|
4871
|
+
function occupancyKey(deviceId, zoneId, className, threshold) {
|
|
4872
|
+
return `${deviceId}|${zoneId ?? FRAME_SCOPE}|${className ?? ALL_CLASSES}|${THRESHOLD_PREFIX}${threshold}`;
|
|
4873
|
+
}
|
|
4874
|
+
/**
|
|
4875
|
+
* Inverse of {@link occupancyKey} — the ONE place the key grammar is decoded, so
|
|
4876
|
+
* the durable store (whose column schema drops the derivable
|
|
4877
|
+
* `zoneId`/`className`/`threshold`) can reconstruct the full scope on reseed.
|
|
4878
|
+
*
|
|
4879
|
+
* The four-segment grammar is asserted EXACTLY: at least four segments AND a
|
|
4880
|
+
* trailing `t<digits>` threshold marker. A legacy three-segment key (no marker)
|
|
4881
|
+
* is REJECTED (`null`) rather than positionally mis-parsed — the durable store
|
|
4882
|
+
* then SKIPS that row, so a pre-amendment persisted row cold-re-observes instead
|
|
4883
|
+
* of hydrating a corrupt scope (className←threshold, zoneId←class). `deviceId`
|
|
4884
|
+
* is the leading numeric segment, `threshold` the trailing `t<n>`, `className`
|
|
4885
|
+
* the segment before it, and the (possibly `|`-containing) `zoneId` everything
|
|
4886
|
+
* between. Exact per-segment count is impossible because a `zoneId` may itself
|
|
4887
|
+
* contain `|`; the trailing marker is the unambiguous grammar discriminator.
|
|
4888
|
+
*/
|
|
4889
|
+
function parseOccupancyKey(key) {
|
|
4890
|
+
const parts = key.split("|");
|
|
4891
|
+
if (parts.length < 4) return null;
|
|
4892
|
+
const deviceId = Number(parts[0]);
|
|
4893
|
+
if (!Number.isFinite(deviceId)) return null;
|
|
4894
|
+
const thresholdMatch = THRESHOLD_SEGMENT.exec(parts[parts.length - 1] ?? "");
|
|
4895
|
+
if (thresholdMatch === null) return null;
|
|
4896
|
+
const threshold = Number(thresholdMatch[1]);
|
|
4897
|
+
const className = parts[parts.length - 2] ?? ALL_CLASSES;
|
|
4898
|
+
const zoneId = parts.slice(1, -2).join("|");
|
|
4899
|
+
return {
|
|
4900
|
+
deviceId,
|
|
4901
|
+
...zoneId !== FRAME_SCOPE ? { zoneId } : {},
|
|
4902
|
+
...className !== ALL_CLASSES ? { className } : {},
|
|
4903
|
+
threshold
|
|
4904
|
+
};
|
|
4905
|
+
}
|
|
4906
|
+
/** Device-agnostic partial key (`zone|class|t<threshold>`) — the merge/watch
|
|
4907
|
+
* granularity. Distinct thresholds are distinct partial keys (per-threshold). */
|
|
4908
|
+
function partialKey(zoneId, className, threshold) {
|
|
4909
|
+
return `${zoneId ?? FRAME_SCOPE}|${className ?? ALL_CLASSES}|${THRESHOLD_PREFIX}${threshold}`;
|
|
4910
|
+
}
|
|
4911
|
+
/** The device-agnostic partial key of a full state/edge key — everything after
|
|
4912
|
+
* the leading `deviceId|` segment. */
|
|
4913
|
+
function partialKeyOf(key) {
|
|
4914
|
+
const idx = key.indexOf("|");
|
|
4915
|
+
return idx < 0 ? key : key.slice(idx + 1);
|
|
4916
|
+
}
|
|
4917
|
+
var OccupancyWatcher = class {
|
|
4918
|
+
/** Watched specs, keyed by their device-agnostic partial key. */
|
|
4919
|
+
watched = /* @__PURE__ */ new Map();
|
|
4920
|
+
/** Confirmed + pending state, keyed by the full {@link OccupancyKey}. */
|
|
4921
|
+
states = /* @__PURE__ */ new Map();
|
|
4922
|
+
/**
|
|
4923
|
+
* Replace the watched key set (rule-driven — recomputed on rule change).
|
|
4924
|
+
* Each distinct `(zone, class, threshold)` is its OWN watched partial key
|
|
4925
|
+
* (per-threshold edges — NO min-threshold merge). Specs that collide on the
|
|
4926
|
+
* SAME `(zone, class, threshold)` merge to the MAX sustain (longest debounce),
|
|
4927
|
+
* so one watcher serves those co-threshold rules. Confirmed state for a key
|
|
4928
|
+
* that is no longer watched is DROPPED (bounds the RAM map + lets the caller
|
|
4929
|
+
* prune the durable row to the active set); a still-watched key retains its
|
|
4930
|
+
* level (re-evaluated on the next `observe`).
|
|
4931
|
+
*/
|
|
4932
|
+
setWatchedKeys(specs) {
|
|
4933
|
+
this.watched.clear();
|
|
4934
|
+
for (const spec of specs) {
|
|
4935
|
+
const pk = partialKey(spec.zoneId, spec.className, spec.threshold);
|
|
4936
|
+
const existing = this.watched.get(pk);
|
|
4937
|
+
if (existing === void 0) {
|
|
4938
|
+
this.watched.set(pk, {
|
|
4939
|
+
...spec.zoneId !== void 0 ? { zoneId: spec.zoneId } : {},
|
|
4940
|
+
...spec.className !== void 0 ? { className: spec.className } : {},
|
|
4941
|
+
threshold: spec.threshold,
|
|
4942
|
+
sustainSeconds: spec.sustainSeconds
|
|
4943
|
+
});
|
|
4944
|
+
continue;
|
|
4945
|
+
}
|
|
4946
|
+
this.watched.set(pk, {
|
|
4947
|
+
...existing,
|
|
4948
|
+
sustainSeconds: Math.max(existing.sustainSeconds, spec.sustainSeconds)
|
|
4949
|
+
});
|
|
4950
|
+
}
|
|
4951
|
+
for (const key of [...this.states.keys()]) if (!this.watched.has(partialKeyOf(key))) this.states.delete(key);
|
|
4241
4952
|
}
|
|
4242
|
-
/**
|
|
4243
|
-
|
|
4244
|
-
|
|
4245
|
-
|
|
4246
|
-
|
|
4247
|
-
|
|
4248
|
-
|
|
4249
|
-
|
|
4250
|
-
|
|
4251
|
-
|
|
4252
|
-
|
|
4253
|
-
|
|
4254
|
-
|
|
4255
|
-
|
|
4256
|
-
|
|
4257
|
-
|
|
4258
|
-
|
|
4259
|
-
|
|
4260
|
-
|
|
4953
|
+
/**
|
|
4954
|
+
* Feed one camera snapshot at time `now`, returning the edges that COMMIT on
|
|
4955
|
+
* this tick (usually none). Every watched key is evaluated for `deviceId`;
|
|
4956
|
+
* fail-closed keys (absent zone) are skipped and hold no state.
|
|
4957
|
+
*/
|
|
4958
|
+
observe(deviceId, snapshot, now) {
|
|
4959
|
+
const edges = [];
|
|
4960
|
+
for (const spec of this.watched.values()) {
|
|
4961
|
+
const resolved = resolveScope(snapshot, spec);
|
|
4962
|
+
if (resolved === null) continue;
|
|
4963
|
+
const key = occupancyKey(deviceId, spec.zoneId, spec.className, spec.threshold);
|
|
4964
|
+
const edge = step(this.stateFor(key, deviceId, spec, now), spec, resolved, now);
|
|
4965
|
+
if (edge !== null) edges.push(edge);
|
|
4966
|
+
}
|
|
4967
|
+
return edges;
|
|
4968
|
+
}
|
|
4969
|
+
/** Reseed confirmed state from durable rows (boot). Only rows whose key is
|
|
4970
|
+
* currently WATCHED are restored — an orphaned durable row (its rule gone)
|
|
4971
|
+
* is dropped, keeping the RAM map bounded to the active set. Pending edges
|
|
4972
|
+
* are not restored (fail-closed — they re-open on the next snapshots). */
|
|
4973
|
+
hydrate(rows) {
|
|
4974
|
+
for (const row of rows) {
|
|
4975
|
+
if (!this.watched.has(partialKeyOf(row.key))) continue;
|
|
4976
|
+
this.states.set(row.key, {
|
|
4977
|
+
deviceId: row.deviceId,
|
|
4978
|
+
...row.zoneId !== void 0 ? { zoneId: row.zoneId } : {},
|
|
4979
|
+
...row.className !== void 0 ? { className: row.className } : {},
|
|
4980
|
+
threshold: row.threshold,
|
|
4981
|
+
confirmedCount: row.confirmedCount,
|
|
4982
|
+
occupied: row.occupied,
|
|
4983
|
+
lastChangeAt: row.lastChangeAt
|
|
4261
4984
|
});
|
|
4262
|
-
if (!result.success) return {
|
|
4263
|
-
ok: false,
|
|
4264
|
-
error: result.error ?? "send failed",
|
|
4265
|
-
permanent: false
|
|
4266
|
-
};
|
|
4267
|
-
this.deps.logger.info("notification delivered", {
|
|
4268
|
-
tags: { deviceId: entry.deviceId },
|
|
4269
|
-
meta: {
|
|
4270
|
-
ruleId: entry.ruleId,
|
|
4271
|
-
target: target.name,
|
|
4272
|
-
kind: target.kind,
|
|
4273
|
-
recordKind: entry.recordKind
|
|
4274
|
-
}
|
|
4275
|
-
});
|
|
4276
|
-
return { ok: true };
|
|
4277
|
-
} catch (err) {
|
|
4278
|
-
return {
|
|
4279
|
-
ok: false,
|
|
4280
|
-
error: String(err),
|
|
4281
|
-
permanent: false
|
|
4282
|
-
};
|
|
4283
4985
|
}
|
|
4284
4986
|
}
|
|
4285
|
-
|
|
4286
|
-
|
|
4287
|
-
|
|
4987
|
+
/** Snapshot the CONFIRMED state for durable persistence (pending excluded). */
|
|
4988
|
+
snapshotState() {
|
|
4989
|
+
const rows = [];
|
|
4990
|
+
for (const [key, state] of this.states) rows.push({
|
|
4991
|
+
key,
|
|
4992
|
+
deviceId: state.deviceId,
|
|
4993
|
+
...state.zoneId !== void 0 ? { zoneId: state.zoneId } : {},
|
|
4994
|
+
...state.className !== void 0 ? { className: state.className } : {},
|
|
4995
|
+
threshold: state.threshold,
|
|
4996
|
+
confirmedCount: state.confirmedCount,
|
|
4997
|
+
occupied: state.occupied,
|
|
4998
|
+
lastChangeAt: state.lastChangeAt,
|
|
4999
|
+
updatedAt: state.lastChangeAt
|
|
5000
|
+
});
|
|
5001
|
+
return rows;
|
|
5002
|
+
}
|
|
5003
|
+
stateFor(key, deviceId, spec, now) {
|
|
5004
|
+
const existing = this.states.get(key);
|
|
5005
|
+
if (existing !== void 0) return existing;
|
|
5006
|
+
const fresh = {
|
|
5007
|
+
deviceId,
|
|
5008
|
+
...spec.zoneId !== void 0 ? { zoneId: spec.zoneId } : {},
|
|
5009
|
+
...spec.className !== void 0 ? { className: spec.className } : {},
|
|
5010
|
+
threshold: spec.threshold,
|
|
5011
|
+
confirmedCount: 0,
|
|
5012
|
+
occupied: false,
|
|
5013
|
+
lastChangeAt: now
|
|
5014
|
+
};
|
|
5015
|
+
this.states.set(key, fresh);
|
|
5016
|
+
return fresh;
|
|
5017
|
+
}
|
|
5018
|
+
};
|
|
5019
|
+
/** Read the count (+ zone name) for a spec, or `null` when fail-closed. */
|
|
5020
|
+
function resolveScope(snapshot, spec) {
|
|
5021
|
+
if (spec.zoneId === void 0) return { count: spec.className === void 0 ? snapshot.frame.totalObjects : snapshot.frame.byClass[spec.className] ?? 0 };
|
|
5022
|
+
const zone = snapshot.zones.find((z) => z.zoneId === spec.zoneId);
|
|
5023
|
+
if (zone === void 0) return null;
|
|
5024
|
+
return {
|
|
5025
|
+
count: spec.className === void 0 ? zone.totalObjects : zone.byClass[spec.className] ?? 0,
|
|
5026
|
+
zoneName: zone.zoneName
|
|
5027
|
+
};
|
|
5028
|
+
}
|
|
5029
|
+
/**
|
|
5030
|
+
* Advance one key's state by one observation. Mutates `state` in place (the
|
|
5031
|
+
* watcher owns it) and returns a committed edge, or `null`.
|
|
5032
|
+
*/
|
|
5033
|
+
function step(state, spec, resolved, now) {
|
|
5034
|
+
const rawOccupied = resolved.count >= spec.threshold;
|
|
5035
|
+
const sustainMs = spec.sustainSeconds * 1e3;
|
|
5036
|
+
if (rawOccupied === state.occupied) {
|
|
5037
|
+
state.pendingTargetOccupied = void 0;
|
|
5038
|
+
state.pendingSince = void 0;
|
|
5039
|
+
return null;
|
|
5040
|
+
}
|
|
5041
|
+
if (state.pendingTargetOccupied !== rawOccupied) {
|
|
5042
|
+
state.pendingTargetOccupied = rawOccupied;
|
|
5043
|
+
state.pendingSince = now;
|
|
5044
|
+
}
|
|
5045
|
+
if (now - (state.pendingSince ?? now) < sustainMs) return null;
|
|
5046
|
+
const previousCount = state.confirmedCount;
|
|
5047
|
+
state.confirmedCount = resolved.count;
|
|
5048
|
+
state.occupied = rawOccupied;
|
|
5049
|
+
state.lastChangeAt = now;
|
|
5050
|
+
state.pendingTargetOccupied = void 0;
|
|
5051
|
+
state.pendingSince = void 0;
|
|
5052
|
+
return {
|
|
5053
|
+
deviceId: state.deviceId,
|
|
5054
|
+
...spec.zoneId !== void 0 ? { zoneId: spec.zoneId } : {},
|
|
5055
|
+
...resolved.zoneName !== void 0 ? { zoneName: resolved.zoneName } : {},
|
|
5056
|
+
...spec.className !== void 0 ? { className: spec.className } : {},
|
|
5057
|
+
count: resolved.count,
|
|
5058
|
+
previousCount,
|
|
5059
|
+
occupied: rawOccupied,
|
|
5060
|
+
threshold: spec.threshold,
|
|
5061
|
+
timestamp: now
|
|
5062
|
+
};
|
|
5063
|
+
}
|
|
5064
|
+
//#endregion
|
|
5065
|
+
//#region src/notification-center/occupancy-store.ts
|
|
5066
|
+
var NC_OCCUPANCY_COLLECTION = "notification-center:occupancy";
|
|
5067
|
+
var NC_OCCUPANCY_COLUMNS = [
|
|
5068
|
+
{
|
|
5069
|
+
name: "key",
|
|
5070
|
+
type: "TEXT",
|
|
5071
|
+
primaryKey: true,
|
|
5072
|
+
notNull: true
|
|
5073
|
+
},
|
|
5074
|
+
{
|
|
5075
|
+
name: "deviceId",
|
|
5076
|
+
type: "INTEGER",
|
|
5077
|
+
notNull: true
|
|
5078
|
+
},
|
|
5079
|
+
{
|
|
5080
|
+
name: "confirmedCount",
|
|
5081
|
+
type: "INTEGER",
|
|
5082
|
+
notNull: true
|
|
5083
|
+
},
|
|
5084
|
+
{
|
|
5085
|
+
name: "occupied",
|
|
5086
|
+
type: "BOOLEAN",
|
|
5087
|
+
notNull: true
|
|
5088
|
+
},
|
|
5089
|
+
{
|
|
5090
|
+
name: "lastChangeAt",
|
|
5091
|
+
type: "INTEGER",
|
|
5092
|
+
notNull: true
|
|
5093
|
+
},
|
|
5094
|
+
{
|
|
5095
|
+
name: "updatedAt",
|
|
5096
|
+
type: "INTEGER",
|
|
5097
|
+
notNull: true
|
|
5098
|
+
}
|
|
5099
|
+
];
|
|
5100
|
+
var NC_OCCUPANCY_INDEXES = [{
|
|
5101
|
+
name: "idx_nc_occupancy_device",
|
|
5102
|
+
columns: ["deviceId"]
|
|
5103
|
+
}];
|
|
5104
|
+
/** Query cap — a per-(device, zone, class) key set is small; this is a
|
|
5105
|
+
* generous ceiling that still bounds a pathological read. */
|
|
5106
|
+
var LOAD_LIMIT = 1e5;
|
|
5107
|
+
var OccupancyStore = class {
|
|
5108
|
+
cache = /* @__PURE__ */ new Map();
|
|
5109
|
+
store;
|
|
5110
|
+
logger;
|
|
5111
|
+
constructor(deps) {
|
|
5112
|
+
this.store = deps.store;
|
|
5113
|
+
this.logger = deps.logger;
|
|
5114
|
+
}
|
|
5115
|
+
static async declare(store) {
|
|
5116
|
+
await store.declareCollection.mutate({
|
|
5117
|
+
collection: NC_OCCUPANCY_COLLECTION,
|
|
5118
|
+
columns: [...NC_OCCUPANCY_COLUMNS],
|
|
5119
|
+
indexes: [...NC_OCCUPANCY_INDEXES]
|
|
5120
|
+
});
|
|
5121
|
+
}
|
|
5122
|
+
/**
|
|
5123
|
+
* Reseed the confirmed edge-state from the store (boot) — replaces the cache
|
|
5124
|
+
* wholesale and returns the rows for {@link OccupancyWatcher.hydrate}. A row
|
|
5125
|
+
* whose scalars/key no longer parse is skipped with a warning (a degraded row
|
|
5126
|
+
* must never crash the reseed). Best-effort: a store error yields `[]` and a
|
|
5127
|
+
* cold watcher, never a throw into boot.
|
|
5128
|
+
*/
|
|
5129
|
+
async load() {
|
|
4288
5130
|
try {
|
|
4289
|
-
const
|
|
4290
|
-
|
|
4291
|
-
|
|
5131
|
+
const records = await this.store.query.query({
|
|
5132
|
+
collection: NC_OCCUPANCY_COLLECTION,
|
|
5133
|
+
filter: { limit: LOAD_LIMIT }
|
|
5134
|
+
});
|
|
5135
|
+
this.cache.clear();
|
|
5136
|
+
let skipped = 0;
|
|
5137
|
+
for (const record of records) {
|
|
5138
|
+
const row = recordToRow(record.id, record.data);
|
|
5139
|
+
if (row === null) {
|
|
5140
|
+
skipped += 1;
|
|
5141
|
+
continue;
|
|
5142
|
+
}
|
|
5143
|
+
this.cache.set(row.key, row);
|
|
5144
|
+
}
|
|
5145
|
+
this.logger.debug("occupancy state loaded", { meta: {
|
|
5146
|
+
keys: this.cache.size,
|
|
5147
|
+
...skipped > 0 ? { skippedInvalid: skipped } : {}
|
|
5148
|
+
} });
|
|
5149
|
+
return [...this.cache.values()];
|
|
4292
5150
|
} catch (err) {
|
|
4293
|
-
this.
|
|
4294
|
-
|
|
5151
|
+
this.logger.warn("occupancy state load failed", { meta: { error: String(err) } });
|
|
5152
|
+
return [];
|
|
4295
5153
|
}
|
|
4296
|
-
return this.targetCache.get(targetId) ?? null;
|
|
4297
|
-
}
|
|
4298
|
-
cachedTarget(targetId) {
|
|
4299
|
-
if (this.targetCache === null) return null;
|
|
4300
|
-
if (this.now() - this.targetCacheAt > this.targetCacheTtlMs) return null;
|
|
4301
|
-
return this.targetCache.get(targetId) ?? null;
|
|
4302
5154
|
}
|
|
4303
|
-
|
|
4304
|
-
|
|
4305
|
-
|
|
4306
|
-
const vars = buildTemplateVars(entry, deviceName);
|
|
4307
|
-
const title = renderTemplate(entry.payload.template?.title, vars) ?? entry.payload.ruleName;
|
|
4308
|
-
const body = renderTemplate(entry.payload.template?.body, vars) ?? defaultBody(entry, deviceName);
|
|
4309
|
-
const attachment = await this.resolveAttachment(entry);
|
|
4310
|
-
const params = pickParams(entry.payload.params);
|
|
4311
|
-
return {
|
|
4312
|
-
body,
|
|
4313
|
-
title,
|
|
4314
|
-
priority: clampPriority(paramNumber(entry.payload.params, "priority") ?? entry.payload.priority),
|
|
4315
|
-
...params,
|
|
4316
|
-
tag: entry.ruleId,
|
|
4317
|
-
deviceId: subject.deviceId,
|
|
4318
|
-
...subject.eventId !== void 0 ? { eventId: subject.eventId } : {},
|
|
4319
|
-
...attachment !== null ? { attachments: [attachment] } : {}
|
|
4320
|
-
};
|
|
5155
|
+
/** The in-RAM confirmed-state mirror (post-{@link load}/{@link persist}). */
|
|
5156
|
+
snapshot() {
|
|
5157
|
+
return [...this.cache.values()];
|
|
4321
5158
|
}
|
|
4322
5159
|
/**
|
|
4323
|
-
*
|
|
4324
|
-
*
|
|
4325
|
-
*
|
|
4326
|
-
* `immediate` entries, falling back to the parent track's media set.
|
|
5160
|
+
* Durably upsert one confirmed edge-state row (write-through: the store FIRST,
|
|
5161
|
+
* then the cache — a failed persist never leaves a phantom in-RAM level). The
|
|
5162
|
+
* `key` is the PK, so re-persisting a key advances it in place.
|
|
4327
5163
|
*/
|
|
4328
|
-
async
|
|
4329
|
-
|
|
4330
|
-
|
|
4331
|
-
|
|
4332
|
-
|
|
4333
|
-
if (subject.eventId !== void 0) owners.push({
|
|
4334
|
-
kind: "event",
|
|
4335
|
-
id: subject.eventId
|
|
4336
|
-
});
|
|
4337
|
-
if (subject.trackId !== void 0) owners.push({
|
|
4338
|
-
kind: "track",
|
|
4339
|
-
id: subject.trackId
|
|
5164
|
+
async persist(row) {
|
|
5165
|
+
await this.store.set.mutate({
|
|
5166
|
+
collection: NC_OCCUPANCY_COLLECTION,
|
|
5167
|
+
key: row.key,
|
|
5168
|
+
value: rowToValue(row)
|
|
4340
5169
|
});
|
|
4341
|
-
|
|
4342
|
-
|
|
4343
|
-
|
|
4344
|
-
|
|
4345
|
-
|
|
4346
|
-
|
|
4347
|
-
|
|
4348
|
-
|
|
4349
|
-
|
|
4350
|
-
|
|
4351
|
-
|
|
4352
|
-
|
|
4353
|
-
|
|
4354
|
-
|
|
4355
|
-
|
|
4356
|
-
|
|
4357
|
-
|
|
4358
|
-
|
|
5170
|
+
this.cache.set(row.key, row);
|
|
5171
|
+
}
|
|
5172
|
+
/**
|
|
5173
|
+
* Prune every persisted key NOT in `activeKeys` (the currently watched set) —
|
|
5174
|
+
* the bounded-row-count guarantee when rules stop watching a key. Returns the
|
|
5175
|
+
* number of rows dropped. Best-effort per row: a failed delete is logged and
|
|
5176
|
+
* the key retained (retried next prune) rather than aborting the sweep.
|
|
5177
|
+
*/
|
|
5178
|
+
async pruneExcept(activeKeys) {
|
|
5179
|
+
let pruned = 0;
|
|
5180
|
+
for (const key of [...this.cache.keys()]) {
|
|
5181
|
+
if (activeKeys.has(key)) continue;
|
|
5182
|
+
try {
|
|
5183
|
+
await this.store.delete.mutate({
|
|
5184
|
+
collection: NC_OCCUPANCY_COLLECTION,
|
|
5185
|
+
key
|
|
5186
|
+
});
|
|
5187
|
+
this.cache.delete(key);
|
|
5188
|
+
pruned += 1;
|
|
5189
|
+
} catch (err) {
|
|
5190
|
+
this.logger.debug("occupancy prune delete failed", { meta: {
|
|
5191
|
+
key,
|
|
5192
|
+
error: String(err)
|
|
5193
|
+
} });
|
|
4359
5194
|
}
|
|
4360
|
-
} catch (err) {
|
|
4361
|
-
this.deps.logger.debug("attachment media read failed", { meta: {
|
|
4362
|
-
owner: owner.kind,
|
|
4363
|
-
ownerId: owner.id,
|
|
4364
|
-
error: String(err)
|
|
4365
|
-
} });
|
|
4366
5195
|
}
|
|
4367
|
-
return
|
|
5196
|
+
return pruned;
|
|
4368
5197
|
}
|
|
4369
5198
|
};
|
|
4370
|
-
|
|
4371
|
-
|
|
5199
|
+
/** The persisted column map for a row (the `key` PK is passed separately). */
|
|
5200
|
+
function rowToValue(row) {
|
|
4372
5201
|
return {
|
|
4373
|
-
|
|
4374
|
-
|
|
4375
|
-
|
|
4376
|
-
|
|
4377
|
-
|
|
4378
|
-
confidence: subject.confidence !== void 0 ? `${Math.round(subject.confidence * 100)}%` : "",
|
|
4379
|
-
time: new Date(subject.timestamp).toLocaleTimeString(),
|
|
4380
|
-
rule: entry.payload.ruleName
|
|
5202
|
+
deviceId: row.deviceId,
|
|
5203
|
+
confirmedCount: row.confirmedCount,
|
|
5204
|
+
occupied: row.occupied,
|
|
5205
|
+
lastChangeAt: row.lastChangeAt,
|
|
5206
|
+
updatedAt: row.updatedAt
|
|
4381
5207
|
};
|
|
4382
5208
|
}
|
|
4383
|
-
/**
|
|
4384
|
-
|
|
4385
|
-
|
|
4386
|
-
|
|
4387
|
-
|
|
4388
|
-
|
|
4389
|
-
|
|
4390
|
-
|
|
4391
|
-
|
|
4392
|
-
const
|
|
4393
|
-
|
|
4394
|
-
|
|
4395
|
-
|
|
4396
|
-
|
|
4397
|
-
const
|
|
4398
|
-
if (
|
|
4399
|
-
|
|
4400
|
-
|
|
4401
|
-
|
|
4402
|
-
|
|
4403
|
-
|
|
4404
|
-
|
|
4405
|
-
|
|
4406
|
-
|
|
4407
|
-
|
|
4408
|
-
|
|
4409
|
-
|
|
5209
|
+
/**
|
|
5210
|
+
* Structurally validate a persisted record and reconstruct the full
|
|
5211
|
+
* {@link OccupancyStateRow} (deriving `zoneId`/`className`/`threshold` from the
|
|
5212
|
+
* key). Returns `null` for any malformed row — including a legacy
|
|
5213
|
+
* three-segment key with no `t<n>` threshold marker, which
|
|
5214
|
+
* {@link parseOccupancyKey} rejects — so the caller skips it (cold re-observe)
|
|
5215
|
+
* rather than hydrating a mis-parsed scope.
|
|
5216
|
+
*/
|
|
5217
|
+
function recordToRow(key, data) {
|
|
5218
|
+
const scope = parseOccupancyKey(key);
|
|
5219
|
+
if (scope === null) return null;
|
|
5220
|
+
const deviceId = Number(data["deviceId"]);
|
|
5221
|
+
const confirmedCount = Number(data["confirmedCount"]);
|
|
5222
|
+
const lastChangeAt = Number(data["lastChangeAt"]);
|
|
5223
|
+
const updatedAt = Number(data["updatedAt"]);
|
|
5224
|
+
if (!Number.isFinite(deviceId) || !Number.isFinite(confirmedCount) || !Number.isFinite(lastChangeAt) || !Number.isFinite(updatedAt)) return null;
|
|
5225
|
+
const rawOccupied = data["occupied"];
|
|
5226
|
+
const occupied = rawOccupied === true || rawOccupied === 1;
|
|
5227
|
+
return {
|
|
5228
|
+
key,
|
|
5229
|
+
deviceId,
|
|
5230
|
+
...scope.zoneId !== void 0 ? { zoneId: scope.zoneId } : {},
|
|
5231
|
+
...scope.className !== void 0 ? { className: scope.className } : {},
|
|
5232
|
+
threshold: scope.threshold,
|
|
5233
|
+
confirmedCount,
|
|
5234
|
+
occupied,
|
|
5235
|
+
lastChangeAt,
|
|
5236
|
+
updatedAt
|
|
5237
|
+
};
|
|
4410
5238
|
}
|
|
4411
5239
|
//#endregion
|
|
5240
|
+
//#region src/notification-center/rule-store.ts
|
|
5241
|
+
var NC_RULES_COLLECTION = "notification-center:rules";
|
|
5242
|
+
var NC_RULES_COLUMNS = [
|
|
5243
|
+
{
|
|
5244
|
+
name: "id",
|
|
5245
|
+
type: "TEXT",
|
|
5246
|
+
primaryKey: true,
|
|
5247
|
+
notNull: true
|
|
5248
|
+
},
|
|
5249
|
+
{
|
|
5250
|
+
name: "name",
|
|
5251
|
+
type: "TEXT",
|
|
5252
|
+
notNull: true
|
|
5253
|
+
},
|
|
5254
|
+
{
|
|
5255
|
+
name: "enabled",
|
|
5256
|
+
type: "BOOLEAN",
|
|
5257
|
+
notNull: true
|
|
5258
|
+
},
|
|
5259
|
+
{
|
|
5260
|
+
name: "delivery",
|
|
5261
|
+
type: "TEXT",
|
|
5262
|
+
notNull: true
|
|
5263
|
+
},
|
|
5264
|
+
{
|
|
5265
|
+
name: "updatedAt",
|
|
5266
|
+
type: "INTEGER",
|
|
5267
|
+
notNull: true
|
|
5268
|
+
},
|
|
5269
|
+
(
|
|
5270
|
+
/** The FULL rule object (Zod-validated on read) — scalars above are
|
|
5271
|
+
* indexed projections only. */
|
|
5272
|
+
{
|
|
5273
|
+
name: "rule",
|
|
5274
|
+
type: "JSON",
|
|
5275
|
+
notNull: true
|
|
5276
|
+
})
|
|
5277
|
+
];
|
|
5278
|
+
var NC_RULES_INDEXES = [{
|
|
5279
|
+
name: "idx_nc_rules_enabled",
|
|
5280
|
+
columns: ["enabled"]
|
|
5281
|
+
}];
|
|
5282
|
+
var NcRuleStore = class {
|
|
5283
|
+
byId = /* @__PURE__ */ new Map();
|
|
5284
|
+
store;
|
|
5285
|
+
logger;
|
|
5286
|
+
now;
|
|
5287
|
+
newId;
|
|
5288
|
+
constructor(deps) {
|
|
5289
|
+
this.store = deps.store;
|
|
5290
|
+
this.logger = deps.logger;
|
|
5291
|
+
this.now = deps.now ?? (() => Date.now());
|
|
5292
|
+
this.newId = deps.newId ?? (() => randomUUID());
|
|
5293
|
+
}
|
|
5294
|
+
static async declare(store) {
|
|
5295
|
+
await store.declareCollection.mutate({
|
|
5296
|
+
collection: NC_RULES_COLLECTION,
|
|
5297
|
+
columns: [...NC_RULES_COLUMNS],
|
|
5298
|
+
indexes: [...NC_RULES_INDEXES]
|
|
5299
|
+
});
|
|
5300
|
+
}
|
|
5301
|
+
/**
|
|
5302
|
+
* (Re)hydrate the FULL rule set from the store — called at boot and on
|
|
5303
|
+
* the periodic refresh tick (cross-node CRUD staleness bound). Replaces
|
|
5304
|
+
* the cache wholesale; a row whose JSON no longer validates is skipped
|
|
5305
|
+
* with a warning (a degraded rule must never crash evaluation).
|
|
5306
|
+
*/
|
|
5307
|
+
async load() {
|
|
5308
|
+
try {
|
|
5309
|
+
const rows = await this.store.query.query({
|
|
5310
|
+
collection: NC_RULES_COLLECTION,
|
|
5311
|
+
filter: { limit: 1e4 }
|
|
5312
|
+
});
|
|
5313
|
+
this.byId.clear();
|
|
5314
|
+
let skipped = 0;
|
|
5315
|
+
for (const row of rows) {
|
|
5316
|
+
const parsed = NcRuleSchema.safeParse(row.data["rule"]);
|
|
5317
|
+
if (!parsed.success) {
|
|
5318
|
+
skipped += 1;
|
|
5319
|
+
continue;
|
|
5320
|
+
}
|
|
5321
|
+
this.byId.set(parsed.data.id, parsed.data);
|
|
5322
|
+
}
|
|
5323
|
+
this.logger.debug("notification rules loaded", { meta: {
|
|
5324
|
+
rules: this.byId.size,
|
|
5325
|
+
...skipped > 0 ? { skippedInvalid: skipped } : {}
|
|
5326
|
+
} });
|
|
5327
|
+
} catch (err) {
|
|
5328
|
+
this.logger.warn("notification rules load failed", { meta: { error: String(err) } });
|
|
5329
|
+
}
|
|
5330
|
+
}
|
|
5331
|
+
list() {
|
|
5332
|
+
return [...this.byId.values()].sort((a, b) => b.updatedAt - a.updatedAt);
|
|
5333
|
+
}
|
|
5334
|
+
listEnabled(delivery) {
|
|
5335
|
+
return this.list().filter((r) => r.enabled && r.delivery === delivery);
|
|
5336
|
+
}
|
|
5337
|
+
get(ruleId) {
|
|
5338
|
+
return this.byId.get(ruleId) ?? null;
|
|
5339
|
+
}
|
|
5340
|
+
/**
|
|
5341
|
+
* Rules visible to `userId`: their OWN personal rules (`ownerUserId ===
|
|
5342
|
+
* userId`) plus every admin/global rule (`ownerUserId` absent). Never
|
|
5343
|
+
* another user's personal rows. Newest-first (inherits {@link list}).
|
|
5344
|
+
*
|
|
5345
|
+
* The caller identity is server-derived; an absent/undefined caller must be
|
|
5346
|
+
* resolved to a fail-closed value by the bridge action BEFORE calling this —
|
|
5347
|
+
* this store never treats a missing caller as admin/global.
|
|
5348
|
+
*/
|
|
5349
|
+
listForOwner(userId) {
|
|
5350
|
+
return this.list().filter((r) => r.ownerUserId === void 0 || r.ownerUserId === userId);
|
|
5351
|
+
}
|
|
5352
|
+
/** Create a new rule. `createdBy` is the SERVER-injected caller userId. */
|
|
5353
|
+
async create(input, createdBy) {
|
|
5354
|
+
const now = this.now();
|
|
5355
|
+
const rule = {
|
|
5356
|
+
...input,
|
|
5357
|
+
id: this.newId(),
|
|
5358
|
+
createdBy,
|
|
5359
|
+
createdAt: now,
|
|
5360
|
+
updatedAt: now,
|
|
5361
|
+
disabledTargetIds: []
|
|
5362
|
+
};
|
|
5363
|
+
await this.persist(rule);
|
|
5364
|
+
this.byId.set(rule.id, rule);
|
|
5365
|
+
return rule;
|
|
5366
|
+
}
|
|
5367
|
+
/** Apply a partial patch. Immutable: returns the NEW rule object. */
|
|
5368
|
+
async update(ruleId, patch) {
|
|
5369
|
+
const existing = this.byId.get(ruleId);
|
|
5370
|
+
if (!existing) throw new Error(`notification rule not found: ${ruleId}`);
|
|
5371
|
+
const candidate = {
|
|
5372
|
+
...existing,
|
|
5373
|
+
...patch,
|
|
5374
|
+
id: existing.id,
|
|
5375
|
+
createdBy: existing.createdBy,
|
|
5376
|
+
createdAt: existing.createdAt,
|
|
5377
|
+
updatedAt: this.now()
|
|
5378
|
+
};
|
|
5379
|
+
const updated = NcRuleSchema.parse(candidate);
|
|
5380
|
+
await this.persist(updated);
|
|
5381
|
+
this.byId.set(updated.id, updated);
|
|
5382
|
+
return updated;
|
|
5383
|
+
}
|
|
5384
|
+
async setEnabled(ruleId, enabled) {
|
|
5385
|
+
return this.update(ruleId, { enabled });
|
|
5386
|
+
}
|
|
5387
|
+
/**
|
|
5388
|
+
* Per-target opt-out toggle for a rule. `enabled: false` suppresses the
|
|
5389
|
+
* target for THIS rule at send time; `true` re-enables it. Idempotent per
|
|
5390
|
+
* target (a `Set` dedups; removing an absent id is a no-op). Throws when the
|
|
5391
|
+
* rule id is unknown.
|
|
5392
|
+
*
|
|
5393
|
+
* OWNERSHIP: this store applies the toggle unconditionally — the owner-only
|
|
5394
|
+
* restriction ("only a target's owner may opt it out") is enforced by the
|
|
5395
|
+
* `nc.setRuleTargetEnabled` bridge action, which resolves the fail-closed
|
|
5396
|
+
* caller and validates target ownership before calling here.
|
|
5397
|
+
*/
|
|
5398
|
+
async setRuleTargetEnabled(ruleId, targetId, enabled) {
|
|
5399
|
+
const existing = this.byId.get(ruleId);
|
|
5400
|
+
if (!existing) throw new Error(`notification rule not found: ${ruleId}`);
|
|
5401
|
+
const next = new Set(existing.disabledTargetIds);
|
|
5402
|
+
if (enabled) next.delete(targetId);
|
|
5403
|
+
else next.add(targetId);
|
|
5404
|
+
return this.update(ruleId, { disabledTargetIds: [...next] });
|
|
5405
|
+
}
|
|
5406
|
+
/** Hot-path read for the dispatcher: is `targetId` opted out of `ruleId`? */
|
|
5407
|
+
isRuleTargetDisabled(ruleId, targetId) {
|
|
5408
|
+
const rule = this.byId.get(ruleId);
|
|
5409
|
+
return rule ? rule.disabledTargetIds.includes(targetId) : false;
|
|
5410
|
+
}
|
|
5411
|
+
/** Idempotent delete — unknown ids are a no-op. */
|
|
5412
|
+
async delete(ruleId) {
|
|
5413
|
+
this.byId.delete(ruleId);
|
|
5414
|
+
try {
|
|
5415
|
+
await this.store.delete.mutate({
|
|
5416
|
+
collection: NC_RULES_COLLECTION,
|
|
5417
|
+
key: ruleId
|
|
5418
|
+
});
|
|
5419
|
+
} catch (err) {
|
|
5420
|
+
this.logger.warn("notification rule delete failed", { meta: {
|
|
5421
|
+
ruleId,
|
|
5422
|
+
error: String(err)
|
|
5423
|
+
} });
|
|
5424
|
+
throw err instanceof Error ? err : new Error(String(err));
|
|
5425
|
+
}
|
|
5426
|
+
}
|
|
5427
|
+
async persist(rule) {
|
|
5428
|
+
await this.store.set.mutate({
|
|
5429
|
+
collection: NC_RULES_COLLECTION,
|
|
5430
|
+
key: rule.id,
|
|
5431
|
+
value: {
|
|
5432
|
+
name: rule.name,
|
|
5433
|
+
enabled: rule.enabled,
|
|
5434
|
+
delivery: rule.delivery,
|
|
5435
|
+
updatedAt: rule.updatedAt,
|
|
5436
|
+
rule
|
|
5437
|
+
}
|
|
5438
|
+
});
|
|
5439
|
+
}
|
|
5440
|
+
};
|
|
5441
|
+
//#endregion
|
|
4412
5442
|
//#region src/notification-center/index.ts
|
|
4413
5443
|
var DEFAULT_DRAIN_INTERVAL_MS = 2e3;
|
|
4414
5444
|
var DEFAULT_RULE_RELOAD_INTERVAL_MS = 3e4;
|
|
@@ -4421,6 +5451,69 @@ var WATERMARK_EVERY_TICKS = 15;
|
|
|
4421
5451
|
/** Terminal outbox rows older than this are pruned at boot. */
|
|
4422
5452
|
var OUTBOX_RETENTION_MS = 168 * 36e5;
|
|
4423
5453
|
var TEST_RULE_MAX_RESULTS = 200;
|
|
5454
|
+
/**
|
|
5455
|
+
* The className every durable package event carries (mirrors
|
|
5456
|
+
* `PackageDropDetector.PACKAGE_EVENT_CLASS` — declared locally so the NC module
|
|
5457
|
+
* stays free of a cross-module import into the pipeline). Delivery events use
|
|
5458
|
+
* the `idle` state, pick-ups the `left` state.
|
|
5459
|
+
*/
|
|
5460
|
+
var PACKAGE_EVENT_CLASS$1 = "package";
|
|
5461
|
+
/**
|
|
5462
|
+
* Map an occupancy condition to the watcher key spec. Every op keys on its own
|
|
5463
|
+
* `count` EXCEPT `<=` C, which keys on threshold C+1 — the `count ≥ C+1`
|
|
5464
|
+
* predicate goes false exactly when the count drops to ≤C (mirrors the engine's
|
|
5465
|
+
* `matchesOccupancy`, so the watched key and the matched edge always agree).
|
|
5466
|
+
*/
|
|
5467
|
+
function occupancySpecFromCondition(occ) {
|
|
5468
|
+
const threshold = occ.op === "<=" ? occ.count + 1 : occ.count;
|
|
5469
|
+
return {
|
|
5470
|
+
...occ.zoneId !== void 0 ? { zoneId: occ.zoneId } : {},
|
|
5471
|
+
...occ.className !== void 0 ? { className: occ.className } : {},
|
|
5472
|
+
threshold,
|
|
5473
|
+
sustainSeconds: occ.sustainSeconds
|
|
5474
|
+
};
|
|
5475
|
+
}
|
|
5476
|
+
/** Classify an object-event row as a package delivery / pick-up, or `null` when
|
|
5477
|
+
* it is an ordinary detection. */
|
|
5478
|
+
function packagePhaseOf(ev) {
|
|
5479
|
+
if (ev.className !== PACKAGE_EVENT_CLASS$1) return null;
|
|
5480
|
+
if (ev.state === "left") return "picked-up";
|
|
5481
|
+
if (ev.state === "idle") return "delivered";
|
|
5482
|
+
return null;
|
|
5483
|
+
}
|
|
5484
|
+
/**
|
|
5485
|
+
* Project a durable outbox row onto a delivery-history entry (the cap
|
|
5486
|
+
* contract). History is a VIEW — this is the ONLY mapping; there is no
|
|
5487
|
+
* separate history collection to write, so a row and its history entry can
|
|
5488
|
+
* never disagree. `ruleName` + `subject` come from the intent snapshot
|
|
5489
|
+
* frozen at enqueue; `error` surfaces only on a dead row (`lastError`).
|
|
5490
|
+
*/
|
|
5491
|
+
function outboxEntryToHistory(entry) {
|
|
5492
|
+
const s = entry.payload.subject;
|
|
5493
|
+
return {
|
|
5494
|
+
id: entry.id,
|
|
5495
|
+
ruleId: entry.ruleId,
|
|
5496
|
+
ruleName: entry.payload.ruleName,
|
|
5497
|
+
delivery: entry.payload.delivery,
|
|
5498
|
+
targetId: entry.targetId,
|
|
5499
|
+
deviceId: entry.deviceId,
|
|
5500
|
+
recordKind: entry.recordKind === "audio-event" ? "object-event" : entry.recordKind,
|
|
5501
|
+
recordId: entry.recordId,
|
|
5502
|
+
...entry.trackId !== void 0 ? { trackId: entry.trackId } : {},
|
|
5503
|
+
status: entry.status,
|
|
5504
|
+
attempts: entry.attempts,
|
|
5505
|
+
createdAt: entry.createdAt,
|
|
5506
|
+
updatedAt: entry.updatedAt,
|
|
5507
|
+
...entry.lastError !== void 0 ? { error: entry.lastError } : {},
|
|
5508
|
+
subject: {
|
|
5509
|
+
className: s.className,
|
|
5510
|
+
...s.label !== void 0 ? { label: s.label } : {},
|
|
5511
|
+
...s.confidence !== void 0 ? { confidence: s.confidence } : {},
|
|
5512
|
+
zones: [...s.zones],
|
|
5513
|
+
timestamp: s.timestamp
|
|
5514
|
+
}
|
|
5515
|
+
};
|
|
5516
|
+
}
|
|
4424
5517
|
var NotificationCenter = class {
|
|
4425
5518
|
logger;
|
|
4426
5519
|
rules;
|
|
@@ -4428,6 +5521,14 @@ var NotificationCenter = class {
|
|
|
4428
5521
|
dispatcher;
|
|
4429
5522
|
deps;
|
|
4430
5523
|
now;
|
|
5524
|
+
/** Debounced occupancy edge state machine (pure) + its durable confirmed
|
|
5525
|
+
* state. Fed in-process by {@link observeOccupancy} from ZoneAnalytics
|
|
5526
|
+
* snapshots; watched keys are recomputed from the enabled occupancy rules. */
|
|
5527
|
+
occupancyWatcher = new OccupancyWatcher();
|
|
5528
|
+
occupancyStore;
|
|
5529
|
+
/** True when ≥1 enabled `device-event` rule declares an occupancy condition —
|
|
5530
|
+
* the watcher is idle (zero per-frame cost) otherwise. */
|
|
5531
|
+
occupancyEnabled = false;
|
|
4431
5532
|
/** In-memory cooldown map — seeded from persisted outbox rows on start. */
|
|
4432
5533
|
lastFiredAt = /* @__PURE__ */ new Map();
|
|
4433
5534
|
/**
|
|
@@ -4450,9 +5551,14 @@ var NotificationCenter = class {
|
|
|
4450
5551
|
store: deps.store,
|
|
4451
5552
|
logger: this.logger.child("rules")
|
|
4452
5553
|
});
|
|
5554
|
+
this.occupancyStore = new OccupancyStore({
|
|
5555
|
+
store: deps.store,
|
|
5556
|
+
logger: this.logger.child("occupancy")
|
|
5557
|
+
});
|
|
4453
5558
|
this.dispatcher = new NcDispatcher({
|
|
4454
5559
|
...deps.dispatcher,
|
|
4455
|
-
logger: this.logger.child("dispatch")
|
|
5560
|
+
logger: this.logger.child("dispatch"),
|
|
5561
|
+
isRuleTargetDisabled: (ruleId, targetId) => this.rules.isRuleTargetDisabled(ruleId, targetId)
|
|
4456
5562
|
});
|
|
4457
5563
|
this.outbox = new NcOutbox({
|
|
4458
5564
|
store: deps.store,
|
|
@@ -4461,10 +5567,20 @@ var NotificationCenter = class {
|
|
|
4461
5567
|
...deps.now !== void 0 ? { now: deps.now } : {}
|
|
4462
5568
|
});
|
|
4463
5569
|
}
|
|
5570
|
+
/**
|
|
5571
|
+
* The durable rule store — exposed so the hub-only `nc.*` bridge actions
|
|
5572
|
+
* (`nc-actions.ts`, addonId `pipeline-analytics`) can serve the viewer's
|
|
5573
|
+
* ownership-scoped rule CRUD over `addons.custom`. Read/write goes through
|
|
5574
|
+
* the same write-through cache the evaluation path reads.
|
|
5575
|
+
*/
|
|
5576
|
+
get ruleStore() {
|
|
5577
|
+
return this.rules;
|
|
5578
|
+
}
|
|
4464
5579
|
/** Declare every Notification Center collection (idempotent, boot-time). */
|
|
4465
5580
|
static async declare(store) {
|
|
4466
5581
|
await NcRuleStore.declare(store);
|
|
4467
5582
|
await NcOutbox.declare(store);
|
|
5583
|
+
await OccupancyStore.declare(store);
|
|
4468
5584
|
}
|
|
4469
5585
|
/**
|
|
4470
5586
|
* Load rules (every node — the cap provider serves CRUD from any node).
|
|
@@ -4474,17 +5590,19 @@ var NotificationCenter = class {
|
|
|
4474
5590
|
*/
|
|
4475
5591
|
async start(opts) {
|
|
4476
5592
|
await this.rules.load();
|
|
5593
|
+
this.refreshOccupancyWatch();
|
|
4477
5594
|
if (!opts.evaluation) return;
|
|
4478
5595
|
this.evaluationActive = true;
|
|
4479
5596
|
await this.outbox.load();
|
|
4480
5597
|
await this.seedCooldowns();
|
|
4481
5598
|
await this.outbox.pruneBefore(this.now() - OUTBOX_RETENTION_MS);
|
|
5599
|
+
await this.hydrateOccupancy();
|
|
4482
5600
|
await this.reconcile();
|
|
4483
5601
|
this.drainTimer = setInterval(() => {
|
|
4484
5602
|
this.drainTick();
|
|
4485
5603
|
}, this.deps.drainIntervalMs ?? DEFAULT_DRAIN_INTERVAL_MS);
|
|
4486
5604
|
this.reloadTimer = setInterval(() => {
|
|
4487
|
-
this.
|
|
5605
|
+
this.reloadRules();
|
|
4488
5606
|
}, this.deps.ruleReloadIntervalMs ?? DEFAULT_RULE_RELOAD_INTERVAL_MS);
|
|
4489
5607
|
this.logger.info("notification center started", { meta: {
|
|
4490
5608
|
rules: this.rules.list().length,
|
|
@@ -4510,6 +5628,7 @@ var NotificationCenter = class {
|
|
|
4510
5628
|
*/
|
|
4511
5629
|
onObjectEventPersisted(event, _track) {
|
|
4512
5630
|
if (!this.evaluationActive) return;
|
|
5631
|
+
if (packagePhaseOf(event) !== null) return;
|
|
4513
5632
|
const subject = subjectFromObjectEvent(event);
|
|
4514
5633
|
this.scheduleEvaluation(subject, "object-event", () => ({
|
|
4515
5634
|
tags: { deviceId: event.deviceId },
|
|
@@ -4530,6 +5649,117 @@ var NotificationCenter = class {
|
|
|
4530
5649
|
meta: { trackId: track.trackId }
|
|
4531
5650
|
}));
|
|
4532
5651
|
}
|
|
5652
|
+
/**
|
|
5653
|
+
* Called at the SensorEvent persist site (`ingestSensorStateChange` — one row
|
|
5654
|
+
* per linked camera), in the SAME moment as the durable insert. Feeds the
|
|
5655
|
+
* `device-event` trigger (doorbell press / sensor state change). Fire-and-
|
|
5656
|
+
* forget from the ingest loop; the outbox owns delivery from here.
|
|
5657
|
+
*
|
|
5658
|
+
* Delivery-grade boundary (honest): the SensorEventStore is itself fed from
|
|
5659
|
+
* the LOSSY `DeviceStateChanged` telemetry bus, and there is no NC crash-gap
|
|
5660
|
+
* reconcile for sensor rows in this slice (unlike object events). So a dropped
|
|
5661
|
+
* upstream bus event, or a crash in the persist→outbox window, drops the
|
|
5662
|
+
* device-event notification — the durable guarantee begins at this hook, not
|
|
5663
|
+
* before it.
|
|
5664
|
+
*/
|
|
5665
|
+
onSensorEventPersisted(event) {
|
|
5666
|
+
if (!this.evaluationActive) return;
|
|
5667
|
+
const subject = subjectFromSensorEvent(event);
|
|
5668
|
+
this.scheduleEvaluation(subject, "device-event", () => ({
|
|
5669
|
+
tags: { deviceId: event.deviceId },
|
|
5670
|
+
meta: {
|
|
5671
|
+
sensorEventId: event.id,
|
|
5672
|
+
kind: event.kind
|
|
5673
|
+
}
|
|
5674
|
+
}));
|
|
5675
|
+
}
|
|
5676
|
+
/**
|
|
5677
|
+
* Called at the AUDIO-event persist site (`eventStore.insertAudio`), in the
|
|
5678
|
+
* SAME moment as the durable insert. Feeds an `immediate` rule that OPTS IN
|
|
5679
|
+
* to an `audio-*` class (the safety gate in `evaluateRule` — a rule without
|
|
5680
|
+
* an audio class never fires here). Fire-and-forget; the outbox owns delivery.
|
|
5681
|
+
*
|
|
5682
|
+
* Delivery-grade boundary (honest): audio events persist on a SEPARATE store
|
|
5683
|
+
* from object events, and there is no NC crash-gap reconcile for audio rows
|
|
5684
|
+
* in this slice (unlike object events, which the boot reconcile re-scans). So
|
|
5685
|
+
* a crash in the persist→outbox window drops the audio notification — the
|
|
5686
|
+
* durable guarantee begins at this hook, matching the device-event boundary.
|
|
5687
|
+
*/
|
|
5688
|
+
onAudioEventPersisted(event) {
|
|
5689
|
+
if (!this.evaluationActive) return;
|
|
5690
|
+
const subject = subjectFromAudioEvent(event);
|
|
5691
|
+
this.scheduleEvaluation(subject, "audio-event", () => ({
|
|
5692
|
+
tags: { deviceId: event.deviceId },
|
|
5693
|
+
meta: {
|
|
5694
|
+
audioEventId: event.id,
|
|
5695
|
+
class: event.classification?.className
|
|
5696
|
+
}
|
|
5697
|
+
}));
|
|
5698
|
+
}
|
|
5699
|
+
/**
|
|
5700
|
+
* Called at the package object-event persist site (`PackageDropDetector` —
|
|
5701
|
+
* delivered/picked-up), in the SAME moment as the durable insert. Feeds the
|
|
5702
|
+
* `package-event` trigger. Package events ARE object-event rows, so the boot
|
|
5703
|
+
* crash-gap reconcile re-covers them (routed by className in {@link reconcile}).
|
|
5704
|
+
*/
|
|
5705
|
+
onPackageEventPersisted(event, phase) {
|
|
5706
|
+
if (!this.evaluationActive) return;
|
|
5707
|
+
const subject = subjectFromPackageEvent(event, phase);
|
|
5708
|
+
this.scheduleEvaluation(subject, "package-event", () => ({
|
|
5709
|
+
tags: { deviceId: event.deviceId },
|
|
5710
|
+
meta: {
|
|
5711
|
+
eventId: event.id,
|
|
5712
|
+
phase
|
|
5713
|
+
}
|
|
5714
|
+
}));
|
|
5715
|
+
}
|
|
5716
|
+
/**
|
|
5717
|
+
* Feed one ZoneAnalytics occupancy snapshot into the debounced watcher
|
|
5718
|
+
* (in-process, telemetry-loss-tolerant — a dropped snapshot just misses a
|
|
5719
|
+
* sample; the durable confirmed edge-state is the recovery). Called per
|
|
5720
|
+
* ≤1 Hz snapshot from `ZoneAnalyticsProvider` (live frames + the parked-object
|
|
5721
|
+
* baseline sampler). Cheap no-op when no occupancy rule is enabled. Each
|
|
5722
|
+
* COMMITTED edge is persisted (rare — only on a confirmed flip, never
|
|
5723
|
+
* per-frame) and routed to {@link onOccupancyEdge}.
|
|
5724
|
+
*/
|
|
5725
|
+
observeOccupancy(deviceId, snapshot) {
|
|
5726
|
+
if (!this.evaluationActive || !this.occupancyEnabled) return;
|
|
5727
|
+
const now = this.now();
|
|
5728
|
+
let edges;
|
|
5729
|
+
try {
|
|
5730
|
+
edges = this.occupancyWatcher.observe(deviceId, snapshot, now);
|
|
5731
|
+
} catch (err) {
|
|
5732
|
+
this.logger.debug("occupancy observe failed", {
|
|
5733
|
+
tags: { deviceId },
|
|
5734
|
+
meta: { error: String(err) }
|
|
5735
|
+
});
|
|
5736
|
+
return;
|
|
5737
|
+
}
|
|
5738
|
+
for (const edge of edges) {
|
|
5739
|
+
this.persistOccupancyEdge(edge, now);
|
|
5740
|
+
this.onOccupancyEdge(edge);
|
|
5741
|
+
}
|
|
5742
|
+
}
|
|
5743
|
+
/**
|
|
5744
|
+
* Persist hook for a committed occupancy edge — mirrors the other persist
|
|
5745
|
+
* hooks (serialized via {@link scheduleEvaluation}). The subject rides the
|
|
5746
|
+
* EXISTING `device-event` delivery via the internal `occupancy-event` kind.
|
|
5747
|
+
* Public so a test / a future out-of-band edge source can drive it directly.
|
|
5748
|
+
*/
|
|
5749
|
+
onOccupancyEdge(edge) {
|
|
5750
|
+
if (!this.evaluationActive) return;
|
|
5751
|
+
const subject = subjectFromOccupancyEvent(edge);
|
|
5752
|
+
this.scheduleEvaluation(subject, "occupancy-event", () => ({
|
|
5753
|
+
tags: { deviceId: edge.deviceId },
|
|
5754
|
+
meta: {
|
|
5755
|
+
zoneId: edge.zoneId ?? "@frame",
|
|
5756
|
+
className: edge.className ?? "@all",
|
|
5757
|
+
count: edge.count,
|
|
5758
|
+
threshold: edge.threshold,
|
|
5759
|
+
occupied: edge.occupied
|
|
5760
|
+
}
|
|
5761
|
+
}));
|
|
5762
|
+
}
|
|
4533
5763
|
buildProvider() {
|
|
4534
5764
|
return {
|
|
4535
5765
|
listRules: async () => ({ rules: [...this.rules.list()] }),
|
|
@@ -4546,7 +5776,8 @@ var NotificationCenter = class {
|
|
|
4546
5776
|
},
|
|
4547
5777
|
updateRule: async ({ ruleId, patch, caller }) => {
|
|
4548
5778
|
if (patch.targets !== void 0) await this.validateTargetRefs(patch.targets.map((t) => t.targetId));
|
|
4549
|
-
const
|
|
5779
|
+
const { disabledTargetIds: _optOut, ...safePatch } = patch;
|
|
5780
|
+
const updated = await this.rules.update(ruleId, safePatch);
|
|
4550
5781
|
this.logger.info("notification rule updated", { meta: {
|
|
4551
5782
|
ruleId,
|
|
4552
5783
|
by: caller.userId
|
|
@@ -4562,7 +5793,24 @@ var NotificationCenter = class {
|
|
|
4562
5793
|
return { success: true };
|
|
4563
5794
|
},
|
|
4564
5795
|
testRule: async ({ rule, lookbackMinutes }) => ({ results: [...await this.dryRun(rule, lookbackMinutes)] }),
|
|
4565
|
-
getConditionCatalog: async () => ({ catalog: [...NC_CONDITION_CATALOG] })
|
|
5796
|
+
getConditionCatalog: async () => ({ catalog: [...NC_CONDITION_CATALOG] }),
|
|
5797
|
+
getHistory: async ({ filter }) => {
|
|
5798
|
+
const entries = await this.outbox.queryHistory({
|
|
5799
|
+
...filter.ruleId !== void 0 ? { ruleId: filter.ruleId } : {},
|
|
5800
|
+
...filter.deviceId !== void 0 ? { deviceId: filter.deviceId } : {},
|
|
5801
|
+
...filter.status !== void 0 ? { status: filter.status } : {},
|
|
5802
|
+
...filter.since !== void 0 ? { since: filter.since } : {},
|
|
5803
|
+
...filter.until !== void 0 ? { until: filter.until } : {},
|
|
5804
|
+
limit: filter.limit
|
|
5805
|
+
});
|
|
5806
|
+
const mapped = [];
|
|
5807
|
+
for (const entry of entries) try {
|
|
5808
|
+
mapped.push(outboxEntryToHistory(entry));
|
|
5809
|
+
} catch {
|
|
5810
|
+
this.logger.debug("getHistory: skipping malformed outbox row", { meta: { id: entry.id } });
|
|
5811
|
+
}
|
|
5812
|
+
return { entries: mapped };
|
|
5813
|
+
}
|
|
4566
5814
|
};
|
|
4567
5815
|
}
|
|
4568
5816
|
/** Append one evaluation to the serialized chain (see {@link evalChain}). */
|
|
@@ -4584,31 +5832,35 @@ var NotificationCenter = class {
|
|
|
4584
5832
|
});
|
|
4585
5833
|
}
|
|
4586
5834
|
async evaluateAndEnqueue(subject, kind) {
|
|
4587
|
-
const delivery = kind === "object-event" ? "immediate" : "
|
|
5835
|
+
const delivery = kind === "object-event" || kind === "audio-event" ? "immediate" : kind === "occupancy-event" ? "device-event" : kind;
|
|
4588
5836
|
const candidates = this.rules.listEnabled(delivery);
|
|
4589
5837
|
if (candidates.length === 0) return;
|
|
4590
5838
|
const now = this.now();
|
|
4591
5839
|
for (const rule of candidates) {
|
|
4592
|
-
|
|
5840
|
+
const evaluation = evaluateRule(rule, subject);
|
|
5841
|
+
if (!evaluation.matched) continue;
|
|
4593
5842
|
const key = cooldownKey(rule, subject);
|
|
4594
5843
|
if (isCoolingDown(rule, this.lastFiredAt.get(key), now)) continue;
|
|
4595
|
-
if (await this.outbox.enqueue(this.buildEntries(rule, subject, kind)) > 0) this.lastFiredAt.set(key, now);
|
|
5844
|
+
if (await this.outbox.enqueue(this.buildEntries(rule, subject, kind, evaluation.matchedOn)) > 0) this.lastFiredAt.set(key, now);
|
|
4596
5845
|
}
|
|
4597
5846
|
}
|
|
4598
|
-
buildEntries(rule, subject, kind) {
|
|
4599
|
-
|
|
5847
|
+
buildEntries(rule, subject, kind, matchedOn) {
|
|
5848
|
+
const hasEventMedia = kind === "object-event" || kind === "package-event";
|
|
5849
|
+
const isTrackScoped = kind === "object-event" || kind === "track-end";
|
|
5850
|
+
return rule.targets.filter((target) => !rule.disabledTargetIds.includes(target.targetId)).map((target) => {
|
|
4600
5851
|
const payload = {
|
|
4601
5852
|
ruleName: rule.name,
|
|
4602
5853
|
delivery: rule.delivery,
|
|
4603
5854
|
priority: rule.priority,
|
|
4604
5855
|
...rule.template !== void 0 ? { template: rule.template } : {},
|
|
4605
5856
|
media: rule.media.attach,
|
|
5857
|
+
...matchedOn !== void 0 && matchedOn.length > 0 ? { matchedOn } : {},
|
|
4606
5858
|
...target.params !== void 0 ? { params: target.params } : {},
|
|
4607
5859
|
subject: {
|
|
4608
5860
|
deviceId: subject.deviceId,
|
|
4609
5861
|
...subject.trackId !== void 0 ? { trackId: subject.trackId } : {},
|
|
4610
|
-
...
|
|
4611
|
-
className: subject.classNames[0] ?? "
|
|
5862
|
+
...hasEventMedia ? { eventId: subject.recordId } : {},
|
|
5863
|
+
className: subject.classNames[0] ?? subject.sensorKind ?? "event",
|
|
4612
5864
|
...subject.label !== void 0 ? { label: subject.label } : {},
|
|
4613
5865
|
...subject.confidence !== void 0 ? { confidence: subject.confidence } : {},
|
|
4614
5866
|
zones: subject.zones,
|
|
@@ -4619,9 +5871,9 @@ var NotificationCenter = class {
|
|
|
4619
5871
|
ruleId: rule.id,
|
|
4620
5872
|
targetId: target.targetId,
|
|
4621
5873
|
deviceId: subject.deviceId,
|
|
4622
|
-
recordKind: kind,
|
|
5874
|
+
recordKind: kind === "occupancy-event" ? "device-event" : kind,
|
|
4623
5875
|
recordId: subject.recordId,
|
|
4624
|
-
...subject.trackId !== void 0 ? { trackId: subject.trackId } : {},
|
|
5876
|
+
...isTrackScoped && subject.trackId !== void 0 ? { trackId: subject.trackId } : {},
|
|
4625
5877
|
payload
|
|
4626
5878
|
};
|
|
4627
5879
|
});
|
|
@@ -4637,6 +5889,67 @@ var NotificationCenter = class {
|
|
|
4637
5889
|
if (entry.createdAt > prev) this.lastFiredAt.set(key, entry.createdAt);
|
|
4638
5890
|
}
|
|
4639
5891
|
}
|
|
5892
|
+
/**
|
|
5893
|
+
* Recompute the watcher's watched key set from the enabled occupancy rules
|
|
5894
|
+
* (rule-driven — refreshed on boot + every reload tick). `setWatchedKeys`
|
|
5895
|
+
* drops confirmed state for keys no longer watched; the caller then prunes the
|
|
5896
|
+
* durable rows to the surviving set.
|
|
5897
|
+
*/
|
|
5898
|
+
refreshOccupancyWatch() {
|
|
5899
|
+
const specs = [];
|
|
5900
|
+
for (const rule of this.rules.listEnabled("device-event")) {
|
|
5901
|
+
const occ = rule.conditions.occupancy;
|
|
5902
|
+
if (occ === void 0) continue;
|
|
5903
|
+
specs.push(occupancySpecFromCondition(occ));
|
|
5904
|
+
}
|
|
5905
|
+
this.occupancyWatcher.setWatchedKeys(specs);
|
|
5906
|
+
this.occupancyEnabled = specs.length > 0;
|
|
5907
|
+
}
|
|
5908
|
+
/** Boot reseed of confirmed occupancy edge-state (durability, constraint 4) —
|
|
5909
|
+
* hydrate the watcher from the store, then prune orphaned durable rows to the
|
|
5910
|
+
* active watched set. Runs AFTER {@link refreshOccupancyWatch} so hydrate
|
|
5911
|
+
* restores only currently-watched keys. */
|
|
5912
|
+
async hydrateOccupancy() {
|
|
5913
|
+
const rows = await this.occupancyStore.load();
|
|
5914
|
+
this.occupancyWatcher.hydrate(rows);
|
|
5915
|
+
await this.occupancyStore.pruneExcept(this.activeOccupancyKeys());
|
|
5916
|
+
}
|
|
5917
|
+
/** Rule-reload tick: refresh the rule cache (cross-node CRUD staleness bound),
|
|
5918
|
+
* recompute the occupancy watched set + prune stale durable rows. */
|
|
5919
|
+
async reloadRules() {
|
|
5920
|
+
await this.rules.load();
|
|
5921
|
+
this.refreshOccupancyWatch();
|
|
5922
|
+
if (!this.evaluationActive) return;
|
|
5923
|
+
await this.occupancyStore.pruneExcept(this.activeOccupancyKeys());
|
|
5924
|
+
}
|
|
5925
|
+
/** The full-key set the watcher currently tracks (post prune) — the bound for
|
|
5926
|
+
* {@link OccupancyStore.pruneExcept}. */
|
|
5927
|
+
activeOccupancyKeys() {
|
|
5928
|
+
return new Set(this.occupancyWatcher.snapshotState().map((r) => r.key));
|
|
5929
|
+
}
|
|
5930
|
+
/** Write-through the committed confirmed level for one edge (rare — only on a
|
|
5931
|
+
* boolean flip). Best-effort: a failed persist is logged, never thrown into
|
|
5932
|
+
* the frame path (the boot reseed + next edge recover). */
|
|
5933
|
+
async persistOccupancyEdge(edge, now) {
|
|
5934
|
+
try {
|
|
5935
|
+
await this.occupancyStore.persist({
|
|
5936
|
+
key: occupancyKey(edge.deviceId, edge.zoneId, edge.className, edge.threshold),
|
|
5937
|
+
deviceId: edge.deviceId,
|
|
5938
|
+
...edge.zoneId !== void 0 ? { zoneId: edge.zoneId } : {},
|
|
5939
|
+
...edge.className !== void 0 ? { className: edge.className } : {},
|
|
5940
|
+
threshold: edge.threshold,
|
|
5941
|
+
confirmedCount: edge.count,
|
|
5942
|
+
occupied: edge.occupied,
|
|
5943
|
+
lastChangeAt: edge.timestamp,
|
|
5944
|
+
updatedAt: now
|
|
5945
|
+
});
|
|
5946
|
+
} catch (err) {
|
|
5947
|
+
this.logger.debug("occupancy persist failed", {
|
|
5948
|
+
tags: { deviceId: edge.deviceId },
|
|
5949
|
+
meta: { error: String(err) }
|
|
5950
|
+
});
|
|
5951
|
+
}
|
|
5952
|
+
}
|
|
4640
5953
|
/** Boot crash-gap reconcile — see the module docstring. */
|
|
4641
5954
|
async reconcile() {
|
|
4642
5955
|
const now = this.now();
|
|
@@ -4645,13 +5958,24 @@ var NotificationCenter = class {
|
|
|
4645
5958
|
const since = Math.max(windowStart, (watermark ?? 0) - RECONCILE_OVERLAP_MS);
|
|
4646
5959
|
try {
|
|
4647
5960
|
const ordered = [...await this.deps.listObjectEventsSince(since, RECONCILE_SCAN_LIMIT)].sort((a, b) => a.timestamp - b.timestamp);
|
|
4648
|
-
for (const event of ordered)
|
|
4649
|
-
|
|
4650
|
-
|
|
4651
|
-
|
|
4652
|
-
|
|
4653
|
-
|
|
4654
|
-
|
|
5961
|
+
for (const event of ordered) {
|
|
5962
|
+
const phase = packagePhaseOf(event);
|
|
5963
|
+
if (phase !== null) this.scheduleEvaluation(subjectFromPackageEvent(event, phase), "package-event", () => ({
|
|
5964
|
+
tags: { deviceId: event.deviceId },
|
|
5965
|
+
meta: {
|
|
5966
|
+
eventId: event.id,
|
|
5967
|
+
phase,
|
|
5968
|
+
reconcile: true
|
|
5969
|
+
}
|
|
5970
|
+
}));
|
|
5971
|
+
else this.scheduleEvaluation(subjectFromObjectEvent(event), "object-event", () => ({
|
|
5972
|
+
tags: { deviceId: event.deviceId },
|
|
5973
|
+
meta: {
|
|
5974
|
+
eventId: event.id,
|
|
5975
|
+
reconcile: true
|
|
5976
|
+
}
|
|
5977
|
+
}));
|
|
5978
|
+
}
|
|
4655
5979
|
await this.evalChain;
|
|
4656
5980
|
if (ordered.length > 0) this.logger.info("notification reconcile scanned missed events", { meta: {
|
|
4657
5981
|
since,
|
|
@@ -4680,8 +6004,14 @@ var NotificationCenter = class {
|
|
|
4680
6004
|
const subjects = [];
|
|
4681
6005
|
if (rule.delivery === "immediate") {
|
|
4682
6006
|
const events = await this.deps.listObjectEventsSince(since, RECONCILE_SCAN_LIMIT);
|
|
4683
|
-
for (const ev of events) subjects.push(subjectFromObjectEvent(ev));
|
|
4684
|
-
} else {
|
|
6007
|
+
for (const ev of events) if (packagePhaseOf(ev) === null) subjects.push(subjectFromObjectEvent(ev));
|
|
6008
|
+
} else if (rule.delivery === "package-event") {
|
|
6009
|
+
const events = await this.deps.listObjectEventsSince(since, RECONCILE_SCAN_LIMIT);
|
|
6010
|
+
for (const ev of events) {
|
|
6011
|
+
const phase = packagePhaseOf(ev);
|
|
6012
|
+
if (phase !== null) subjects.push(subjectFromPackageEvent(ev, phase));
|
|
6013
|
+
}
|
|
6014
|
+
} else if (rule.delivery === "track-end") {
|
|
4685
6015
|
const tracks = await this.deps.listRecentTracks(since, TEST_RULE_MAX_RESULTS);
|
|
4686
6016
|
for (const track of tracks) subjects.push(subjectFromTrack(track));
|
|
4687
6017
|
}
|
|
@@ -4691,7 +6021,7 @@ var NotificationCenter = class {
|
|
|
4691
6021
|
const evaluation = evaluateRule(rule, subject);
|
|
4692
6022
|
results.push({
|
|
4693
6023
|
recordId: subject.recordId,
|
|
4694
|
-
recordKind: subject.kind === "
|
|
6024
|
+
recordKind: subject.kind === "track-end" ? "track" : subject.kind === "audio-event" ? "object-event" : subject.kind === "occupancy-event" ? "device-event" : subject.kind,
|
|
4695
6025
|
deviceId: subject.deviceId,
|
|
4696
6026
|
timestamp: subject.timestamp,
|
|
4697
6027
|
wouldFire: evaluation.matched,
|
|
@@ -4704,6 +6034,156 @@ var NotificationCenter = class {
|
|
|
4704
6034
|
}
|
|
4705
6035
|
};
|
|
4706
6036
|
//#endregion
|
|
6037
|
+
//#region src/notification-center/nc-actions.ts
|
|
6038
|
+
/**
|
|
6039
|
+
* nc-actions — the `pipeline-analytics` rule-bridge action catalog.
|
|
6040
|
+
*
|
|
6041
|
+
* The Notification Center viewer NEVER adds a cap method: every rule read/write
|
|
6042
|
+
* rides the generic `addons.custom` bridge (`{ addonId, action, input }`) with a
|
|
6043
|
+
* per-action `nc.*` name, and THIS module enforces the per-action auth +
|
|
6044
|
+
* ownership server-side (spec C1/C2). The addon id is `'pipeline-analytics'`.
|
|
6045
|
+
*
|
|
6046
|
+
* Auth model (spec §3.1 / §5 — server-derived, never client-trusted):
|
|
6047
|
+
* - `nc.listRules` / `nc.getConditionCatalog` — any authenticated caller;
|
|
6048
|
+
* listRules is SCOPED (own personal + global rules only) and stamps a
|
|
6049
|
+
* per-row `readOnly` verdict.
|
|
6050
|
+
* - `nc.createRule` / `nc.updateRule` / `nc.deleteRule` — operate ONLY on
|
|
6051
|
+
* caller-OWNED rules; `ownerUserId` is stamped from the caller and can
|
|
6052
|
+
* never be re-owned through a patch.
|
|
6053
|
+
* - `nc.setRuleTargetEnabled` — a user opting HIS OWN target out of a rule
|
|
6054
|
+
* VISIBLE to him (his own personal rule OR a global/admin rule).
|
|
6055
|
+
*
|
|
6056
|
+
* Target-ownership limits (personal-rule delivery target list + the opt-out
|
|
6057
|
+
* target) are enforced against the notifiers target catalog; ADMINS bypass the
|
|
6058
|
+
* target-ownership limit (they may deliver to / opt any target) but NEVER the
|
|
6059
|
+
* fail-closed caller check — an absent caller (UDS version-skew can still
|
|
6060
|
+
* deliver `undefined`) is rejected before any ownership decision.
|
|
6061
|
+
*/
|
|
6062
|
+
/** A rule as served to the viewer — carries the server's `readOnly` verdict. */
|
|
6063
|
+
var NcViewerRuleSchema = NcRuleSchema.extend({ readOnly: boolean() });
|
|
6064
|
+
/**
|
|
6065
|
+
* The action catalog — the tRPC contract Group B (the viewer client) consumes
|
|
6066
|
+
* against `addonId: 'pipeline-analytics'`. Every entry that depends on the
|
|
6067
|
+
* caller identity is declared `caller: 'required'` so the dispatcher forwards
|
|
6068
|
+
* the server-derived `{ userId, isAdmin }` as the handler's second argument.
|
|
6069
|
+
*/
|
|
6070
|
+
var ncActions = defineCustomActions({
|
|
6071
|
+
"nc.listRules": customAction(object({}), object({ rules: array(NcViewerRuleSchema) }), { caller: "required" }),
|
|
6072
|
+
"nc.getConditionCatalog": customAction(object({}), object({
|
|
6073
|
+
catalog: array(NcConditionDescriptorSchema),
|
|
6074
|
+
taxonomy: NcTaxonomySchema
|
|
6075
|
+
})),
|
|
6076
|
+
"nc.createRule": customAction(object({ rule: NcRuleInputSchema }), object({ rule: NcRuleSchema }), {
|
|
6077
|
+
kind: "mutation",
|
|
6078
|
+
caller: "required"
|
|
6079
|
+
}),
|
|
6080
|
+
"nc.updateRule": customAction(object({
|
|
6081
|
+
ruleId: string(),
|
|
6082
|
+
patch: NcRulePatchSchema
|
|
6083
|
+
}), object({ rule: NcRuleSchema }), {
|
|
6084
|
+
kind: "mutation",
|
|
6085
|
+
caller: "required"
|
|
6086
|
+
}),
|
|
6087
|
+
"nc.deleteRule": customAction(object({ ruleId: string() }), object({ success: literal(true) }), {
|
|
6088
|
+
kind: "mutation",
|
|
6089
|
+
caller: "required"
|
|
6090
|
+
}),
|
|
6091
|
+
"nc.setRuleTargetEnabled": customAction(object({
|
|
6092
|
+
ruleId: string(),
|
|
6093
|
+
targetId: string(),
|
|
6094
|
+
enabled: boolean()
|
|
6095
|
+
}), object({ success: literal(true) }), {
|
|
6096
|
+
kind: "mutation",
|
|
6097
|
+
caller: "required"
|
|
6098
|
+
})
|
|
6099
|
+
});
|
|
6100
|
+
/** Fail-closed caller resolution — an absent forwarded caller is NEVER admin. */
|
|
6101
|
+
function requireCaller(caller) {
|
|
6102
|
+
if (!caller || typeof caller.userId !== "string" || caller.userId.length === 0) throw new Error("forbidden: authenticated caller required");
|
|
6103
|
+
return caller;
|
|
6104
|
+
}
|
|
6105
|
+
function makeNcActionHandlers(deps) {
|
|
6106
|
+
/** A rule the caller may EDIT — must exist and be owned by the caller. */
|
|
6107
|
+
const assertOwnsRule = (ruleId) => {
|
|
6108
|
+
const rule = deps.ruleStore.get(ruleId);
|
|
6109
|
+
if (rule === null) throw new Error(`forbidden: rule not found: ${ruleId}`);
|
|
6110
|
+
return rule;
|
|
6111
|
+
};
|
|
6112
|
+
const assertRuleOwned = (ruleId, userId) => {
|
|
6113
|
+
const rule = assertOwnsRule(ruleId);
|
|
6114
|
+
if (rule.ownerUserId !== userId) throw new Error(`forbidden: rule not owned: ${ruleId}`);
|
|
6115
|
+
return rule;
|
|
6116
|
+
};
|
|
6117
|
+
/** A rule the caller may SEE — his own personal rule OR a global/admin rule. */
|
|
6118
|
+
const assertRuleVisible = (ruleId, userId) => {
|
|
6119
|
+
const rule = assertOwnsRule(ruleId);
|
|
6120
|
+
if (rule.ownerUserId !== void 0 && rule.ownerUserId !== userId) throw new Error(`forbidden: rule not owned: ${ruleId}`);
|
|
6121
|
+
return rule;
|
|
6122
|
+
};
|
|
6123
|
+
const assertTargetsOwned = async (targetIds, caller) => {
|
|
6124
|
+
if (caller.isAdmin) return;
|
|
6125
|
+
const owned = new Set(await deps.listCallerTargetIds(caller.userId));
|
|
6126
|
+
for (const id of targetIds) if (!owned.has(id)) throw new Error(`forbidden: target not owned: ${id}`);
|
|
6127
|
+
};
|
|
6128
|
+
return {
|
|
6129
|
+
"nc.listRules": async (_input, caller) => {
|
|
6130
|
+
const c = requireCaller(caller);
|
|
6131
|
+
return { rules: deps.ruleStore.listForOwner(c.userId).map((r) => ({
|
|
6132
|
+
...r,
|
|
6133
|
+
readOnly: r.ownerUserId !== c.userId
|
|
6134
|
+
})) };
|
|
6135
|
+
},
|
|
6136
|
+
"nc.getConditionCatalog": async () => ({
|
|
6137
|
+
catalog: [...NC_CONDITION_CATALOG],
|
|
6138
|
+
taxonomy: NC_TAXONOMY
|
|
6139
|
+
}),
|
|
6140
|
+
"nc.createRule": async (input, caller) => {
|
|
6141
|
+
const c = requireCaller(caller);
|
|
6142
|
+
const parsed = NcRuleInputSchema.parse(input.rule);
|
|
6143
|
+
await assertTargetsOwned(parsed.targets.map((t) => t.targetId), c);
|
|
6144
|
+
const rule = await deps.ruleStore.create({
|
|
6145
|
+
...parsed,
|
|
6146
|
+
ownerUserId: c.userId
|
|
6147
|
+
}, c.userId);
|
|
6148
|
+
deps.logger.info("nc rule created", { meta: {
|
|
6149
|
+
ruleId: rule.id,
|
|
6150
|
+
owner: c.userId
|
|
6151
|
+
} });
|
|
6152
|
+
return { rule };
|
|
6153
|
+
},
|
|
6154
|
+
"nc.updateRule": async (input, caller) => {
|
|
6155
|
+
const c = requireCaller(caller);
|
|
6156
|
+
assertRuleOwned(input.ruleId, c.userId);
|
|
6157
|
+
const patch = NcRulePatchSchema.parse(input.patch);
|
|
6158
|
+
if (patch.targets !== void 0) await assertTargetsOwned(patch.targets.map((t) => t.targetId), c);
|
|
6159
|
+
const { ownerUserId: _owner, disabledTargetIds: _optOut, ...safe } = patch;
|
|
6160
|
+
const rule = await deps.ruleStore.update(input.ruleId, safe);
|
|
6161
|
+
deps.logger.info("nc rule updated", { meta: {
|
|
6162
|
+
ruleId: rule.id,
|
|
6163
|
+
owner: c.userId
|
|
6164
|
+
} });
|
|
6165
|
+
return { rule };
|
|
6166
|
+
},
|
|
6167
|
+
"nc.deleteRule": async (input, caller) => {
|
|
6168
|
+
const c = requireCaller(caller);
|
|
6169
|
+
assertRuleOwned(input.ruleId, c.userId);
|
|
6170
|
+
await deps.ruleStore.delete(input.ruleId);
|
|
6171
|
+
deps.logger.info("nc rule deleted", { meta: {
|
|
6172
|
+
ruleId: input.ruleId,
|
|
6173
|
+
owner: c.userId
|
|
6174
|
+
} });
|
|
6175
|
+
return { success: true };
|
|
6176
|
+
},
|
|
6177
|
+
"nc.setRuleTargetEnabled": async (input, caller) => {
|
|
6178
|
+
const c = requireCaller(caller);
|
|
6179
|
+
assertRuleVisible(input.ruleId, c.userId);
|
|
6180
|
+
await assertTargetsOwned([input.targetId], c);
|
|
6181
|
+
await deps.ruleStore.setRuleTargetEnabled(input.ruleId, input.targetId, input.enabled);
|
|
6182
|
+
return { success: true };
|
|
6183
|
+
}
|
|
6184
|
+
};
|
|
6185
|
+
}
|
|
6186
|
+
//#endregion
|
|
4707
6187
|
//#region src/pipeline-analytics/pipeline/object-embedding-selection.ts
|
|
4708
6188
|
function isClipObjectEmbedding(t) {
|
|
4709
6189
|
return Array.isArray(t.embedding) && t.embedding.length > 0 && typeof t.embeddingModelId === "string" && t.embeddingModelId.startsWith("mobileclip-");
|
|
@@ -4713,39 +6193,42 @@ function resolveSearchThumbnailUrl(input) {
|
|
|
4713
6193
|
const id = input.embeddingMediaKey ?? input.eventId;
|
|
4714
6194
|
return `${input.baseUrl}/${encodeURIComponent(id)}`;
|
|
4715
6195
|
}
|
|
4716
|
-
|
|
4717
|
-
|
|
4718
|
-
|
|
4719
|
-
*
|
|
4720
|
-
*
|
|
4721
|
-
|
|
4722
|
-
|
|
4723
|
-
|
|
4724
|
-
* (near-)the whole frame — the "envelope exploded to full-frame" signature. If
|
|
4725
|
-
* that frame happens to win the best-detection race, the gallery/reel best
|
|
4726
|
-
* `thumbnail` becomes a useless full-scene tile (the subject crop is the entire
|
|
4727
|
-
* washed-out frame), not the subject. This guard rejects such a frame from the
|
|
4728
|
-
* best-`thumbnail` decision so the track keeps a real subject-centered tile.
|
|
4729
|
-
*
|
|
4730
|
-
* Conservative by design: it only rejects boxes covering ≥ {@link
|
|
4731
|
-
* NEAR_FULL_FRAME_AREA} of the frame — a genuine large close-up subject stays
|
|
4732
|
-
* well under this. Rejecting a frame does NOT land a thumbnail, so the #27-A
|
|
4733
|
-
* per-frame retry keeps trying until a plausible frame wins.
|
|
4734
|
-
*/
|
|
6196
|
+
function isPlausibleThumbnailBox(bbox, frameWidth, frameHeight, score) {
|
|
6197
|
+
if (frameWidth <= 0 || frameHeight <= 0) return true;
|
|
6198
|
+
if (score !== void 0 && score >= .5) return true;
|
|
6199
|
+
if (bbox.w * bbox.h / (frameWidth * frameHeight) >= .85) return false;
|
|
6200
|
+
if (bbox.h >= frameHeight * .95) return false;
|
|
6201
|
+
if (bbox.w >= frameWidth * .95) return false;
|
|
6202
|
+
return true;
|
|
6203
|
+
}
|
|
4735
6204
|
/**
|
|
4736
|
-
*
|
|
4737
|
-
*
|
|
4738
|
-
*
|
|
6205
|
+
* Minimum fraction of the track's BEST-SEEN bbox area a retry frame's bbox must
|
|
6206
|
+
* still cover to be an acceptable best-`thumbnail` RETRY subject. 0.45 tolerates
|
|
6207
|
+
* normal breathing of the tracker box (~2/3 per side) while rejecting the
|
|
6208
|
+
* degenerate shrink (observed live 2026-07-22: a car born at 78px retried at
|
|
6209
|
+
* 26px once the native lease warmed — a ~0.11 area fraction — and the landed
|
|
6210
|
+
* "best" thumbnail was a sliver of a distant car).
|
|
4739
6211
|
*/
|
|
4740
|
-
var
|
|
6212
|
+
var RETRY_MIN_FRACTION_OF_BEST = .45;
|
|
4741
6213
|
/**
|
|
4742
|
-
*
|
|
4743
|
-
*
|
|
4744
|
-
*
|
|
6214
|
+
* RETRY-ONLY gate (#27-A degenerate-retry fix): on a cold on-motion session the
|
|
6215
|
+
* native lease misses the first seconds, so the large-subject captures return
|
|
6216
|
+
* null and the per-frame retry re-fires with the CURRENT frame's bbox. By the
|
|
6217
|
+
* time the lease warms the subject may have shrunk to a sliver; capturing THAT
|
|
6218
|
+
* frame lands a genuine-native-but-useless thumbnail and `thumbnailLanded`
|
|
6219
|
+
* stops all further retries. Reject a retry whose current bbox area has
|
|
6220
|
+
* collapsed below `minFractionOfBest` of the track's best-seen bbox area.
|
|
6221
|
+
*
|
|
6222
|
+
* Applies ONLY to the `!thumbnailLanded` retry path — a genuine new-best
|
|
6223
|
+
* capture is never routed through this gate (the caller bypasses it on
|
|
6224
|
+
* `isNewBest`). With no best-seen reference (or a degenerate one) there is no
|
|
6225
|
+
* info to reject on → plausible.
|
|
4745
6226
|
*/
|
|
4746
|
-
function
|
|
4747
|
-
if (
|
|
4748
|
-
|
|
6227
|
+
function isPlausibleRetryBox(current, bestSeen, minFractionOfBest = RETRY_MIN_FRACTION_OF_BEST) {
|
|
6228
|
+
if (bestSeen === null) return true;
|
|
6229
|
+
const bestArea = bestSeen.w * bestSeen.h;
|
|
6230
|
+
if (bestArea <= 0) return true;
|
|
6231
|
+
return current.w * current.h >= minFractionOfBest * bestArea;
|
|
4749
6232
|
}
|
|
4750
6233
|
//#endregion
|
|
4751
6234
|
//#region src/pipeline-analytics/pipeline/stationary/stationary-types.ts
|
|
@@ -5488,7 +6971,7 @@ var BindingCache = class {
|
|
|
5488
6971
|
this.state.set(deviceId, active);
|
|
5489
6972
|
return active;
|
|
5490
6973
|
} catch (err) {
|
|
5491
|
-
this.logger.
|
|
6974
|
+
this.logger.warn("BindingCache.isActive lookup failed — frames will drop as inactive", {
|
|
5492
6975
|
tags: { deviceId },
|
|
5493
6976
|
meta: { error: String(err) }
|
|
5494
6977
|
});
|
|
@@ -5961,9 +7444,9 @@ var DISPLAY_FALLBACK_MAX_WIDTH = 1920;
|
|
|
5961
7444
|
/** Throttle for the native-crop HIT/FALLBACK metric window line. */
|
|
5962
7445
|
var NATIVE_CROP_METRIC_INTERVAL_MS = 3e4;
|
|
5963
7446
|
function createNativeFrameTransport(deps) {
|
|
5964
|
-
const { api,
|
|
7447
|
+
const { api, logger } = deps;
|
|
5965
7448
|
const pipelineRunnerApi = api.pipelineRunner;
|
|
5966
|
-
const
|
|
7449
|
+
const wantJpeg = (_handle) => true;
|
|
5967
7450
|
const cropReplyToRgb = async (reply) => {
|
|
5968
7451
|
if (reply.jpeg !== void 0) {
|
|
5969
7452
|
const rgb = await decodeJpegToRgb(reply.jpeg);
|
|
@@ -5991,7 +7474,7 @@ function createNativeFrameTransport(deps) {
|
|
|
5991
7474
|
h: 1
|
|
5992
7475
|
},
|
|
5993
7476
|
maxWidth: handle.width,
|
|
5994
|
-
encodeJpeg:
|
|
7477
|
+
encodeJpeg: wantJpeg(handle)
|
|
5995
7478
|
}, nodePin(handle.nodeId));
|
|
5996
7479
|
if (!full || full.width <= 0 || full.height <= 0) return null;
|
|
5997
7480
|
const rgb = await cropReplyToRgb(full);
|
|
@@ -6015,7 +7498,7 @@ function createNativeFrameTransport(deps) {
|
|
|
6015
7498
|
h: 1
|
|
6016
7499
|
},
|
|
6017
7500
|
maxWidth,
|
|
6018
|
-
encodeJpeg:
|
|
7501
|
+
encodeJpeg: wantJpeg(handle)
|
|
6019
7502
|
}, nodePin(handle.nodeId));
|
|
6020
7503
|
if (!full || full.width <= 0 || full.height <= 0) return null;
|
|
6021
7504
|
const rgb = await cropReplyToRgb(full);
|
|
@@ -6055,7 +7538,7 @@ function createNativeFrameTransport(deps) {
|
|
|
6055
7538
|
handle: frameHandle,
|
|
6056
7539
|
bbox: paddedNorm,
|
|
6057
7540
|
...maxWidth !== void 0 ? { maxWidth } : {},
|
|
6058
|
-
encodeJpeg:
|
|
7541
|
+
encodeJpeg: wantJpeg(frameHandle)
|
|
6059
7542
|
}, nodePin(frameHandle.nodeId));
|
|
6060
7543
|
if (!native || native.width <= 0 || native.height <= 0) return null;
|
|
6061
7544
|
return await cropReplyToRgb(native);
|
|
@@ -6352,153 +7835,41 @@ function createAnalyticsWidgetsProvider() {
|
|
|
6352
7835
|
}
|
|
6353
7836
|
//#endregion
|
|
6354
7837
|
//#region src/pipeline-analytics/store/recent-cursor.ts
|
|
6355
|
-
function encodeRecentCursor(cursor) {
|
|
6356
|
-
return Buffer.from(JSON.stringify({
|
|
6357
|
-
l: cursor.lastSeen,
|
|
6358
|
-
i: cursor.trackId
|
|
6359
|
-
}), "utf8").toString("base64url");
|
|
6360
|
-
}
|
|
6361
|
-
/**
|
|
6362
|
-
* Decode + validate an opaque cursor. Fails fast with a clear error on any
|
|
6363
|
-
* malformed input (bad base64, bad JSON, wrong field types) — a garbage
|
|
6364
|
-
* cursor must never silently degrade into a full-history first page.
|
|
6365
|
-
*/
|
|
6366
|
-
function decodeRecentCursor(raw) {
|
|
6367
|
-
let parsed;
|
|
6368
|
-
try {
|
|
6369
|
-
parsed = JSON.parse(Buffer.from(raw, "base64url").toString("utf8"));
|
|
6370
|
-
} catch {
|
|
6371
|
-
throw new Error("listRecentTracks: malformed cursor");
|
|
6372
|
-
}
|
|
6373
|
-
if (parsed === null || typeof parsed !== "object" || !("l" in parsed) || !("i" in parsed) || typeof parsed.l !== "number" || !Number.isFinite(parsed.l) || typeof parsed.i !== "string" || parsed.i.length === 0) throw new Error("listRecentTracks: malformed cursor");
|
|
6374
|
-
return {
|
|
6375
|
-
lastSeen: parsed.l,
|
|
6376
|
-
trackId: parsed.i
|
|
6377
|
-
};
|
|
6378
|
-
}
|
|
6379
|
-
/** Comparator for the (lastSeen DESC, id DESC) total order. */
|
|
6380
|
-
function compareRecentDesc(a, b) {
|
|
6381
|
-
if (a.lastSeen !== b.lastSeen) return b.lastSeen - a.lastSeen;
|
|
6382
|
-
if (a.id === b.id) return 0;
|
|
6383
|
-
return a.id < b.id ? 1 : -1;
|
|
6384
|
-
}
|
|
6385
|
-
/** True when `row` sits strictly AFTER the cursor position in DESC order
|
|
6386
|
-
* (i.e. belongs to the next page). */
|
|
6387
|
-
function isAfterCursor(row, cursor) {
|
|
6388
|
-
if (row.lastSeen < cursor.lastSeen) return true;
|
|
6389
|
-
return row.lastSeen === cursor.lastSeen && row.id < cursor.trackId;
|
|
6390
|
-
}
|
|
6391
|
-
//#endregion
|
|
6392
|
-
//#region src/pipeline-analytics/store/zone-geometry.ts
|
|
6393
|
-
/**
|
|
6394
|
-
* Normalized min/max envelope over every position's bbox. Returns `null`
|
|
6395
|
-
* when the frame dimensions are unknown/degenerate or there are no
|
|
6396
|
-
* positions — the caller persists NULL envelope columns in that case.
|
|
6397
|
-
*/
|
|
6398
|
-
function computeTrackEnvelope(positions, frameWidth, frameHeight) {
|
|
6399
|
-
if (frameWidth === void 0 || frameHeight === void 0 || !(frameWidth > 0) || !(frameHeight > 0) || positions.length === 0) return null;
|
|
6400
|
-
let minX = Number.POSITIVE_INFINITY;
|
|
6401
|
-
let minY = Number.POSITIVE_INFINITY;
|
|
6402
|
-
let maxX = Number.NEGATIVE_INFINITY;
|
|
6403
|
-
let maxY = Number.NEGATIVE_INFINITY;
|
|
6404
|
-
for (const p of positions) {
|
|
6405
|
-
const x0 = p.bbox.x / frameWidth;
|
|
6406
|
-
const y0 = p.bbox.y / frameHeight;
|
|
6407
|
-
const x1 = (p.bbox.x + p.bbox.w) / frameWidth;
|
|
6408
|
-
const y1 = (p.bbox.y + p.bbox.h) / frameHeight;
|
|
6409
|
-
if (x0 < minX) minX = x0;
|
|
6410
|
-
if (y0 < minY) minY = y0;
|
|
6411
|
-
if (x1 > maxX) maxX = x1;
|
|
6412
|
-
if (y1 > maxY) maxY = y1;
|
|
6413
|
-
}
|
|
6414
|
-
return {
|
|
6415
|
-
minX,
|
|
6416
|
-
minY,
|
|
6417
|
-
maxX,
|
|
6418
|
-
maxY
|
|
6419
|
-
};
|
|
6420
|
-
}
|
|
6421
|
-
/**
|
|
6422
|
-
* Axis-aligned bounds of a zone filter (rect: itself; polygon: vertex
|
|
6423
|
-
* min/max). A degenerate polygon (< 3 points) yields the full frame so the
|
|
6424
|
-
* SQL prefilter never silently drops rows the precise test would keep.
|
|
6425
|
-
*/
|
|
6426
|
-
function zoneBounds(zone) {
|
|
6427
|
-
if (zone.kind === "rect") return {
|
|
6428
|
-
minX: zone.x,
|
|
6429
|
-
minY: zone.y,
|
|
6430
|
-
maxX: zone.x + zone.width,
|
|
6431
|
-
maxY: zone.y + zone.height
|
|
6432
|
-
};
|
|
6433
|
-
if (zone.points.length < 3) return {
|
|
6434
|
-
minX: 0,
|
|
6435
|
-
minY: 0,
|
|
6436
|
-
maxX: 1,
|
|
6437
|
-
maxY: 1
|
|
6438
|
-
};
|
|
6439
|
-
let minX = Number.POSITIVE_INFINITY;
|
|
6440
|
-
let minY = Number.POSITIVE_INFINITY;
|
|
6441
|
-
let maxX = Number.NEGATIVE_INFINITY;
|
|
6442
|
-
let maxY = Number.NEGATIVE_INFINITY;
|
|
6443
|
-
for (const p of zone.points) {
|
|
6444
|
-
if (p.x < minX) minX = p.x;
|
|
6445
|
-
if (p.y < minY) minY = p.y;
|
|
6446
|
-
if (p.x > maxX) maxX = p.x;
|
|
6447
|
-
if (p.y > maxY) maxY = p.y;
|
|
6448
|
-
}
|
|
6449
|
-
return {
|
|
6450
|
-
minX,
|
|
6451
|
-
minY,
|
|
6452
|
-
maxX,
|
|
6453
|
-
maxY
|
|
6454
|
-
};
|
|
6455
|
-
}
|
|
6456
|
-
/** Whether two axis-aligned envelopes overlap (touching edges count). */
|
|
6457
|
-
function envelopesOverlap(a, b) {
|
|
6458
|
-
return a.minX <= b.maxX && a.maxX >= b.minX && a.minY <= b.maxY && a.maxY >= b.minY;
|
|
7838
|
+
function encodeRecentCursor(cursor) {
|
|
7839
|
+
return Buffer.from(JSON.stringify({
|
|
7840
|
+
l: cursor.lastSeen,
|
|
7841
|
+
i: cursor.trackId
|
|
7842
|
+
}), "utf8").toString("base64url");
|
|
6459
7843
|
}
|
|
6460
7844
|
/**
|
|
6461
|
-
*
|
|
6462
|
-
*
|
|
6463
|
-
*
|
|
7845
|
+
* Decode + validate an opaque cursor. Fails fast with a clear error on any
|
|
7846
|
+
* malformed input (bad base64, bad JSON, wrong field types) — a garbage
|
|
7847
|
+
* cursor must never silently degrade into a full-history first page.
|
|
6464
7848
|
*/
|
|
6465
|
-
function
|
|
6466
|
-
|
|
6467
|
-
|
|
6468
|
-
|
|
6469
|
-
|
|
6470
|
-
|
|
6471
|
-
if (a.y > point.y !== b.y > point.y && point.x < (b.x - a.x) * (point.y - a.y) / (b.y - a.y) + a.x) inside = !inside;
|
|
7849
|
+
function decodeRecentCursor(raw) {
|
|
7850
|
+
let parsed;
|
|
7851
|
+
try {
|
|
7852
|
+
parsed = JSON.parse(Buffer.from(raw, "base64url").toString("utf8"));
|
|
7853
|
+
} catch {
|
|
7854
|
+
throw new Error("listRecentTracks: malformed cursor");
|
|
6472
7855
|
}
|
|
6473
|
-
|
|
7856
|
+
if (parsed === null || typeof parsed !== "object" || !("l" in parsed) || !("i" in parsed) || typeof parsed.l !== "number" || !Number.isFinite(parsed.l) || typeof parsed.i !== "string" || parsed.i.length === 0) throw new Error("listRecentTracks: malformed cursor");
|
|
7857
|
+
return {
|
|
7858
|
+
lastSeen: parsed.l,
|
|
7859
|
+
trackId: parsed.i
|
|
7860
|
+
};
|
|
6474
7861
|
}
|
|
6475
|
-
/**
|
|
6476
|
-
|
|
6477
|
-
|
|
6478
|
-
|
|
6479
|
-
|
|
6480
|
-
|
|
6481
|
-
|
|
6482
|
-
*
|
|
6483
|
-
|
|
6484
|
-
|
|
6485
|
-
|
|
6486
|
-
if (frameWidth === void 0 || frameHeight === void 0 || !(frameWidth > 0) || !(frameHeight > 0)) return true;
|
|
6487
|
-
if (zone.kind === "rect") {
|
|
6488
|
-
const rect = zoneBounds(zone);
|
|
6489
|
-
for (const p of positions) if (envelopesOverlap({
|
|
6490
|
-
minX: p.bbox.x / frameWidth,
|
|
6491
|
-
minY: p.bbox.y / frameHeight,
|
|
6492
|
-
maxX: (p.bbox.x + p.bbox.w) / frameWidth,
|
|
6493
|
-
maxY: (p.bbox.y + p.bbox.h) / frameHeight
|
|
6494
|
-
}, rect)) return true;
|
|
6495
|
-
return false;
|
|
6496
|
-
}
|
|
6497
|
-
for (const p of positions) if (pointInPolygon({
|
|
6498
|
-
x: p.x / frameWidth,
|
|
6499
|
-
y: p.y / frameHeight
|
|
6500
|
-
}, zone.points)) return true;
|
|
6501
|
-
return false;
|
|
7862
|
+
/** Comparator for the (lastSeen DESC, id DESC) total order. */
|
|
7863
|
+
function compareRecentDesc(a, b) {
|
|
7864
|
+
if (a.lastSeen !== b.lastSeen) return b.lastSeen - a.lastSeen;
|
|
7865
|
+
if (a.id === b.id) return 0;
|
|
7866
|
+
return a.id < b.id ? 1 : -1;
|
|
7867
|
+
}
|
|
7868
|
+
/** True when `row` sits strictly AFTER the cursor position in DESC order
|
|
7869
|
+
* (i.e. belongs to the next page). */
|
|
7870
|
+
function isAfterCursor(row, cursor) {
|
|
7871
|
+
if (row.lastSeen < cursor.lastSeen) return true;
|
|
7872
|
+
return row.lastSeen === cursor.lastSeen && row.id < cursor.trackId;
|
|
6502
7873
|
}
|
|
6503
7874
|
//#endregion
|
|
6504
7875
|
//#region src/pipeline-analytics/store/track-store.ts
|
|
@@ -7590,6 +8961,11 @@ var MediaStore = class {
|
|
|
7590
8961
|
* and no duplicate snapshot/lastFrame pair. The `lastFrame` write lands BEFORE
|
|
7591
8962
|
* the snapshot delete, so the view is never momentarily absent. Returns the new
|
|
7592
8963
|
* `lastFrame` key.
|
|
8964
|
+
*
|
|
8965
|
+
* `keepSource: true` (short-track keyFrameSmall fallback, 2026-07-23): copy
|
|
8966
|
+
* the bytes into the `lastFrame` slot WITHOUT deleting the source row — used
|
|
8967
|
+
* when the source is the track's `keyFrameSmall`, which must keep serving its
|
|
8968
|
+
* own kind.
|
|
7593
8969
|
*/
|
|
7594
8970
|
async promoteToLastFrame(input) {
|
|
7595
8971
|
const data = Buffer.from(input.snapshot.base64, "base64");
|
|
@@ -7601,7 +8977,7 @@ var MediaStore = class {
|
|
|
7601
8977
|
timestamp: input.snapshot.timestamp,
|
|
7602
8978
|
data
|
|
7603
8979
|
});
|
|
7604
|
-
await this.deleteByKey(input.snapshot.key);
|
|
8980
|
+
if (input.keepSource !== true) await this.deleteByKey(input.snapshot.key);
|
|
7605
8981
|
return newKey;
|
|
7606
8982
|
}
|
|
7607
8983
|
/**
|
|
@@ -9179,15 +10555,19 @@ async function ingestSensorStateChange(deps, data, timestamp) {
|
|
|
9179
10555
|
const makeId = deps.makeId ?? (() => `pa-sensor-${randomUUID()}`);
|
|
9180
10556
|
let inserted = 0;
|
|
9181
10557
|
for (const cameraId of cameraIds) {
|
|
9182
|
-
|
|
10558
|
+
const ev = {
|
|
9183
10559
|
id: makeId(),
|
|
9184
10560
|
deviceId: cameraId,
|
|
9185
10561
|
sourceDeviceId: data.deviceId,
|
|
9186
10562
|
kind: descriptor.kind,
|
|
9187
10563
|
value,
|
|
9188
10564
|
timestamp
|
|
9189
|
-
}
|
|
10565
|
+
};
|
|
10566
|
+
await deps.sink.insert(ev);
|
|
9190
10567
|
inserted++;
|
|
10568
|
+
if (deps.onPersisted !== void 0) try {
|
|
10569
|
+
deps.onPersisted(ev);
|
|
10570
|
+
} catch {}
|
|
9191
10571
|
}
|
|
9192
10572
|
return inserted;
|
|
9193
10573
|
}
|
|
@@ -9575,7 +10955,7 @@ async function composeWideCentralSquareThumbnail(slab, layout) {
|
|
|
9575
10955
|
const leftPad = Math.max(0, Math.round(layout.slabOffsetX * scale));
|
|
9576
10956
|
const rightPad = Math.max(0, canvasNativeW - nativeW - leftPad);
|
|
9577
10957
|
if (leftPad === 0 && rightPad === 0) return slab;
|
|
9578
|
-
return sharp(await sharp(slab).resize(canvasNativeW, nativeH, { fit: "
|
|
10958
|
+
return sharp(await sharp(slab).resize(canvasNativeW, nativeH, { fit: "cover" }).blur(LETTERBOX_BLUR_SIGMA).modulate({ brightness: LETTERBOX_BLUR_BRIGHTNESS }).toBuffer()).composite([{
|
|
9579
10959
|
input: slab,
|
|
9580
10960
|
left: leftPad,
|
|
9581
10961
|
top: 0
|
|
@@ -9742,8 +11122,11 @@ function isUniformRgbFrame(data, width, height) {
|
|
|
9742
11122
|
*
|
|
9743
11123
|
* The detection frame is resolved ONCE per call (blank-frame guard); native full
|
|
9744
11124
|
* frames + subject crops are fetched per target by handle. Frames recycle in
|
|
9745
|
-
* milliseconds, so the caller
|
|
9746
|
-
*
|
|
11125
|
+
* milliseconds, so the caller either invokes this while the handle is still
|
|
11126
|
+
* live, or — when the call is queued behind a capture lane (the S3 batch
|
|
11127
|
+
* dispatch) — pre-resolves the ≤640 frame in the live window via
|
|
11128
|
+
* {@link resolvePinnedFrame} and passes it as `pinnedFrame`, which skips the
|
|
11129
|
+
* by-handle resolve for the ≤640 tier entirely.
|
|
9747
11130
|
*/
|
|
9748
11131
|
var EventMediaDispatcher = class {
|
|
9749
11132
|
deps;
|
|
@@ -9763,6 +11146,61 @@ var EventMediaDispatcher = class {
|
|
|
9763
11146
|
this.deps = deps;
|
|
9764
11147
|
this.sharedNativeFullFrame = createSharedFrameResolver((handle) => this.deps.getNativeFullFrameRgb(handle, SNAPSHOT_MAX_WIDTH));
|
|
9765
11148
|
}
|
|
11149
|
+
/**
|
|
11150
|
+
* Resolve the ≤640 detection frame by handle NOW — in the caller's LIVE frame
|
|
11151
|
+
* window — and COPY its pixel data (`Buffer.from`) so ring recycling can never
|
|
11152
|
+
* touch it. This is the seam the per-frame batch dispatch uses to pin the
|
|
11153
|
+
* frame BEFORE queueing on the CaptureScheduler: since S3 the batch is one
|
|
11154
|
+
* scheduler request against a 3-slot device lane and can start seconds late,
|
|
11155
|
+
* by which time the handle's ring slot is usually recycled and the by-handle
|
|
11156
|
+
* resolve in {@link captureForFrame} finds nothing (the 2026-07-23 boxed-tile
|
|
11157
|
+
* regression). ~0.7MB per pinned frame, freed with the batch — bounded by the
|
|
11158
|
+
* lane. Returns `null` on a genuine live-window miss (logged at debug — the
|
|
11159
|
+
* caller then omits `pinnedFrame` and captureForFrame's by-handle resolve,
|
|
11160
|
+
* WARN-logged on failure, remains the fallback).
|
|
11161
|
+
*/
|
|
11162
|
+
async resolvePinnedFrame(deviceId, frameHandle) {
|
|
11163
|
+
try {
|
|
11164
|
+
const decoded = await resolveFrame(frameHandle, { getRemoteFrame: this.deps.getRemoteFrame });
|
|
11165
|
+
if (!decoded) return null;
|
|
11166
|
+
return {
|
|
11167
|
+
...decoded,
|
|
11168
|
+
data: Buffer.from(decoded.data)
|
|
11169
|
+
};
|
|
11170
|
+
} catch (err) {
|
|
11171
|
+
this.deps.logger.debug("event media: pinned-frame resolve threw (live window)", {
|
|
11172
|
+
tags: { deviceId },
|
|
11173
|
+
meta: {
|
|
11174
|
+
deviceId,
|
|
11175
|
+
shmId: frameHandle.shmId,
|
|
11176
|
+
error: String(err)
|
|
11177
|
+
}
|
|
11178
|
+
});
|
|
11179
|
+
return null;
|
|
11180
|
+
}
|
|
11181
|
+
}
|
|
11182
|
+
/**
|
|
11183
|
+
* WARN-log a whole-batch capture abort: every early return in
|
|
11184
|
+
* {@link captureForFrame} loses ALL of the frame's targets at once —
|
|
11185
|
+
* firstFrame + snapshot/lastFrame/thumbnail + event child crops — which
|
|
11186
|
+
* previously died with DEBUG-only logs (invisible in production, the silent
|
|
11187
|
+
* half of the boxed-tile regression). The per-kind lost-target counts make
|
|
11188
|
+
* the blast radius visible.
|
|
11189
|
+
*/
|
|
11190
|
+
warnBatchLost(reason, input, extraMeta = {}) {
|
|
11191
|
+
this.deps.logger.warn(`event media: capture batch lost — ${reason}`, {
|
|
11192
|
+
tags: { deviceId: input.deviceId },
|
|
11193
|
+
meta: {
|
|
11194
|
+
deviceId: input.deviceId,
|
|
11195
|
+
shmId: input.frameHandle.shmId,
|
|
11196
|
+
pinned: input.pinnedFrame !== void 0,
|
|
11197
|
+
lostEventTargets: input.events.length,
|
|
11198
|
+
lostFirstFrameTargets: input.trackFrames.length,
|
|
11199
|
+
lostSnapshotTargets: input.snapshots?.length ?? 0,
|
|
11200
|
+
...extraMeta
|
|
11201
|
+
}
|
|
11202
|
+
});
|
|
11203
|
+
}
|
|
9766
11204
|
async captureForFrame(input) {
|
|
9767
11205
|
const { deviceId, frameHandle, events, trackFrames } = input;
|
|
9768
11206
|
const snapshots = input.snapshots ?? [];
|
|
@@ -9776,51 +11214,28 @@ var EventMediaDispatcher = class {
|
|
|
9776
11214
|
const extraCandidateCount = input.rasterFallbackCandidates?.length ?? 0;
|
|
9777
11215
|
if (events.length === 0 && trackFrames.length === 0 && snapshots.length === 0 && extraCandidateCount === 0) return empty;
|
|
9778
11216
|
let decoded;
|
|
9779
|
-
|
|
11217
|
+
if (input.pinnedFrame !== void 0) decoded = input.pinnedFrame;
|
|
11218
|
+
else try {
|
|
9780
11219
|
decoded = await resolveFrame(frameHandle, { getRemoteFrame: this.deps.getRemoteFrame });
|
|
9781
11220
|
} catch (err) {
|
|
9782
|
-
this.
|
|
9783
|
-
tags: { deviceId },
|
|
9784
|
-
meta: {
|
|
9785
|
-
deviceId,
|
|
9786
|
-
shmId: frameHandle.shmId,
|
|
9787
|
-
error: String(err)
|
|
9788
|
-
}
|
|
9789
|
-
});
|
|
11221
|
+
this.warnBatchLost("resolveFrame threw", input, { error: String(err) });
|
|
9790
11222
|
return empty;
|
|
9791
11223
|
}
|
|
9792
11224
|
if (!decoded) {
|
|
9793
|
-
this.
|
|
9794
|
-
tags: { deviceId },
|
|
9795
|
-
meta: {
|
|
9796
|
-
deviceId,
|
|
9797
|
-
shmId: frameHandle.shmId
|
|
9798
|
-
}
|
|
9799
|
-
});
|
|
11225
|
+
this.warnBatchLost("frame recycled before resolve", input);
|
|
9800
11226
|
return empty;
|
|
9801
11227
|
}
|
|
9802
11228
|
if (decoded.format !== "rgb") {
|
|
9803
|
-
this.
|
|
9804
|
-
tags: { deviceId },
|
|
9805
|
-
meta: {
|
|
9806
|
-
deviceId,
|
|
9807
|
-
format: decoded.format
|
|
9808
|
-
}
|
|
9809
|
-
});
|
|
11229
|
+
this.warnBatchLost("resolved frame is not RGB", input, { format: decoded.format });
|
|
9810
11230
|
return empty;
|
|
9811
11231
|
}
|
|
9812
|
-
const frameData = Buffer.from(decoded.data);
|
|
11232
|
+
const frameData = input.pinnedFrame !== void 0 ? decoded.data : Buffer.from(decoded.data);
|
|
9813
11233
|
const fw = decoded.width;
|
|
9814
11234
|
const fh = decoded.height;
|
|
9815
11235
|
if (isUniformRgbFrame(frameData, fw, fh)) {
|
|
9816
|
-
this.
|
|
9817
|
-
|
|
9818
|
-
|
|
9819
|
-
deviceId,
|
|
9820
|
-
shmId: frameHandle.shmId,
|
|
9821
|
-
width: fw,
|
|
9822
|
-
height: fh
|
|
9823
|
-
}
|
|
11236
|
+
this.warnBatchLost("resolved frame uniform (blank/hwaccel)", input, {
|
|
11237
|
+
width: fw,
|
|
11238
|
+
height: fh
|
|
9824
11239
|
});
|
|
9825
11240
|
return empty;
|
|
9826
11241
|
}
|
|
@@ -10633,6 +12048,7 @@ var ZoneAnalyticsProvider = class {
|
|
|
10633
12048
|
}
|
|
10634
12049
|
this.snapshots.set(input.deviceId, snapshot);
|
|
10635
12050
|
this.appendHistory(input.deviceId, snapshot);
|
|
12051
|
+
this.emitSnapshot(input.deviceId, snapshot);
|
|
10636
12052
|
this.sliceThrottle.push(input.deviceId, snapshot);
|
|
10637
12053
|
}
|
|
10638
12054
|
/** Drop a device's snapshot + history. Called on device removal. */
|
|
@@ -10697,10 +12113,24 @@ var ZoneAnalyticsProvider = class {
|
|
|
10697
12113
|
});
|
|
10698
12114
|
if (snapshot) {
|
|
10699
12115
|
this.appendHistory(deviceId, snapshot);
|
|
12116
|
+
this.emitSnapshot(deviceId, snapshot);
|
|
10700
12117
|
this.sliceThrottle.push(deviceId, snapshot);
|
|
10701
12118
|
}
|
|
10702
12119
|
}
|
|
10703
12120
|
}
|
|
12121
|
+
/** Push one snapshot to the occupancy tap, isolating any consumer throw from
|
|
12122
|
+
* the frame path (occupancy notifications must never break analytics). */
|
|
12123
|
+
emitSnapshot(deviceId, snapshot) {
|
|
12124
|
+
if (!this.ctx.onSnapshot) return;
|
|
12125
|
+
try {
|
|
12126
|
+
this.ctx.onSnapshot(deviceId, snapshot);
|
|
12127
|
+
} catch (err) {
|
|
12128
|
+
this.ctx.logger.debug("zone-analytics occupancy tap failed", {
|
|
12129
|
+
tags: { deviceId },
|
|
12130
|
+
meta: { error: err instanceof Error ? err.message : String(err) }
|
|
12131
|
+
});
|
|
12132
|
+
}
|
|
12133
|
+
}
|
|
10704
12134
|
appendHistory(deviceId, snapshot) {
|
|
10705
12135
|
const ring = this.history.get(deviceId) ?? [];
|
|
10706
12136
|
const cutoff = snapshot.ts - HISTORY_WINDOW_MS;
|
|
@@ -11610,21 +13040,47 @@ var FaceSettingsSchema = object({
|
|
|
11610
13040
|
* stripped from the per-device schema).
|
|
11611
13041
|
*/
|
|
11612
13042
|
enabled: boolean().default(true),
|
|
11613
|
-
/** Cosine similarity (on L2-normalized arcface vectors) required to match.
|
|
11614
|
-
|
|
11615
|
-
|
|
11616
|
-
|
|
13043
|
+
/** Cosine similarity (on L2-normalized arcface vectors) required to match.
|
|
13044
|
+
* Raised 0.55→0.62 (2026-07-23 face-quality batch): 0.55 was implausibly
|
|
13045
|
+
* loose against thin/low-quality galleries and produced ~25-40% visually
|
|
13046
|
+
* wrong auto-assignments in the 12h audit. */
|
|
13047
|
+
similarityThreshold: number().min(0).max(1).default(.62),
|
|
13048
|
+
/** Reject ambiguous matches: require best − secondBest ≥ margin. Raised
|
|
13049
|
+
* 0.10→0.15 (2026-07-23 face-quality batch) to widen the confusion gap. */
|
|
13050
|
+
margin: number().min(0).max(1).default(.15),
|
|
11617
13051
|
/** Minimum face-detection confidence for a face to be considered. */
|
|
11618
13052
|
minFaceConfidence: number().min(0).max(1).default(.5),
|
|
11619
13053
|
/**
|
|
11620
|
-
* Minimum face bbox size (px, shorter side
|
|
11621
|
-
* space) for a face to be
|
|
11622
|
-
*
|
|
11623
|
-
*
|
|
11624
|
-
*
|
|
13054
|
+
* Minimum face bbox size (px, shorter side, NATIVE scale when the runner
|
|
13055
|
+
* measured it, else detection-frame space) for a face to be DETECTED and
|
|
13056
|
+
* COLLECTED into the recent-faces buffer. Below this the face is dropped
|
|
13057
|
+
* BEFORE ingest/enrolment (#26.1). This is the DETECTION/collection floor —
|
|
13058
|
+
* NOT the auto-assignment floor (see {@link recognitionMinFacePx}).
|
|
11625
13059
|
*/
|
|
11626
13060
|
minFacePx: number().min(0).default(30),
|
|
11627
13061
|
/**
|
|
13062
|
+
* Minimum face short side (px, NATIVE scale when the runner measured it, else
|
|
13063
|
+
* detection-frame space) for a collected face to be eligible for AUTO-MATCH
|
|
13064
|
+
* (identity assignment). Separate from — and ≥ — {@link minFacePx}: faces
|
|
13065
|
+
* between `minFacePx` and this floor are still detected, cropped, and stored
|
|
13066
|
+
* in the buffer (available for MANUAL assignment), but are NEVER auto-assigned
|
|
13067
|
+
* an identity. ArcFace embeddings below ~48px are unreliable and drove the
|
|
13068
|
+
* observed false positives (2026-07-23 face-quality batch). A face below this
|
|
13069
|
+
* floor keeps `recognizedIdentityId` UNSET (fail-safe).
|
|
13070
|
+
*/
|
|
13071
|
+
recognitionMinFacePx: number().min(0).default(48),
|
|
13072
|
+
/**
|
|
13073
|
+
* Lower cosine bound of the SUGGESTION band (2026-07-24). A face whose best
|
|
13074
|
+
* gallery match MISSES auto-assignment but is still plausible surfaces as a
|
|
13075
|
+
* SUGGESTION (persisted `suggestedIdentityId`/`suggestedMatchScore`, never an
|
|
13076
|
+
* assignment) when EITHER: its match cosine is in [`suggestionMinCosine`,
|
|
13077
|
+
* `similarityThreshold`) AND its face clears the recognition size floor; OR its
|
|
13078
|
+
* cosine is ≥ `similarityThreshold` but the face is below the recognition floor
|
|
13079
|
+
* (blocked ONLY by size). Below this cosine nothing is suggested. Operator-
|
|
13080
|
+
* overridable per field, like the other face thresholds.
|
|
13081
|
+
*/
|
|
13082
|
+
suggestionMinCosine: number().min(0).max(1).default(.5),
|
|
13083
|
+
/**
|
|
11628
13084
|
* Minimum enrolled-sample count an identity must have before it can be an
|
|
11629
13085
|
* AUTO-MATCH target. A single-sample identity is an unreliable sink (one noisy
|
|
11630
13086
|
* enrolment attracted 81% of matches); identities below this are excluded from
|
|
@@ -11652,6 +13108,8 @@ function resolveFaceSettings(raw) {
|
|
|
11652
13108
|
margin: pick("margin"),
|
|
11653
13109
|
minFaceConfidence: pick("minFaceConfidence"),
|
|
11654
13110
|
minFacePx: pick("minFacePx"),
|
|
13111
|
+
recognitionMinFacePx: pick("recognitionMinFacePx"),
|
|
13112
|
+
suggestionMinCosine: pick("suggestionMinCosine"),
|
|
11655
13113
|
minIdentitySamples: pick("minIdentitySamples"),
|
|
11656
13114
|
confirmFrames: pick("confirmFrames"),
|
|
11657
13115
|
bufferRetentionDays: pick("bufferRetentionDays"),
|
|
@@ -13062,13 +14520,33 @@ function buildGlobalSettingsSchema() {
|
|
|
13062
14520
|
{
|
|
13063
14521
|
type: "number",
|
|
13064
14522
|
key: "minFacePx",
|
|
13065
|
-
label: "Min face size",
|
|
13066
|
-
description: "Minimum face box size (shorter side, pixels) for a face to be
|
|
14523
|
+
label: "Min face size (detect)",
|
|
14524
|
+
description: "Minimum face box size (shorter side, pixels) for a face to be DETECTED and stored in the recent-faces buffer. Faces smaller than this are ignored entirely. This is the collection floor — auto-assignment uses the separate, higher recognition floor below.",
|
|
13067
14525
|
min: 0,
|
|
13068
14526
|
step: 1,
|
|
13069
14527
|
default: FACE_DEFAULTS.minFacePx,
|
|
13070
14528
|
unit: "px"
|
|
13071
14529
|
},
|
|
14530
|
+
{
|
|
14531
|
+
type: "number",
|
|
14532
|
+
key: "recognitionMinFacePx",
|
|
14533
|
+
label: "Min face size (recognize)",
|
|
14534
|
+
description: "Minimum face size (shorter side, pixels) for a stored face to be AUTO-ASSIGNED an identity. Faces between the detection floor and this value are still stored and can be assigned MANUALLY, but are never auto-matched — ArcFace embeddings on small faces are unreliable and produce false identities. Raise to suppress wrong auto-assignments on distant faces.",
|
|
14535
|
+
min: 0,
|
|
14536
|
+
step: 1,
|
|
14537
|
+
default: FACE_DEFAULTS.recognitionMinFacePx,
|
|
14538
|
+
unit: "px"
|
|
14539
|
+
},
|
|
14540
|
+
{
|
|
14541
|
+
type: "number",
|
|
14542
|
+
key: "suggestionMinCosine",
|
|
14543
|
+
label: "Suggestion threshold",
|
|
14544
|
+
description: "Lower cosine bound (0–1) of the SUGGESTION band. A plausible match that misses auto-assignment — cosine between this value and the similarity threshold with a large-enough face, or above the similarity threshold but below the recognition size floor — is surfaced as a one-tap SUGGESTION instead of being assigned. The face stays unassigned. Below this cosine nothing is suggested.",
|
|
14545
|
+
min: 0,
|
|
14546
|
+
max: 1,
|
|
14547
|
+
step: .05,
|
|
14548
|
+
default: FACE_DEFAULTS.suggestionMinCosine
|
|
14549
|
+
},
|
|
13072
14550
|
{
|
|
13073
14551
|
type: "number",
|
|
13074
14552
|
key: "minIdentitySamples",
|
|
@@ -13193,6 +14671,7 @@ var PackageDropDetector = class {
|
|
|
13193
14671
|
importance: PACKAGE_IMPORTANCE
|
|
13194
14672
|
};
|
|
13195
14673
|
await this.deps.events.insertObject(ev);
|
|
14674
|
+
this.deps.onPersisted?.(ev, "delivered");
|
|
13196
14675
|
this.deps.emit.delivered({
|
|
13197
14676
|
deviceId: entry.deviceId,
|
|
13198
14677
|
entryId: entry.id,
|
|
@@ -13243,6 +14722,7 @@ var PackageDropDetector = class {
|
|
|
13243
14722
|
importance: PACKAGE_IMPORTANCE
|
|
13244
14723
|
};
|
|
13245
14724
|
await this.deps.events.insertObject(ev);
|
|
14725
|
+
this.deps.onPersisted?.(ev, "picked-up");
|
|
13246
14726
|
this.deps.emit.pickedUp({
|
|
13247
14727
|
deviceId: entry.deviceId,
|
|
13248
14728
|
entryId: entry.id,
|
|
@@ -13728,6 +15208,22 @@ var FACE_COLUMNS = [
|
|
|
13728
15208
|
{
|
|
13729
15209
|
name: "faceBbox",
|
|
13730
15210
|
type: "JSON"
|
|
15211
|
+
},
|
|
15212
|
+
{
|
|
15213
|
+
name: "bestMatchScore",
|
|
15214
|
+
type: "REAL"
|
|
15215
|
+
},
|
|
15216
|
+
{
|
|
15217
|
+
name: "nativeFaceShortSidePx",
|
|
15218
|
+
type: "REAL"
|
|
15219
|
+
},
|
|
15220
|
+
{
|
|
15221
|
+
name: "suggestedIdentityId",
|
|
15222
|
+
type: "TEXT"
|
|
15223
|
+
},
|
|
15224
|
+
{
|
|
15225
|
+
name: "suggestedMatchScore",
|
|
15226
|
+
type: "REAL"
|
|
13731
15227
|
}
|
|
13732
15228
|
];
|
|
13733
15229
|
var FACE_INDEXES = [{
|
|
@@ -13802,7 +15298,11 @@ var FaceStore = class {
|
|
|
13802
15298
|
recognizedIdentityId: data.recognizedIdentityId ?? void 0,
|
|
13803
15299
|
assignedSampleId: data.assignedSampleId ?? void 0,
|
|
13804
15300
|
keyFrameMediaKey: data.keyFrameMediaKey ?? void 0,
|
|
13805
|
-
faceBbox: data.faceBbox ?? void 0
|
|
15301
|
+
faceBbox: data.faceBbox ?? void 0,
|
|
15302
|
+
bestMatchScore: data.bestMatchScore ?? void 0,
|
|
15303
|
+
nativeFaceShortSidePx: data.nativeFaceShortSidePx ?? void 0,
|
|
15304
|
+
suggestedIdentityId: data.suggestedIdentityId ?? void 0,
|
|
15305
|
+
suggestedMatchScore: data.suggestedMatchScore ?? void 0
|
|
13806
15306
|
};
|
|
13807
15307
|
}).filter((f) => !f.assigned);
|
|
13808
15308
|
}
|
|
@@ -14002,7 +15502,11 @@ var FaceStore = class {
|
|
|
14002
15502
|
recognizedIdentityId: data.recognizedIdentityId ?? void 0,
|
|
14003
15503
|
assignedSampleId: data.assignedSampleId ?? void 0,
|
|
14004
15504
|
keyFrameMediaKey: data.keyFrameMediaKey ?? void 0,
|
|
14005
|
-
faceBbox: data.faceBbox ?? void 0
|
|
15505
|
+
faceBbox: data.faceBbox ?? void 0,
|
|
15506
|
+
bestMatchScore: data.bestMatchScore ?? void 0,
|
|
15507
|
+
nativeFaceShortSidePx: data.nativeFaceShortSidePx ?? void 0,
|
|
15508
|
+
suggestedIdentityId: data.suggestedIdentityId ?? void 0,
|
|
15509
|
+
suggestedMatchScore: data.suggestedMatchScore ?? void 0
|
|
14006
15510
|
};
|
|
14007
15511
|
}
|
|
14008
15512
|
/**
|
|
@@ -14048,7 +15552,11 @@ var FaceStore = class {
|
|
|
14048
15552
|
recognizedIdentityId: data.recognizedIdentityId ?? void 0,
|
|
14049
15553
|
assignedSampleId: data.assignedSampleId ?? void 0,
|
|
14050
15554
|
keyFrameMediaKey: data.keyFrameMediaKey ?? void 0,
|
|
14051
|
-
faceBbox: data.faceBbox ?? void 0
|
|
15555
|
+
faceBbox: data.faceBbox ?? void 0,
|
|
15556
|
+
bestMatchScore: data.bestMatchScore ?? void 0,
|
|
15557
|
+
nativeFaceShortSidePx: data.nativeFaceShortSidePx ?? void 0,
|
|
15558
|
+
suggestedIdentityId: data.suggestedIdentityId ?? void 0,
|
|
15559
|
+
suggestedMatchScore: data.suggestedMatchScore ?? void 0
|
|
14052
15560
|
};
|
|
14053
15561
|
});
|
|
14054
15562
|
const filterMode = input.filter ?? "all";
|
|
@@ -14280,6 +15788,40 @@ function eligibleIdentities(gallery, probeModelId, probeDim, minIdentitySamples)
|
|
|
14280
15788
|
return eligible;
|
|
14281
15789
|
}
|
|
14282
15790
|
/**
|
|
15791
|
+
* Match a probe embedding against the gallery. Only samples with the same
|
|
15792
|
+
* `modelId` AND the same dimension are compared (model-version safety). Returns
|
|
15793
|
+
* the best identity when its score ≥ threshold and it beats the best OTHER
|
|
15794
|
+
* identity by ≥ margin; otherwise null.
|
|
15795
|
+
*/
|
|
15796
|
+
function matchEmbedding(probe, gallery, opts) {
|
|
15797
|
+
const probeVec = new Float32Array(probe.embedding);
|
|
15798
|
+
const eligible = eligibleIdentities(gallery, probe.modelId, probe.embedding.length, opts.minIdentitySamples ?? 1);
|
|
15799
|
+
const bestByIdentity = /* @__PURE__ */ new Map();
|
|
15800
|
+
for (const s of gallery) {
|
|
15801
|
+
if (s.modelId !== probe.modelId) continue;
|
|
15802
|
+
if (s.embedding.length !== probe.embedding.length) continue;
|
|
15803
|
+
if (!eligible.has(s.identityId)) continue;
|
|
15804
|
+
const score = cosineSimilarity(probeVec, new Float32Array(s.embedding));
|
|
15805
|
+
const prev = bestByIdentity.get(s.identityId);
|
|
15806
|
+
if (prev === void 0 || score > prev) bestByIdentity.set(s.identityId, score);
|
|
15807
|
+
}
|
|
15808
|
+
if (bestByIdentity.size === 0) return null;
|
|
15809
|
+
let bestId = null;
|
|
15810
|
+
let bestScore = -Infinity;
|
|
15811
|
+
let secondScore = -Infinity;
|
|
15812
|
+
for (const [id, score] of bestByIdentity) if (score > bestScore) {
|
|
15813
|
+
secondScore = bestScore;
|
|
15814
|
+
bestScore = score;
|
|
15815
|
+
bestId = id;
|
|
15816
|
+
} else if (score > secondScore) secondScore = score;
|
|
15817
|
+
if (bestId === null || bestScore < opts.threshold) return null;
|
|
15818
|
+
if (secondScore > -Infinity && bestScore - secondScore < opts.margin) return null;
|
|
15819
|
+
return {
|
|
15820
|
+
identityId: bestId,
|
|
15821
|
+
score: bestScore
|
|
15822
|
+
};
|
|
15823
|
+
}
|
|
15824
|
+
/**
|
|
14283
15825
|
* Assign at most one identity per track AND at most one track per identity for
|
|
14284
15826
|
* a single frame. Greedy by score: compute every candidate's full ranked match
|
|
14285
15827
|
* list, then repeatedly take the globally-highest (track, identity) pair whose
|
|
@@ -14386,6 +15928,24 @@ var FaceRecognizer = class {
|
|
|
14386
15928
|
names = /* @__PURE__ */ new Map();
|
|
14387
15929
|
aggregates = /* @__PURE__ */ new Map();
|
|
14388
15930
|
bestFace = /* @__PURE__ */ new Map();
|
|
15931
|
+
/**
|
|
15932
|
+
* Peak identity-MATCH cosine per track for the currently-assigned identity
|
|
15933
|
+
* (NOT the detection confidence held in {@link bestFace}). Reset on an
|
|
15934
|
+
* identity switch so a track carries the confidence of the label it ends up
|
|
15935
|
+
* with. Read synchronously at close (NC `minLabelConfidence`) via
|
|
15936
|
+
* {@link bestLabelMatchConfidence}, then dropped in {@link onTrackEnd}.
|
|
15937
|
+
*/
|
|
15938
|
+
bestMatchScore = /* @__PURE__ */ new Map();
|
|
15939
|
+
/**
|
|
15940
|
+
* Peak SUGGESTION (identity + cosine) per track: the best plausible-but-not-
|
|
15941
|
+
* confident match that MISSED auto-assignment (cosine in the suggestion band
|
|
15942
|
+
* with a large-enough face, OR ≥ threshold but below the recognition size
|
|
15943
|
+
* floor). Accumulated across frames exactly like {@link bestMatchScore},
|
|
15944
|
+
* read at close, then dropped in {@link onTrackEnd}. A track that is ever
|
|
15945
|
+
* auto-assigned discards this at close (mutual exclusivity: assigned rows
|
|
15946
|
+
* carry `recognizedIdentityId`, suggestion rows carry `suggested*`).
|
|
15947
|
+
*/
|
|
15948
|
+
bestSuggestion = /* @__PURE__ */ new Map();
|
|
14389
15949
|
/** Best-face-per-track ranking via the shared tracker primitive, constructed
|
|
14390
15950
|
* WITH the DECLARED face policy (`BEST_FACE_POLICY` in
|
|
14391
15951
|
* `best-selection-policies.ts`): confidence-only, no hysteresis, no rate
|
|
@@ -14455,7 +16015,9 @@ var FaceRecognizer = class {
|
|
|
14455
16015
|
gallerySize: this.gallery.length
|
|
14456
16016
|
}
|
|
14457
16017
|
});
|
|
14458
|
-
const
|
|
16018
|
+
const meetsRecognitionFloor = (t) => (t.nativeFaceShortSidePx ?? (t.faceBbox ? Math.min(t.faceBbox.w, t.faceBbox.h) : void 0) ?? Number.POSITIVE_INFINITY) >= settings.recognitionMinFacePx;
|
|
16019
|
+
const matchCandidates = candidates.filter(meetsRecognitionFloor);
|
|
16020
|
+
const matches = this.gallery.length > 0 && matchCandidates.length > 0 ? assignUniquePerFrame(matchCandidates.map((c) => ({
|
|
14459
16021
|
trackId: c.trackId,
|
|
14460
16022
|
embedding: c.embedding,
|
|
14461
16023
|
modelId: c.embeddingModelId
|
|
@@ -14464,16 +16026,42 @@ var FaceRecognizer = class {
|
|
|
14464
16026
|
margin: settings.margin,
|
|
14465
16027
|
minIdentitySamples: settings.minIdentitySamples
|
|
14466
16028
|
}) : /* @__PURE__ */ new Map();
|
|
16029
|
+
if (this.gallery.length > 0) for (const c of candidates) {
|
|
16030
|
+
const suggestion = matchEmbedding({
|
|
16031
|
+
embedding: c.embedding,
|
|
16032
|
+
modelId: c.embeddingModelId
|
|
16033
|
+
}, this.gallery, {
|
|
16034
|
+
threshold: settings.suggestionMinCosine,
|
|
16035
|
+
margin: settings.margin,
|
|
16036
|
+
minIdentitySamples: settings.minIdentitySamples
|
|
16037
|
+
});
|
|
16038
|
+
if (suggestion === null) continue;
|
|
16039
|
+
const meetsRecognitionFloor = (c.nativeFaceShortSidePx ?? (c.faceBbox ? Math.min(c.faceBbox.w, c.faceBbox.h) : void 0) ?? Number.POSITIVE_INFINITY) >= settings.recognitionMinFacePx;
|
|
16040
|
+
const inNearMissBand = meetsRecognitionFloor && suggestion.score < settings.similarityThreshold;
|
|
16041
|
+
const inSizeBlockedBand = !meetsRecognitionFloor && suggestion.score >= settings.similarityThreshold;
|
|
16042
|
+
if (!inNearMissBand && !inSizeBlockedBand) continue;
|
|
16043
|
+
const prev = this.bestSuggestion.get(c.trackId);
|
|
16044
|
+
if (prev === void 0 || suggestion.score > prev.score) this.bestSuggestion.set(c.trackId, {
|
|
16045
|
+
identityId: suggestion.identityId,
|
|
16046
|
+
score: suggestion.score
|
|
16047
|
+
});
|
|
16048
|
+
}
|
|
14467
16049
|
const labelWork = [];
|
|
14468
16050
|
for (const c of candidates) {
|
|
14469
16051
|
const match = matches.get(c.trackId) ?? null;
|
|
14470
16052
|
const { state, changed } = updateTrackAggregate(this.aggregates.get(c.trackId) ?? EMPTY_AGGREGATE, match, { confirmFrames: settings.confirmFrames });
|
|
14471
16053
|
this.aggregates.set(c.trackId, state);
|
|
14472
|
-
if (changed && state.assignedIdentityId !== null)
|
|
14473
|
-
|
|
14474
|
-
|
|
14475
|
-
|
|
14476
|
-
|
|
16054
|
+
if (changed && state.assignedIdentityId !== null) {
|
|
16055
|
+
labelWork.push({
|
|
16056
|
+
trackId: c.trackId,
|
|
16057
|
+
assignedIdentityId: state.assignedIdentityId,
|
|
16058
|
+
matchScore: match?.score ?? null
|
|
16059
|
+
});
|
|
16060
|
+
if (match?.score !== void 0) this.bestMatchScore.set(c.trackId, match.score);
|
|
16061
|
+
} else if (match !== null && state.assignedIdentityId !== null && match.identityId === state.assignedIdentityId) {
|
|
16062
|
+
const prev = this.bestMatchScore.get(c.trackId);
|
|
16063
|
+
if (prev === void 0 || match.score > prev) this.bestMatchScore.set(c.trackId, match.score);
|
|
16064
|
+
}
|
|
14477
16065
|
const recognizedIdentityId = state.assignedIdentityId ?? match?.identityId ?? void 0;
|
|
14478
16066
|
const held = this.bestFace.get(c.trackId);
|
|
14479
16067
|
const isNewBest = this.bestTracker.observe(c.trackId, c.confidence, input.timestamp);
|
|
@@ -14502,7 +16090,8 @@ var FaceRecognizer = class {
|
|
|
14502
16090
|
bbox: cropBbox,
|
|
14503
16091
|
timestamp: input.timestamp,
|
|
14504
16092
|
...bestCrop !== void 0 ? { crop: bestCrop } : {},
|
|
14505
|
-
...recognizedIdentityId !== void 0 ? { recognizedIdentityId } : {}
|
|
16093
|
+
...recognizedIdentityId !== void 0 ? { recognizedIdentityId } : {},
|
|
16094
|
+
...c.nativeFaceShortSidePx !== void 0 ? { nativeFaceShortSidePx: c.nativeFaceShortSidePx } : {}
|
|
14506
16095
|
});
|
|
14507
16096
|
} else if (crop !== void 0 && held !== void 0) this.bestFace.set(c.trackId, {
|
|
14508
16097
|
...held,
|
|
@@ -14579,11 +16168,23 @@ var FaceRecognizer = class {
|
|
|
14579
16168
|
* entry (crop -> MediaStore under ownerKind 'face'), then drop the track's
|
|
14580
16169
|
* in-memory state. Best-effort; a track with no held face is a no-op.
|
|
14581
16170
|
*/
|
|
16171
|
+
/**
|
|
16172
|
+
* Peak identity-match cosine for the track's assigned identity, or undefined
|
|
16173
|
+
* when no identity was ever confirmed. Read at close BEFORE {@link onTrackEnd}
|
|
16174
|
+
* drops the per-track state (the NC `minLabelConfidence` seam).
|
|
16175
|
+
*/
|
|
16176
|
+
bestLabelMatchConfidence(trackId) {
|
|
16177
|
+
return this.bestMatchScore.get(trackId);
|
|
16178
|
+
}
|
|
14582
16179
|
async onTrackEnd(deviceId, trackId) {
|
|
14583
16180
|
const held = this.bestFace.get(trackId);
|
|
16181
|
+
const bestMatchScore = this.bestMatchScore.get(trackId);
|
|
16182
|
+
const bestSuggestion = this.bestSuggestion.get(trackId);
|
|
14584
16183
|
this.aggregates.delete(trackId);
|
|
14585
16184
|
this.bestFace.delete(trackId);
|
|
14586
16185
|
this.bestTracker.delete(trackId);
|
|
16186
|
+
this.bestMatchScore.delete(trackId);
|
|
16187
|
+
this.bestSuggestion.delete(trackId);
|
|
14587
16188
|
if (held === void 0) {
|
|
14588
16189
|
this.deps.logger.debug("face: track ended without a held face", { tags: {
|
|
14589
16190
|
deviceId,
|
|
@@ -14647,7 +16248,13 @@ var FaceRecognizer = class {
|
|
|
14647
16248
|
...held.recognizedIdentityId !== void 0 ? { recognizedIdentityId: held.recognizedIdentityId } : {},
|
|
14648
16249
|
assigned: false,
|
|
14649
16250
|
faceBbox: held.bbox,
|
|
14650
|
-
...keyFrameMediaKey !== void 0 ? { keyFrameMediaKey } : {}
|
|
16251
|
+
...keyFrameMediaKey !== void 0 ? { keyFrameMediaKey } : {},
|
|
16252
|
+
...bestMatchScore !== void 0 ? { bestMatchScore } : {},
|
|
16253
|
+
...held.nativeFaceShortSidePx !== void 0 ? { nativeFaceShortSidePx: held.nativeFaceShortSidePx } : {},
|
|
16254
|
+
...held.recognizedIdentityId === void 0 && bestSuggestion !== void 0 ? {
|
|
16255
|
+
suggestedIdentityId: bestSuggestion.identityId,
|
|
16256
|
+
suggestedMatchScore: bestSuggestion.score
|
|
16257
|
+
} : {}
|
|
14651
16258
|
});
|
|
14652
16259
|
this.deps.logger.info("face: buffered to gallery", {
|
|
14653
16260
|
tags: {
|
|
@@ -15970,11 +17577,13 @@ var PlateRecognizer = class {
|
|
|
15970
17577
|
} : null;
|
|
15971
17578
|
}
|
|
15972
17579
|
/** Live label for a plate read: the recognized vehicle NAME when matched,
|
|
15973
|
-
* else the
|
|
15974
|
-
*
|
|
17580
|
+
* else the NORMALIZED (uppercase, alnum-only) OCR text — never the raw read,
|
|
17581
|
+
* so lowercase/separator residue can't reach track labels. Returns `null`
|
|
17582
|
+
* for an implausible read (junk OCR off a distant/oblique plate) — the
|
|
17583
|
+
* caller must NOT stamp a label then. */
|
|
15975
17584
|
resolveLabel(text, score) {
|
|
15976
17585
|
if (!isPlausiblePlateRead(text, score)) return null;
|
|
15977
|
-
return this.matchVehicle(text, score)?.name ?? text;
|
|
17586
|
+
return this.matchVehicle(text, score)?.name ?? normalizePlate$1(text);
|
|
15978
17587
|
}
|
|
15979
17588
|
async processFrame(input) {
|
|
15980
17589
|
const minConfidence = input.minConfidence ?? 0;
|
|
@@ -16035,6 +17644,15 @@ var PlateRecognizer = class {
|
|
|
16035
17644
|
...crop !== void 0 ? { crop } : {}
|
|
16036
17645
|
});
|
|
16037
17646
|
}
|
|
17647
|
+
/**
|
|
17648
|
+
* Best OCR read score for the track's held plate (`plateText.confidence`),
|
|
17649
|
+
* or undefined when no plausible plate was read. Read at close BEFORE
|
|
17650
|
+
* {@link onTrackEnd} drops the per-track state (the NC `minLabelConfidence`
|
|
17651
|
+
* seam — mirrors the face recognizer's identity-match confidence).
|
|
17652
|
+
*/
|
|
17653
|
+
bestLabelMatchConfidence(trackId) {
|
|
17654
|
+
return this.bestPlate.get(trackId)?.score;
|
|
17655
|
+
}
|
|
16038
17656
|
/** Persist the held best plate for a finished track as one PlateStore row
|
|
16039
17657
|
* (crop → MediaStore under ownerKind 'plate'), then drop in-memory state. */
|
|
16040
17658
|
async onTrackEnd(deviceId, trackId) {
|
|
@@ -16140,6 +17758,47 @@ var PlateRecognizer = class {
|
|
|
16140
17758
|
}
|
|
16141
17759
|
};
|
|
16142
17760
|
//#endregion
|
|
17761
|
+
//#region src/shared/frame/keyframe-wide-thumbnail.ts
|
|
17762
|
+
/**
|
|
17763
|
+
* 16:9 central-square subject crop out of a DECODED (raw RGB) full frame — the
|
|
17764
|
+
* close-time keyFrame→`thumbnail` derive (#27-A degenerate-retry fix, part 3).
|
|
17765
|
+
*
|
|
17766
|
+
* The live best-shot path fetches the 16:9 central-square WINDOW from the
|
|
17767
|
+
* runner's retained NATIVE surface ({@link wideCentralSquareLayout} +
|
|
17768
|
+
* {@link composeWideCentralSquareThumbnail} — see
|
|
17769
|
+
* `services/event-media-dispatcher.cropSubjectVariants`). At track close that
|
|
17770
|
+
* surface is long gone; the only durable full frame is the persisted native
|
|
17771
|
+
* `keyFrame` JPEG. This helper applies the SAME framing to that decoded frame:
|
|
17772
|
+
* normalized subject bbox → pixel bbox in the frame's OWN space → central-square
|
|
17773
|
+
* 16:9 layout → extract the in-frame slab → compose (lateral ambience fill for
|
|
17774
|
+
* any out-of-frame part). Same containment guarantee, same variants downstream
|
|
17775
|
+
* (`deriveThumbnailSmall` runs on the returned window).
|
|
17776
|
+
*
|
|
17777
|
+
* Shaped as a `cropRegionToJpeg` drop-in for {@link createKeyFrameCrop} (same
|
|
17778
|
+
* signature as the plain `extractCrop` composition used for plate crops), so
|
|
17779
|
+
* the close-time derive reuses the EXISTING keyframe-crop utility — media
|
|
17780
|
+
* fetch, temporal-skew guard and decode included — with only the framing
|
|
17781
|
+
* swapped.
|
|
17782
|
+
*/
|
|
17783
|
+
/**
|
|
17784
|
+
* Compose the 16:9 central-square `thumbnail` window for a normalized subject
|
|
17785
|
+
* region of a raw RGB frame. Returns the composed JPEG (never upscaled — the
|
|
17786
|
+
* window is at the frame's native scale).
|
|
17787
|
+
*/
|
|
17788
|
+
async function cropWideCentralSquareFromRgb(rgb, frameWidth, frameHeight, norm) {
|
|
17789
|
+
const layout = wideCentralSquareLayout({
|
|
17790
|
+
x: norm.x * frameWidth,
|
|
17791
|
+
y: norm.y * frameHeight,
|
|
17792
|
+
w: norm.w * frameWidth,
|
|
17793
|
+
h: norm.h * frameHeight
|
|
17794
|
+
}, {
|
|
17795
|
+
W: frameWidth,
|
|
17796
|
+
H: frameHeight
|
|
17797
|
+
});
|
|
17798
|
+
const { crop: slab } = await extractCrop(rgb, frameWidth, frameHeight, layout.fetch);
|
|
17799
|
+
return composeWideCentralSquareThumbnail(slab, layout);
|
|
17800
|
+
}
|
|
17801
|
+
//#endregion
|
|
16143
17802
|
//#region src/pipeline-analytics/pipeline/event-child-crops.ts
|
|
16144
17803
|
/**
|
|
16145
17804
|
* Compute Intersection-over-Union between two (x,y,w,h) bounding boxes.
|
|
@@ -16705,6 +18364,8 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
16705
18364
|
* path). Null on non-post-processing nodes or when disabled. */
|
|
16706
18365
|
embeddingDispatcher = null;
|
|
16707
18366
|
bindingCache = null;
|
|
18367
|
+
/** Per-device throttle for the inactive-binding frame-drop warn (5 min). */
|
|
18368
|
+
inactiveBindingDropWarnAt = /* @__PURE__ */ new Map();
|
|
16708
18369
|
zoneAnalytics = null;
|
|
16709
18370
|
audioMetrics = null;
|
|
16710
18371
|
/**
|
|
@@ -16837,40 +18498,67 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
16837
18498
|
return this._captureScheduler;
|
|
16838
18499
|
}
|
|
16839
18500
|
get trackCloser() {
|
|
16840
|
-
if (!this._trackCloser)
|
|
16841
|
-
|
|
16842
|
-
|
|
16843
|
-
|
|
16844
|
-
|
|
16845
|
-
|
|
16846
|
-
|
|
16847
|
-
|
|
16848
|
-
|
|
16849
|
-
|
|
16850
|
-
|
|
16851
|
-
|
|
16852
|
-
|
|
16853
|
-
|
|
16854
|
-
|
|
16855
|
-
|
|
16856
|
-
|
|
16857
|
-
|
|
16858
|
-
|
|
16859
|
-
|
|
16860
|
-
|
|
16861
|
-
|
|
16862
|
-
|
|
16863
|
-
|
|
16864
|
-
|
|
16865
|
-
|
|
16866
|
-
|
|
16867
|
-
|
|
16868
|
-
|
|
16869
|
-
|
|
16870
|
-
|
|
16871
|
-
|
|
16872
|
-
|
|
16873
|
-
|
|
18501
|
+
if (!this._trackCloser) {
|
|
18502
|
+
const deriveKeyFrameThumbnailJpeg = createKeyFrameCrop({
|
|
18503
|
+
getMedia: async (mediaKey) => {
|
|
18504
|
+
const m = await this.mediaStore?.getByKey(mediaKey);
|
|
18505
|
+
return m ? {
|
|
18506
|
+
base64: m.base64,
|
|
18507
|
+
timestamp: m.timestamp
|
|
18508
|
+
} : null;
|
|
18509
|
+
},
|
|
18510
|
+
decodeJpegToRgb: (base64) => decodeJpegToRgb(base64),
|
|
18511
|
+
cropRegionToJpeg: (bytes, w, h, norm) => cropWideCentralSquareFromRgb(bytes, w, h, norm),
|
|
18512
|
+
maxSkewMs: KEYFRAME_CROP_MAX_SKEW_MS,
|
|
18513
|
+
logger: this.ctx.logger.child("CloseKeyFrameThumbnail")
|
|
18514
|
+
});
|
|
18515
|
+
this._trackCloser = new TrackCloser({
|
|
18516
|
+
logger: this.ctx.logger,
|
|
18517
|
+
residents: this.residents,
|
|
18518
|
+
trackStore: () => this.trackStore,
|
|
18519
|
+
mediaStore: () => this.mediaStore,
|
|
18520
|
+
eventStore: () => this.eventStore,
|
|
18521
|
+
faceRecognizer: () => this.faceRecognizer,
|
|
18522
|
+
plateRecognizer: () => this.plateRecognizer,
|
|
18523
|
+
detailDispatcher: () => this.detailDispatcher,
|
|
18524
|
+
overlayState: this.overlayState,
|
|
18525
|
+
mediaCaptureLog: this.mediaCaptureLog,
|
|
18526
|
+
frameDims: (deviceId) => this.lastFrameDimsByDevice.get(deviceId),
|
|
18527
|
+
isShuttingDown: () => this.shuttingDown,
|
|
18528
|
+
emitTrackEnded: (data, timestampMs) => {
|
|
18529
|
+
this.ctx.eventBus.emit({
|
|
18530
|
+
id: `pa-end-${data.trackId}`,
|
|
18531
|
+
timestamp: new Date(timestampMs),
|
|
18532
|
+
source: {
|
|
18533
|
+
type: "addon",
|
|
18534
|
+
id: "pipeline-analytics",
|
|
18535
|
+
addonId: "pipeline-analytics"
|
|
18536
|
+
},
|
|
18537
|
+
category: EventCategory.PipelineAnalyticsTrackEnded,
|
|
18538
|
+
data: {
|
|
18539
|
+
deviceId: data.deviceId,
|
|
18540
|
+
trackId: data.trackId,
|
|
18541
|
+
className: data.className,
|
|
18542
|
+
durationMs: data.durationMs
|
|
18543
|
+
}
|
|
18544
|
+
});
|
|
18545
|
+
},
|
|
18546
|
+
emitTrackLifecycle: (payload, timestampMs) => this.emitTrackLifecycle(payload, timestampMs),
|
|
18547
|
+
onTrackClosed: (track, ownedMedia, info) => this.notificationCenter?.onTrackClosed(track, ownedMedia, info),
|
|
18548
|
+
deriveThumbnailFromKeyFrame: async (input) => {
|
|
18549
|
+
const derived = await deriveKeyFrameThumbnailJpeg({
|
|
18550
|
+
...input,
|
|
18551
|
+
padding: 0
|
|
18552
|
+
});
|
|
18553
|
+
if (!derived) return null;
|
|
18554
|
+
return {
|
|
18555
|
+
thumbnail: derived.jpeg,
|
|
18556
|
+
thumbnailSmall: await deriveThumbnailSmall(derived.jpeg),
|
|
18557
|
+
skewMs: derived.skewMs
|
|
18558
|
+
};
|
|
18559
|
+
}
|
|
18560
|
+
});
|
|
18561
|
+
}
|
|
16874
18562
|
return this._trackCloser;
|
|
16875
18563
|
}
|
|
16876
18564
|
/** Master toggle for the migrated object/face EmbeddingDispatcher (default
|
|
@@ -16948,7 +18636,33 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
16948
18636
|
if (this.isPostProcessingNode) await this.startEmbeddingDispatcher(logger, transport);
|
|
16949
18637
|
this.startSweepTimers();
|
|
16950
18638
|
this.ctx.logger.info("pipeline-analytics subscribers installed");
|
|
16951
|
-
|
|
18639
|
+
const providers = this.buildProviderRegistrations(api, stores, capProviders);
|
|
18640
|
+
const isHub = ownNodeId === "hub";
|
|
18641
|
+
const ncHandlers = isHub ? this.buildNcActionHandlers(api) : void 0;
|
|
18642
|
+
return {
|
|
18643
|
+
providers,
|
|
18644
|
+
...isHub && ncHandlers !== void 0 ? {
|
|
18645
|
+
customActions: ncActions,
|
|
18646
|
+
actionHandlers: ncHandlers
|
|
18647
|
+
} : {}
|
|
18648
|
+
};
|
|
18649
|
+
}
|
|
18650
|
+
/**
|
|
18651
|
+
* Build the `nc.*` bridge handlers over the rule store, resolving the
|
|
18652
|
+
* caller-owned target set from the notifiers target catalog (a target is
|
|
18653
|
+
* owned when its open `config.ownerUserId` blob matches the caller). Returns
|
|
18654
|
+
* `undefined` when the Notification Center is absent (never built).
|
|
18655
|
+
*/
|
|
18656
|
+
buildNcActionHandlers(api) {
|
|
18657
|
+
const center = this.notificationCenter;
|
|
18658
|
+
if (center === null) return void 0;
|
|
18659
|
+
return makeNcActionHandlers({
|
|
18660
|
+
ruleStore: center.ruleStore,
|
|
18661
|
+
logger: this.ctx.logger.child("nc-actions"),
|
|
18662
|
+
listCallerTargetIds: async (userId) => {
|
|
18663
|
+
return (await api.notificationOutput.listTargets.query({})).filter((t) => t.config["ownerUserId"] === userId).map((t) => t.id);
|
|
18664
|
+
}
|
|
18665
|
+
});
|
|
16952
18666
|
}
|
|
16953
18667
|
/** Declare typed collections up-front so the first insert doesn't race with
|
|
16954
18668
|
* a CREATE TABLE. Idempotent. */
|
|
@@ -17073,6 +18787,7 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
17073
18787
|
resolveZones: (deviceId) => this.resolveDeviceZones(deviceId),
|
|
17074
18788
|
resolvePackageRules: (deviceId) => this.resolveDevicePackageRules(deviceId),
|
|
17075
18789
|
resolveSettings: (deviceId) => this.resolveDevicePackageDropSettings(deviceId),
|
|
18790
|
+
onPersisted: (ev, phase) => this.notificationCenter?.onPackageEventPersisted(ev, phase),
|
|
17076
18791
|
onError: (scope, err) => logger.warn("package-drop detector error", { meta: {
|
|
17077
18792
|
scope,
|
|
17078
18793
|
error: errMsg(err)
|
|
@@ -17231,7 +18946,8 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
17231
18946
|
fetchDevice: (deviceId) => this.ctx.fetchDevice(deviceId),
|
|
17232
18947
|
listStationaryObjects: (deviceId) => this.stationaryRegistry?.listViews(deviceId) ?? [],
|
|
17233
18948
|
listStationaryDeviceIds: () => this.stationaryRegistry?.deviceIds() ?? [],
|
|
17234
|
-
resolveZones: (deviceId) => this.resolveDeviceZones(deviceId)
|
|
18949
|
+
resolveZones: (deviceId) => this.resolveDeviceZones(deviceId),
|
|
18950
|
+
onSnapshot: (deviceId, snapshot) => this.notificationCenter?.observeOccupancy(deviceId, snapshot)
|
|
17235
18951
|
});
|
|
17236
18952
|
this.zoneAnalytics = zoneAnalytics;
|
|
17237
18953
|
const audioMetrics = new AudioMetricsProvider({
|
|
@@ -17578,7 +19294,20 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
17578
19294
|
*/
|
|
17579
19295
|
async processFrame(deviceId, frame, source, frameHandle, detailSteps) {
|
|
17580
19296
|
if (this.shuttingDown) return;
|
|
17581
|
-
if (!await this.bindingCache.isActive(deviceId))
|
|
19297
|
+
if (!await this.bindingCache.isActive(deviceId)) {
|
|
19298
|
+
const now = Date.now();
|
|
19299
|
+
if (now - (this.inactiveBindingDropWarnAt.get(deviceId) ?? 0) >= 3e5) {
|
|
19300
|
+
this.inactiveBindingDropWarnAt.set(deviceId, now);
|
|
19301
|
+
this.ctx.logger.warn("inference frames dropped — pipeline-analytics binding inactive", {
|
|
19302
|
+
tags: { deviceId },
|
|
19303
|
+
meta: {
|
|
19304
|
+
source,
|
|
19305
|
+
detections: frame.detections.length
|
|
19306
|
+
}
|
|
19307
|
+
});
|
|
19308
|
+
}
|
|
19309
|
+
return;
|
|
19310
|
+
}
|
|
17582
19311
|
const key = this.procKey(deviceId, source);
|
|
17583
19312
|
const trk = await this.resolveDeviceTrackingSettings(deviceId);
|
|
17584
19313
|
const activeCount = this.lastActiveTrackIds.get(key)?.size ?? 0;
|
|
@@ -17684,7 +19413,10 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
17684
19413
|
source,
|
|
17685
19414
|
resurrected: true
|
|
17686
19415
|
} });
|
|
17687
|
-
if (this.eventMediaDispatcher)
|
|
19416
|
+
if (this.eventMediaDispatcher) {
|
|
19417
|
+
this.residents.setLastFrameAt(deviceId, id, result.timestamp);
|
|
19418
|
+
if (!this.residents.isFirstFrameLanded(id) && !this.residents.isFirstFramePending(id)) this.residents.markFirstFramePending(deviceId, id);
|
|
19419
|
+
}
|
|
17688
19420
|
continue;
|
|
17689
19421
|
}
|
|
17690
19422
|
bornCandidates.push({
|
|
@@ -17871,8 +19603,6 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
17871
19603
|
else plateCrops += 1;
|
|
17872
19604
|
const snapshotTargets = this.buildSnapshotTargets(deviceId, result.tracked, result.timestamp, mediaSettings, result.frameWidth, result.frameHeight);
|
|
17873
19605
|
for (const retry of this.collectFirstFrameRetries(deviceId, result.tracked, result.timestamp, firstFrameTargets)) firstFrameTargets.push(retry);
|
|
17874
|
-
const keyFrameTrackIds = selectKeyFrameTrackIds(snapshotTargets);
|
|
17875
|
-
if (keyFrameTrackIds.length > 0) this.persistKeyFrames(deviceId, result.timestamp, keyFrameTrackIds, frameHandle);
|
|
17876
19606
|
const targetedThisFrame = new Set([...firstFrameTargets.map((t) => t.trackId), ...snapshotTargets.map((s) => s.trackId)]);
|
|
17877
19607
|
const rasterFallbackCandidates = [];
|
|
17878
19608
|
const widenedRasterWantedIds = /* @__PURE__ */ new Set();
|
|
@@ -17918,6 +19648,7 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
17918
19648
|
for (const t of firstFrameTargets) if (!this.residents.closure(t.trackId)?.rasterFallback) rasterFallbackWantedTrackIds.add(t.trackId);
|
|
17919
19649
|
for (const s of snapshotTargets) if (!this.residents.closure(s.trackId)?.rasterFallback) rasterFallbackWantedTrackIds.add(s.trackId);
|
|
17920
19650
|
const dispatcher = this.eventMediaDispatcher;
|
|
19651
|
+
const pinnedFramePromise = dispatcher.resolvePinnedFrame(deviceId, frameHandle);
|
|
17921
19652
|
this.captureScheduler.request({
|
|
17922
19653
|
deviceId,
|
|
17923
19654
|
kind: deriveFrameDispatchKind({
|
|
@@ -17927,7 +19658,7 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
17927
19658
|
hasSnapshot: snapshotTargets.some((t) => t.appendSnapshot)
|
|
17928
19659
|
}),
|
|
17929
19660
|
holdKeys: heldCaptureKeys,
|
|
17930
|
-
exec: () => dispatcher.captureForFrame({
|
|
19661
|
+
exec: () => pinnedFramePromise.then((pinnedFrame) => dispatcher.captureForFrame({
|
|
17931
19662
|
deviceId,
|
|
17932
19663
|
frameHandle,
|
|
17933
19664
|
events: eventTargets,
|
|
@@ -17935,8 +19666,9 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
17935
19666
|
snapshots: snapshotTargets,
|
|
17936
19667
|
cropPadding: mediaSettings.cropPadding,
|
|
17937
19668
|
rasterFallbackWantedTrackIds,
|
|
17938
|
-
rasterFallbackCandidates
|
|
17939
|
-
|
|
19669
|
+
rasterFallbackCandidates,
|
|
19670
|
+
...pinnedFrame !== null ? { pinnedFrame } : {}
|
|
19671
|
+
})).then((res) => {
|
|
17940
19672
|
for (const rf of res.rasterFallbacks) this.residents.retainRasterFallback(deviceId, rf.trackId, {
|
|
17941
19673
|
jpeg: rf.jpeg,
|
|
17942
19674
|
timestamp: rf.timestamp
|
|
@@ -17952,11 +19684,16 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
17952
19684
|
mediaKey: s.mediaKey
|
|
17953
19685
|
});
|
|
17954
19686
|
for (const trackId of res.thumbnailTrackIds) this.residents.markThumbnailLanded(deviceId, trackId);
|
|
17955
|
-
for (const trackId of res.firstFrameTrackIds)
|
|
19687
|
+
for (const trackId of res.firstFrameTrackIds) {
|
|
19688
|
+
this.residents.clearFirstFramePending(trackId);
|
|
19689
|
+
this.residents.markFirstFrameLanded(deviceId, trackId);
|
|
19690
|
+
}
|
|
17956
19691
|
for (const trackId of res.lastFrameTrackIds) this.residents.setLastFrameAt(deviceId, trackId, dispatchTimestamp);
|
|
17957
19692
|
})
|
|
17958
19693
|
}).catch(() => {});
|
|
17959
19694
|
}
|
|
19695
|
+
const keyFrameTrackIds = selectKeyFrameTrackIds(snapshotTargets);
|
|
19696
|
+
if (keyFrameTrackIds.length > 0) this.persistKeyFrames(deviceId, result.timestamp, keyFrameTrackIds, frameHandle);
|
|
17960
19697
|
}
|
|
17961
19698
|
if (this.faceRecognizer && faceGloballyEnabled && faceSettings) this.faceRecognizer.processFrame({
|
|
17962
19699
|
deviceId,
|
|
@@ -18302,7 +20039,18 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
18302
20039
|
if (!await this.resolveGlobalFaceEnabled()) return;
|
|
18303
20040
|
const [settings, media] = await Promise.all([this.resolveDeviceFaceSettings(deviceId), this.resolveDeviceMediaSettings(deviceId)]);
|
|
18304
20041
|
const faceShortSidePx = detail.nativeFaceShortSidePx ?? (detail.bbox !== void 0 ? Math.min(detail.bbox.w, detail.bbox.h) : void 0);
|
|
18305
|
-
if (faceShortSidePx !== void 0 && faceShortSidePx < settings.minFacePx)
|
|
20042
|
+
if (faceShortSidePx !== void 0 && faceShortSidePx < settings.minFacePx) {
|
|
20043
|
+
this.ctx.logger.debug("face detail below minFacePx — dropped", {
|
|
20044
|
+
tags: { deviceId },
|
|
20045
|
+
meta: {
|
|
20046
|
+
trackId,
|
|
20047
|
+
faceShortSidePx: Math.round(faceShortSidePx),
|
|
20048
|
+
minFacePx: settings.minFacePx,
|
|
20049
|
+
gatedOn: detail.nativeFaceShortSidePx !== void 0 ? "native" : "bbox"
|
|
20050
|
+
}
|
|
20051
|
+
});
|
|
20052
|
+
return;
|
|
20053
|
+
}
|
|
18306
20054
|
await this.faceRecognizer.ingestFaceDetail({
|
|
18307
20055
|
deviceId,
|
|
18308
20056
|
trackId,
|
|
@@ -18313,6 +20061,7 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
18313
20061
|
embedding: decodeEmbeddingBase64(detail.embedding),
|
|
18314
20062
|
parentBbox: { ...frame.bbox },
|
|
18315
20063
|
...detail.bbox !== void 0 ? { faceBbox: { ...detail.bbox } } : {},
|
|
20064
|
+
...detail.nativeFaceShortSidePx !== void 0 ? { nativeFaceShortSidePx: detail.nativeFaceShortSidePx } : {},
|
|
18316
20065
|
...detail.alignedCropJpeg !== void 0 ? { alignedCropJpeg: detail.alignedCropJpeg } : {},
|
|
18317
20066
|
settings,
|
|
18318
20067
|
cropPadding: media.cropPadding,
|
|
@@ -18675,8 +20424,18 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
18675
20424
|
});
|
|
18676
20425
|
const rollingLastFrame = plan.rollingLastFrame && !this.captureScheduler.isKeyActive(captureCoalesceKey(deviceId, "lastFrame", t.trackId));
|
|
18677
20426
|
if (plan.appendSnapshot) this.trackStore.markSnapshotPending(t.trackId, timestamp, t.bbox);
|
|
18678
|
-
const plausibleBox = isPlausibleThumbnailBox(t.bbox, frameWidth, frameHeight);
|
|
18679
|
-
|
|
20427
|
+
const plausibleBox = isPlausibleThumbnailBox(t.bbox, frameWidth, frameHeight, t.confidence);
|
|
20428
|
+
if (isNewBest && plausibleBox) this.residents.recordBestSeenBbox(deviceId, t.trackId, {
|
|
20429
|
+
x: t.bbox.x,
|
|
20430
|
+
y: t.bbox.y,
|
|
20431
|
+
w: t.bbox.w,
|
|
20432
|
+
h: t.bbox.h,
|
|
20433
|
+
frameWidth,
|
|
20434
|
+
frameHeight,
|
|
20435
|
+
timestamp
|
|
20436
|
+
});
|
|
20437
|
+
const retryBoxPlausible = isNewBest || isPlausibleRetryBox(t.bbox, this.residents.bestSeenBbox(t.trackId) ?? null);
|
|
20438
|
+
const bestThumbnail = plan.bestThumbnail && plausibleBox && retryBoxPlausible && !this.captureScheduler.isKeyActive(captureCoalesceKey(deviceId, "thumbnail", t.trackId));
|
|
18680
20439
|
const keyFrame = isNewBest && plausibleBox;
|
|
18681
20440
|
if (!plan.appendSnapshot && !rollingLastFrame && !bestThumbnail && !keyFrame) continue;
|
|
18682
20441
|
targets.push({
|
|
@@ -18743,6 +20502,7 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
18743
20502
|
atMs: timestamp
|
|
18744
20503
|
});
|
|
18745
20504
|
await this.eventStore.insertAudio(ev);
|
|
20505
|
+
this.notificationCenter?.onAudioEventPersisted(ev);
|
|
18746
20506
|
this.trackStore?.addAudioLabelEpisode(deviceId, route.className, topClassification.score, timestamp);
|
|
18747
20507
|
this.ctx.eventBus.emit({
|
|
18748
20508
|
id: `pa-${ev.id}`,
|
|
@@ -19376,7 +21136,8 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
19376
21136
|
try {
|
|
19377
21137
|
await ingestSensorStateChange({
|
|
19378
21138
|
sink: store,
|
|
19379
|
-
cache
|
|
21139
|
+
cache,
|
|
21140
|
+
onPersisted: (ev) => this.notificationCenter?.onSensorEventPersisted(ev)
|
|
19380
21141
|
}, data, timestamp);
|
|
19381
21142
|
} catch (err) {
|
|
19382
21143
|
this.ctx.logger.warn("sensor-event ingest failed", {
|
|
@@ -19895,4 +21656,4 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
19895
21656
|
}
|
|
19896
21657
|
};
|
|
19897
21658
|
//#endregion
|
|
19898
|
-
export { DETECTION_PIPELINE_SECTION_IDS, PipelineAnalyticsAddon as default, pickCleanMedia, retagDetectionSections, stripGlobalOnlyFields, toAnalyticsDeviceSections };
|
|
21659
|
+
export { DETECTION_PIPELINE_SECTION_IDS, ncActions as customActions, ncActions, PipelineAnalyticsAddon as default, pickCleanMedia, retagDetectionSections, stripGlobalOnlyFields, toAnalyticsDeviceSections };
|