@camstack/addon-post-analysis 1.2.72 → 1.2.73
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-C2w3cl2p.js → dist-DDv-TKUR.js} +105 -17
- package/dist/{dist-6te1200B.mjs → dist-_D0jdUh4.mjs} +100 -18
- package/dist/embedding-encoder/index.js +1 -1
- package/dist/embedding-encoder/index.mjs +1 -1
- package/dist/pipeline-analytics/_stub.js +1 -1
- package/dist/pipeline-analytics/{_virtual_mf-localSharedImportMap___mfe_internal__addon_pipeline_analytics_widgets-Brh9RYTk.mjs → _virtual_mf-localSharedImportMap___mfe_internal__addon_pipeline_analytics_widgets-Cbess8m-.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-DE456p1Q.mjs +26 -0
- package/dist/pipeline-analytics/{hostInit-DpWdIydx.mjs → hostInit-D4P0Tmio.mjs} +2 -2
- package/dist/pipeline-analytics/index.js +1965 -231
- package/dist/pipeline-analytics/index.mjs +1958 -231
- package/dist/pipeline-analytics/remoteEntry.js +1 -1
- package/package.json +1 -1
- package/dist/pipeline-analytics/_virtual_mf___mfe_internal__addon_pipeline_analytics_widgets__loadShare___mf_0_camstack_mf_1_ui_mf_2_library__loadShare__.js-BfeJzFhL.mjs +0 -26
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { $ as
|
|
1
|
+
import { $ as plateGalleryCapability, A as TIMELAPSE_DENSE_FLOOR_SEC, B as cosineSimilarity, C as NcSnoozeSchema, Ct as string, D as OpsLogEntrySchema, E as NcTaxonomySchema, F as addonWidgetsSourceCapability, G as encodeVectorBase64, H as defineCustomActions, I as alarmPanelCapability, J as isDetectionMacroClass, K as faceGalleryCapability, L as assertTimelapseCadences, M as TimelapseRulePatchSchema, N as TimelapseRuleSchema, O as RECORDING_EXPORT_MAX_READ_BYTES, P as TrackSourceSchema, Q as pipelineAnalyticsCapability, R as audioMetricsCapability, S as NcSnoozeInputSchema, St as record, T as NcSystemEventKindSchema, Tt as EventCategory, U as deriveRecordingMode, V as customAction, X as kebabToCamel, Y as isScheduleActive, Z as notificationRulesCapability, _ as NcRuleInputSchema, _t as discriminatedUnion, at as zoneAnalyticsCapability, b as NcRuleTargetSchema, bt as object, c as EVENT_PAD_MS, ct as DeviceType, d as NC_ALARM_SYSTEM_EVENT_KINDS, dt as isDeviceScopedCap, et as readDeviceStateFrom, f as NC_CONDITION_CATALOG, ft as nodePin, g as NcConditionDescriptorSchema, gt as boolean, h as NC_TAXONOMY, ht as array, i as DETECTION_MACRO_CLASSES, it as videoclipsCapability, j as TimelapseRuleInputSchema, k as RetrainStatusSchema, l as LabelAttributionSchema, lt as createEvent, mt as _enum, n as DEFAULT_EVENT_COLOR, nt as subKindsOf, o as DeclaredDevices, ot as errMsg, p as NC_DEFAULT_SNOOZE_MINUTES, pt as sleep, rt as vectorDimFromBase64, s as EVENT_KIND_BY_CAP, st as BaseAddon, t as BaseDevice, tt as readTimelapseGeneratedAt, u as MACRO_LABELS, ut as hydrateSchema, v as NcRulePatchSchema, vt as literal, w as NcSnoozeSuppressedSchema, wt as unknown, x as NcScheduleSchema, xt as partialRecord, y as NcRuleSchema, yt as number, z as buildEventKindDescriptor } from "../dist-_D0jdUh4.mjs";
|
|
2
2
|
import { t as __exportAll } from "../embedding-encoder/index.mjs";
|
|
3
3
|
import * as fs from "node:fs";
|
|
4
4
|
import { promises } from "node:fs";
|
|
@@ -3538,6 +3538,17 @@ var NcDeviceDirectory = class {
|
|
|
3538
3538
|
size() {
|
|
3539
3539
|
return this.devices.size;
|
|
3540
3540
|
}
|
|
3541
|
+
/**
|
|
3542
|
+
* Every identity in the current snapshot.
|
|
3543
|
+
*
|
|
3544
|
+
* Exists for the ONE caller that needs the whole directory rather than one
|
|
3545
|
+
* id: the liveness ledger's boot seed, which has to know what every device
|
|
3546
|
+
* was last known to be before the first event arrives. Off the event path by
|
|
3547
|
+
* construction — it is called once, from `start()`.
|
|
3548
|
+
*/
|
|
3549
|
+
all() {
|
|
3550
|
+
return [...this.devices.values()];
|
|
3551
|
+
}
|
|
3541
3552
|
/** Has a refresh ever succeeded? A cold directory notifies about nothing. */
|
|
3542
3553
|
isWarm() {
|
|
3543
3554
|
return this.loadedOnce;
|
|
@@ -3941,6 +3952,43 @@ function displayLabel(state) {
|
|
|
3941
3952
|
return state.subLabel ?? state.label;
|
|
3942
3953
|
}
|
|
3943
3954
|
/**
|
|
3955
|
+
* The tiered columns a row should carry to AGREE with the track, or `undefined`
|
|
3956
|
+
* when the track says nothing yet.
|
|
3957
|
+
*
|
|
3958
|
+
* The projection is total: whichever tiers the track holds are copied, and a
|
|
3959
|
+
* track holding neither yields `undefined` rather than a patch of blanks — the
|
|
3960
|
+
* caller then leaves the row exactly as it was.
|
|
3961
|
+
*/
|
|
3962
|
+
function knownTrackLabelPatch(state) {
|
|
3963
|
+
if (state === void 0) return void 0;
|
|
3964
|
+
if (state.label === void 0 && state.subLabel === void 0) return void 0;
|
|
3965
|
+
return {
|
|
3966
|
+
...state.label !== void 0 ? { label: state.label } : {},
|
|
3967
|
+
...state.labelScore !== void 0 ? { labelScore: state.labelScore } : {},
|
|
3968
|
+
...state.labelMeta !== void 0 ? { labelMeta: state.labelMeta } : {},
|
|
3969
|
+
...state.subLabel !== void 0 ? { subLabel: state.subLabel } : {},
|
|
3970
|
+
...state.subLabelScore !== void 0 ? { subLabelScore: state.subLabelScore } : {},
|
|
3971
|
+
...state.subLabelMeta !== void 0 ? { subLabelMeta: state.subLabelMeta } : {}
|
|
3972
|
+
};
|
|
3973
|
+
}
|
|
3974
|
+
/**
|
|
3975
|
+
* Does this row already say what the subject IS?
|
|
3976
|
+
*
|
|
3977
|
+
* The gate behind `DetailDispatcher.hasTrackLabel`: a periodic face-detection
|
|
3978
|
+
* refresh on a track that already has an answer runs geometry-only and drops
|
|
3979
|
+
* the ArcFace embedding. It reads {@link displayLabel} and not the `label`
|
|
3980
|
+
* column, and the difference is the whole point — an identity is a TIER-2
|
|
3981
|
+
* value and lands in `subLabel`, so the column read reported "unlabelled" for
|
|
3982
|
+
* every track ArcFace had already named and kept re-embedding it forever,
|
|
3983
|
+
* while suppressing the face chain on tracks that carried only a tier-1
|
|
3984
|
+
* sub-class (`van`). Exactly backwards, in both directions.
|
|
3985
|
+
*/
|
|
3986
|
+
function hasResolvedLabel(state) {
|
|
3987
|
+
if (state === null || state === void 0) return false;
|
|
3988
|
+
const label = displayLabel(state);
|
|
3989
|
+
return label !== void 0 && label.length > 0;
|
|
3990
|
+
}
|
|
3991
|
+
/**
|
|
3944
3992
|
* The display label of a frame-path tracked detection — the same
|
|
3945
3993
|
* `subLabel ?? label` question asked of a patch rather than a row. Used by
|
|
3946
3994
|
* every caller that wants the caption, not the tier (media captions, lifecycle
|
|
@@ -3950,6 +3998,36 @@ function patchDisplayLabel(patch) {
|
|
|
3950
3998
|
if (patch === void 0) return void 0;
|
|
3951
3999
|
return patch.subLabel ?? patch.label;
|
|
3952
4000
|
}
|
|
4001
|
+
/**
|
|
4002
|
+
* WHAT a tier-2 label is about — a recognised PERSON or a read PLATE.
|
|
4003
|
+
*
|
|
4004
|
+
* Derived from the deciding step, never stored a second time: the step that
|
|
4005
|
+
* wrote the value is already on the attribution, and a second column stating
|
|
4006
|
+
* the same thing is a knob that can disagree with the first (D62). Both the
|
|
4007
|
+
* machine step and the operator override of each family are listed, because a
|
|
4008
|
+
* hand-assigned identity is still an identity.
|
|
4009
|
+
*
|
|
4010
|
+
* Callers use it to pick a SENTENCE: "Gianluca è arrivato" and "Veicolo AB123CD"
|
|
4011
|
+
* are different keys in the notification text catalog, and a single
|
|
4012
|
+
* `{{label}}` slot cannot word both.
|
|
4013
|
+
*/
|
|
4014
|
+
var FACE_LABEL_STEP_IDS = new Set([
|
|
4015
|
+
"face-recognizer",
|
|
4016
|
+
"face-embedding",
|
|
4017
|
+
"operator:face-gallery"
|
|
4018
|
+
]);
|
|
4019
|
+
/** Steps whose tier-2 value is a PLATE / vehicle name. See {@link FACE_LABEL_STEP_IDS}. */
|
|
4020
|
+
var PLATE_LABEL_STEP_IDS = new Set(["plate-ocr", "operator:plate-gallery"]);
|
|
4021
|
+
/**
|
|
4022
|
+
* Classify a tier-2 attribution. `undefined` for an unattributed value, a
|
|
4023
|
+
* migrated one, or any step outside the two recognition families (a species, a
|
|
4024
|
+
* sub-class) — the caller then falls back to the generic labelled sentence.
|
|
4025
|
+
*/
|
|
4026
|
+
function labelKindOf(meta) {
|
|
4027
|
+
if (meta === void 0) return void 0;
|
|
4028
|
+
if (FACE_LABEL_STEP_IDS.has(meta.stepId)) return "identity";
|
|
4029
|
+
if (PLATE_LABEL_STEP_IDS.has(meta.stepId)) return "plate";
|
|
4030
|
+
}
|
|
3953
4031
|
/** Whether an attribution came from the 4g migration rather than a real step. */
|
|
3954
4032
|
function isMigrated(meta) {
|
|
3955
4033
|
return meta?.stepId === MIGRATION_STEP_ID;
|
|
@@ -4005,7 +4083,8 @@ function resolveTieredLabelWrite(state, candidate) {
|
|
|
4005
4083
|
const meta = {
|
|
4006
4084
|
stepId: candidate.stepId,
|
|
4007
4085
|
...candidate.modelId !== void 0 ? { modelId: candidate.modelId } : {},
|
|
4008
|
-
decidedAt: candidate.decidedAt
|
|
4086
|
+
decidedAt: candidate.decidedAt,
|
|
4087
|
+
...candidate.identityId !== void 0 ? { identityId: candidate.identityId } : {}
|
|
4009
4088
|
};
|
|
4010
4089
|
const incumbent = candidate.tier === 1 ? state.label : state.subLabel;
|
|
4011
4090
|
const incumbentScore = candidate.tier === 1 ? state.labelScore : state.subLabelScore;
|
|
@@ -4227,6 +4306,7 @@ function subjectFromObjectEvent(ev) {
|
|
|
4227
4306
|
timestamp: ev.timestamp,
|
|
4228
4307
|
classNames: [ev.className],
|
|
4229
4308
|
...displayLabel(ev) !== void 0 ? { label: displayLabel(ev) } : {},
|
|
4309
|
+
...recognitionPatch(ev.subLabelMeta),
|
|
4230
4310
|
...ev.confidence !== void 0 ? { confidence: ev.confidence } : {},
|
|
4231
4311
|
zones: ev.zones ?? [],
|
|
4232
4312
|
...ev.trackId !== void 0 ? { trackId: ev.trackId } : {},
|
|
@@ -4240,6 +4320,19 @@ function subjectFromObjectEvent(ev) {
|
|
|
4240
4320
|
function bboxPatch(bbox) {
|
|
4241
4321
|
return bbox !== void 0 ? { bbox } : {};
|
|
4242
4322
|
}
|
|
4323
|
+
/**
|
|
4324
|
+
* The recognition half of a subject, read off the tier-2 attribution: WHICH
|
|
4325
|
+
* gallery row and WHAT KIND of thing. Both absent on an unattributed label, so
|
|
4326
|
+
* every condition that depends on them fails closed for a legacy record — which
|
|
4327
|
+
* is why the id/name matcher keeps its name leg.
|
|
4328
|
+
*/
|
|
4329
|
+
function recognitionPatch(meta) {
|
|
4330
|
+
const kind = labelKindOf(meta);
|
|
4331
|
+
return {
|
|
4332
|
+
...meta?.identityId !== void 0 ? { identityId: meta.identityId } : {},
|
|
4333
|
+
...kind !== void 0 ? { labelKind: kind } : {}
|
|
4334
|
+
};
|
|
4335
|
+
}
|
|
4243
4336
|
/** Convert a normalized 0..1 track envelope (min/max) into the subject's
|
|
4244
4337
|
* top-left+size `BboxRect`. `undefined` in ⇒ `undefined` out (fail closed). */
|
|
4245
4338
|
function envelopeToBbox(envelope) {
|
|
@@ -4404,6 +4497,7 @@ function subjectFromPackageEvent(ev, phase) {
|
|
|
4404
4497
|
timestamp: ev.timestamp,
|
|
4405
4498
|
classNames: [ev.className],
|
|
4406
4499
|
...displayLabel(ev) !== void 0 ? { label: displayLabel(ev) } : {},
|
|
4500
|
+
...recognitionPatch(ev.subLabelMeta),
|
|
4407
4501
|
...ev.confidence !== void 0 ? { confidence: ev.confidence } : {},
|
|
4408
4502
|
zones: ev.zones ?? [],
|
|
4409
4503
|
...ev.trackId !== void 0 ? { trackId: ev.trackId } : {},
|
|
@@ -4422,6 +4516,7 @@ function subjectFromTrack(track, info) {
|
|
|
4422
4516
|
timestamp: track.lastSeen,
|
|
4423
4517
|
classNames: track.classes && track.classes.length > 0 ? track.classes : [track.className],
|
|
4424
4518
|
...displayLabel(track) !== void 0 ? { label: displayLabel(track) } : {},
|
|
4519
|
+
...recognitionPatch(track.subLabelMeta),
|
|
4425
4520
|
...info?.bestConfidence !== void 0 ? { confidence: info.bestConfidence } : {},
|
|
4426
4521
|
zones: track.zonesVisited,
|
|
4427
4522
|
trackId: track.trackId,
|
|
@@ -4457,7 +4552,7 @@ function presentConditionIds(c) {
|
|
|
4457
4552
|
if (c.zonesExclude !== void 0 && c.zonesExclude.length > 0) ids.push("zonesExclude");
|
|
4458
4553
|
if (c.crossing !== void 0 && c.crossing !== "enter") ids.push("crossing");
|
|
4459
4554
|
if (c.labelEquals !== void 0 && c.labelEquals.length > 0) ids.push("labelEquals");
|
|
4460
|
-
if (c.identities !== void 0
|
|
4555
|
+
if (c.identities !== void 0) ids.push("identities");
|
|
4461
4556
|
if (c.identitiesExclude !== void 0 && c.identitiesExclude.length > 0) ids.push("identitiesExclude");
|
|
4462
4557
|
if (c.minLabelConfidence !== void 0) ids.push("minLabelConfidence");
|
|
4463
4558
|
if (c.plates !== void 0) ids.push("plates");
|
|
@@ -4543,6 +4638,70 @@ function toLowerSet(values) {
|
|
|
4543
4638
|
return new Set(values.map((v) => v.trim().toLowerCase()));
|
|
4544
4639
|
}
|
|
4545
4640
|
/**
|
|
4641
|
+
* Does the subject's identity appear in `scope`?
|
|
4642
|
+
*
|
|
4643
|
+
* TWO LEGS, both load-bearing, and the reason is the migration rather than
|
|
4644
|
+
* indecision. `scope` holds gallery ids after `NcRuleStore.load` has resolved
|
|
4645
|
+
* them; a name it could not resolve is left as it stands (reported, never
|
|
4646
|
+
* dropped), and a record written before ids reached `LabelAttribution` carries
|
|
4647
|
+
* a name and no id. Matching only the id would silence the first case; matching
|
|
4648
|
+
* only the name would re-introduce the rename bug the id exists to fix.
|
|
4649
|
+
*
|
|
4650
|
+
* The id leg is exact (a uuid has no case); the name leg is the historical
|
|
4651
|
+
* trim + lowercase comparison, unchanged, so a rule that matched yesterday
|
|
4652
|
+
* matches today.
|
|
4653
|
+
*/
|
|
4654
|
+
function matchesIdentityScope(subject, scope) {
|
|
4655
|
+
if (subject.identityId !== void 0 && scope.includes(subject.identityId)) return true;
|
|
4656
|
+
const label = subject.label?.trim().toLowerCase();
|
|
4657
|
+
return label !== void 0 && toLowerSet(scope).has(label);
|
|
4658
|
+
}
|
|
4659
|
+
/**
|
|
4660
|
+
* Does this rule's match depend on RECOGNITION — a face identity or a plate
|
|
4661
|
+
* read — rather than on the detection alone?
|
|
4662
|
+
*
|
|
4663
|
+
* Derived from the conditions the rule already carries; nothing stores it. It
|
|
4664
|
+
* is what makes `waitForEnhancement` inert on a rule with nothing to wait for,
|
|
4665
|
+
* and what decides whether a track close owes an "…is Gianluca" follow-up.
|
|
4666
|
+
* `identitiesExclude` is deliberately NOT in the set: a veto is satisfied by
|
|
4667
|
+
* the ABSENCE of a recognition, so waiting for one would delay every
|
|
4668
|
+
* notification the rule exists to send.
|
|
4669
|
+
*/
|
|
4670
|
+
function ruleAwaitsRecognition(rule) {
|
|
4671
|
+
return rule.conditions.identities !== void 0 || rule.conditions.plates !== void 0;
|
|
4672
|
+
}
|
|
4673
|
+
/**
|
|
4674
|
+
* Does this rule stop firing on the object event and fire at TRACK CLOSE?
|
|
4675
|
+
*
|
|
4676
|
+
* `waitForEnhancement` AND something to wait for AND an `immediate` delivery.
|
|
4677
|
+
* A `track-end` rule already fires at close, and a rule with no recognition
|
|
4678
|
+
* scope would be delayed for nothing — in both cases the flag is inert, and
|
|
4679
|
+
* that is deliberate: the operator sets one switch and the rule tells them
|
|
4680
|
+
* whether it applies rather than silently changing when a notification arrives.
|
|
4681
|
+
*
|
|
4682
|
+
* The consequence is stated where the flag is declared and repeated in the
|
|
4683
|
+
* editor: a deferred rule inherits TRACK-CLOSE semantics. Its `zones` condition
|
|
4684
|
+
* tests every zone the track visited, and a `crossing` condition can no longer
|
|
4685
|
+
* be satisfied. Nothing is remembered between the two moments — the closed
|
|
4686
|
+
* track IS the evaluation, so a respawn in the middle costs nothing and the
|
|
4687
|
+
* doorbell-mirror failure (D49) has no surface here.
|
|
4688
|
+
*/
|
|
4689
|
+
function ruleDefersToTrackEnd(rule) {
|
|
4690
|
+
return rule.waitForEnhancement === true && rule.delivery === "immediate" && ruleAwaitsRecognition(rule);
|
|
4691
|
+
}
|
|
4692
|
+
/**
|
|
4693
|
+
* Does a track close owe this rule an "…and it was Gianluca" SECOND
|
|
4694
|
+
* notification?
|
|
4695
|
+
*
|
|
4696
|
+
* The mirror of {@link ruleDefersToTrackEnd}: an `immediate` recognition rule
|
|
4697
|
+
* that did NOT wait fired seconds before the face was confirmed, so its
|
|
4698
|
+
* notification is true and incomplete. The upgrade is what makes "off" an
|
|
4699
|
+
* honest setting rather than a silent downgrade.
|
|
4700
|
+
*/
|
|
4701
|
+
function ruleUpgradesAtTrackEnd(rule) {
|
|
4702
|
+
return rule.waitForEnhancement !== true && rule.delivery === "immediate" && ruleAwaitsRecognition(rule);
|
|
4703
|
+
}
|
|
4704
|
+
/**
|
|
4546
4705
|
* Expand each selected class id to itself PLUS its taxonomy leaf subs, so a
|
|
4547
4706
|
* MACRO selection (`vehicle`) matches its sub classNames (`car`/`truck`) — the
|
|
4548
4707
|
* `EVENT_TAXONOMY` tree is the SINGLE source (mirrors the timeline/filter
|
|
@@ -4608,7 +4767,9 @@ function evaluateRule(rule, subject, deviceState, zoneOwners) {
|
|
|
4608
4767
|
if (rule.conditions.occupancy !== void 0) return fail("occupancy");
|
|
4609
4768
|
if (rule.conditions.systemEvent !== void 0) return fail("systemEvent");
|
|
4610
4769
|
} else {
|
|
4611
|
-
if ((rule.delivery === "immediate" ? "object-event" : rule.delivery) !== subject.kind)
|
|
4770
|
+
if ((rule.delivery === "immediate" ? "object-event" : rule.delivery) !== subject.kind) {
|
|
4771
|
+
if (!(subject.kind === "track-end" && ruleDefersToTrackEnd(rule))) return fail("delivery");
|
|
4772
|
+
}
|
|
4612
4773
|
if (rule.conditions.occupancy !== void 0) return fail("occupancy");
|
|
4613
4774
|
if (rule.conditions.systemEvent !== void 0) return fail("systemEvent");
|
|
4614
4775
|
if (rule.conditions.audio !== void 0) return fail("audio");
|
|
@@ -4678,17 +4839,17 @@ function evaluateRule(rule, subject, deviceState, zoneOwners) {
|
|
|
4678
4839
|
if (c.labelEquals !== void 0 && c.labelEquals.length > 0) {
|
|
4679
4840
|
if (label === void 0 || !toLowerSet(c.labelEquals).has(label)) return fail("labelEquals");
|
|
4680
4841
|
}
|
|
4681
|
-
if (c.identities !== void 0
|
|
4682
|
-
if (
|
|
4842
|
+
if (c.identities !== void 0) {
|
|
4843
|
+
if (!(c.identities.length > 0 ? matchesIdentityScope(subject, c.identities) : subject.labelKind === "identity")) return fail("identities");
|
|
4683
4844
|
}
|
|
4684
4845
|
if (c.identitiesExclude !== void 0 && c.identitiesExclude.length > 0) {
|
|
4685
|
-
if (
|
|
4846
|
+
if (matchesIdentityScope(subject, c.identitiesExclude)) return fail("identitiesExclude");
|
|
4686
4847
|
}
|
|
4687
4848
|
if (c.minLabelConfidence !== void 0) {
|
|
4688
4849
|
if (subject.labelConfidence === void 0 || subject.labelConfidence < c.minLabelConfidence) return fail("minLabelConfidence");
|
|
4689
4850
|
}
|
|
4690
4851
|
if (c.plates !== void 0) {
|
|
4691
|
-
if (subject.label
|
|
4852
|
+
if (!(c.plates.values.length > 0 ? subject.label !== void 0 && matchesPlate(subject.label, c.plates.values, c.plates.maxDistance) : subject.labelKind === "plate")) return fail("plates");
|
|
4692
4853
|
}
|
|
4693
4854
|
if (c.sensorKinds !== void 0 && c.sensorKinds.length > 0) {
|
|
4694
4855
|
if (subject.sensorKind === void 0 || !toLowerSet(c.sensorKinds).has(subject.sensorKind.trim().toLowerCase())) return fail("sensorKinds");
|
|
@@ -4753,6 +4914,27 @@ function keysPerClass(rule, subject) {
|
|
|
4753
4914
|
if (subject.kind === "audio-event" || subject.kind === "audio-window") return true;
|
|
4754
4915
|
return rule.throttle.granularity === "per-class";
|
|
4755
4916
|
}
|
|
4917
|
+
/**
|
|
4918
|
+
* The cooldown scope of ONE system event — subject AND direction.
|
|
4919
|
+
*
|
|
4920
|
+
* The `kind` is here because a cooldown is a budget for VOLUME and must never
|
|
4921
|
+
* be a budget for a DIRECTION. `Eventi di sistema` (60 s, `rule-device`)
|
|
4922
|
+
* covers `device-offline` and `device-online` together, so on 2026-08-13 a
|
|
4923
|
+
* camera that came back 13 s after going down had its recovery suppressed:
|
|
4924
|
+
* the operator kept a "camera offline" as the last word while the camera was
|
|
4925
|
+
* up, and — because the liveness ledger HAD advanced on the suppressed flip —
|
|
4926
|
+
* the next outage was a genuine flip again and arrived as what read like a
|
|
4927
|
+
* duplicate. Same split D62 made one level up between `device-switch:<id>` and
|
|
4928
|
+
* `device:<id>`, for the same reason: two facts must not share a slot and
|
|
4929
|
+
* cancel each other out.
|
|
4930
|
+
*
|
|
4931
|
+
* ONE authority: `cooldownKey` and the boot-time cooldown reseed
|
|
4932
|
+
* (`NotificationCenter.seedCooldowns`) both call this. They used to build the
|
|
4933
|
+
* string separately, which is how a key format drifts across a restart.
|
|
4934
|
+
*/
|
|
4935
|
+
function systemEventCooldownScope(systemEvent) {
|
|
4936
|
+
return `s:${systemEvent.subject}:k:${systemEvent.kind}`;
|
|
4937
|
+
}
|
|
4756
4938
|
/** Stable cooldown key per the rule's throttle scope + class granularity. */
|
|
4757
4939
|
/**
|
|
4758
4940
|
* Does the rule's device scope cover this subject?
|
|
@@ -4779,8 +4961,9 @@ function matchesDeviceScope(scope, subject) {
|
|
|
4779
4961
|
function cooldownKey(rule, subject) {
|
|
4780
4962
|
const first = subject.classNames[0];
|
|
4781
4963
|
const classKey = keysPerClass(rule, subject) && first !== void 0 ? `:c:${first}` : "";
|
|
4782
|
-
const scopeRef = subject.systemEvent !== void 0 ?
|
|
4783
|
-
|
|
4964
|
+
const scopeRef = subject.systemEvent !== void 0 ? systemEventCooldownScope(subject.systemEvent) : `d:${subject.deviceId}`;
|
|
4965
|
+
const kindKey = subject.systemEvent !== void 0 ? `:k:${subject.systemEvent.kind}` : "";
|
|
4966
|
+
return rule.throttle.scope === "rule" ? `r:${rule.id}${kindKey}${classKey}` : `r:${rule.id}:${scopeRef}${classKey}`;
|
|
4784
4967
|
}
|
|
4785
4968
|
/** True when the rule fired within its cooldown window before `now`. */
|
|
4786
4969
|
function isCoolingDown(rule, lastFiredAt, now) {
|
|
@@ -4893,8 +5076,11 @@ var en_default = {
|
|
|
4893
5076
|
"detection.title": "{{rule}}",
|
|
4894
5077
|
"detection.body": "{{subject}} on {{camera}}{{inZones}}",
|
|
4895
5078
|
"detection.body.trackEnd": "{{subject}} on {{camera}}{{inZones}} — visit ended",
|
|
5079
|
+
"detection.body.enhanced": "{{subject}} on {{camera}}{{inZones}} — now recognised",
|
|
4896
5080
|
"detection.subject": "{{classNoun}}",
|
|
4897
5081
|
"detection.subject.labelled": "{{classNoun}} ({{label}})",
|
|
5082
|
+
"detection.subject.identity": "{{label}}",
|
|
5083
|
+
"detection.subject.plate": "{{classNoun}} {{label}}",
|
|
4898
5084
|
"detection.inZones": " in {{zones}}",
|
|
4899
5085
|
"class.person": {
|
|
4900
5086
|
"one": "person",
|
|
@@ -4974,6 +5160,7 @@ var en_default = {
|
|
|
4974
5160
|
"system.camera-disabled.body": "Device {{deviceName}} was switched off",
|
|
4975
5161
|
"system.camera-enabled.title": "Device switched on",
|
|
4976
5162
|
"system.camera-enabled.body": "Device {{deviceName}} was switched back on",
|
|
5163
|
+
"test.prefix": "[TEST] {{body}}",
|
|
4977
5164
|
"timelapse.title": "Timelapse — {{camera}}",
|
|
4978
5165
|
"timelapse.body.window": "{{rule}} — {{from}}→{{to}}",
|
|
4979
5166
|
"timelapse.body.tooManyEvents": "too many events for dense sampling: video at a uniform cadence",
|
|
@@ -5056,8 +5243,11 @@ var it_default = {
|
|
|
5056
5243
|
"detection.title": "{{rule}}",
|
|
5057
5244
|
"detection.body": "{{subject}} su {{camera}}{{inZones}}",
|
|
5058
5245
|
"detection.body.trackEnd": "{{subject}} su {{camera}}{{inZones}} — visita terminata",
|
|
5246
|
+
"detection.body.enhanced": "{{subject}} su {{camera}}{{inZones}} — ora riconosciuto",
|
|
5059
5247
|
"detection.subject": "{{classNoun}}",
|
|
5060
5248
|
"detection.subject.labelled": "{{classNoun}} ({{label}})",
|
|
5249
|
+
"detection.subject.identity": "{{label}}",
|
|
5250
|
+
"detection.subject.plate": "{{classNoun}} {{label}}",
|
|
5061
5251
|
"detection.inZones": " in {{zones}}",
|
|
5062
5252
|
"class.person": {
|
|
5063
5253
|
"one": "persona",
|
|
@@ -5137,6 +5327,7 @@ var it_default = {
|
|
|
5137
5327
|
"system.camera-disabled.body": "Il dispositivo {{deviceName}} è stato spento",
|
|
5138
5328
|
"system.camera-enabled.title": "Dispositivo riacceso",
|
|
5139
5329
|
"system.camera-enabled.body": "Il dispositivo {{deviceName}} è stato riacceso",
|
|
5330
|
+
"test.prefix": "[TEST] {{body}}",
|
|
5140
5331
|
"timelapse.title": "Timelapse — {{camera}}",
|
|
5141
5332
|
"timelapse.body.window": "{{rule}} — {{from}}→{{to}}",
|
|
5142
5333
|
"timelapse.body.tooManyEvents": "troppi eventi per il campionamento denso: video a cadenza uniforme",
|
|
@@ -5411,7 +5602,7 @@ function ncSubjectText(texts, input) {
|
|
|
5411
5602
|
vars: { classNoun }
|
|
5412
5603
|
});
|
|
5413
5604
|
return texts.text({
|
|
5414
|
-
key:
|
|
5605
|
+
key: subjectLabelKey(input.labelKind),
|
|
5415
5606
|
vars: {
|
|
5416
5607
|
classNoun,
|
|
5417
5608
|
label: input.label
|
|
@@ -5419,6 +5610,16 @@ function ncSubjectText(texts, input) {
|
|
|
5419
5610
|
});
|
|
5420
5611
|
}
|
|
5421
5612
|
/**
|
|
5613
|
+
* Which labelled-subject key a recognition gets. A kind the catalog does not
|
|
5614
|
+
* name falls back to the generic key rather than printing nothing — the same
|
|
5615
|
+
* direction `ncClassNoun` degrades in, and for the same reason.
|
|
5616
|
+
*/
|
|
5617
|
+
function subjectLabelKey(kind) {
|
|
5618
|
+
if (kind === "identity") return "detection.subject.identity";
|
|
5619
|
+
if (kind === "plate") return "detection.subject.plate";
|
|
5620
|
+
return "detection.subject.labelled";
|
|
5621
|
+
}
|
|
5622
|
+
/**
|
|
5422
5623
|
* ` in gate, drive` — INCLUDING its leading space, and empty when there are no
|
|
5423
5624
|
* zones.
|
|
5424
5625
|
*
|
|
@@ -5917,13 +6118,18 @@ var NcDispatcher = class {
|
|
|
5917
6118
|
...systemCount,
|
|
5918
6119
|
frozen: systemEvent?.title ?? entry.payload.ruleName
|
|
5919
6120
|
});
|
|
5920
|
-
const
|
|
6121
|
+
const rendered = this.renderText({
|
|
5921
6122
|
template: entry.payload.template?.body,
|
|
5922
6123
|
key: bodyKeyFor(entry),
|
|
5923
6124
|
vars,
|
|
5924
6125
|
...systemCount,
|
|
5925
6126
|
...systemEvent?.body !== void 0 ? { frozen: systemEvent.body } : {}
|
|
5926
6127
|
});
|
|
6128
|
+
const body = entry.payload.synthetic === true ? this.renderText({
|
|
6129
|
+
key: "test.prefix",
|
|
6130
|
+
vars: { body: rendered },
|
|
6131
|
+
frozen: rendered
|
|
6132
|
+
}) : rendered;
|
|
5927
6133
|
const attachments = [...await this.withArtifactUrls(await this.resolveAttachments(entry)), ...frozenArtifactAttachments(entry)];
|
|
5928
6134
|
const params = pickParams(entry.payload.params);
|
|
5929
6135
|
const actions = await this.mintActions(entry, target);
|
|
@@ -6280,7 +6486,8 @@ function buildTemplateVars(texts, entry, deviceName, zoneLabels) {
|
|
|
6280
6486
|
...detection.label !== void 0 ? { label: detection.label } : {}
|
|
6281
6487
|
}) : systemEvent?.subject ?? ncSubjectText(texts, {
|
|
6282
6488
|
className: subject.className,
|
|
6283
|
-
...subject.label !== void 0 ? { label: subject.label } : {}
|
|
6489
|
+
...subject.label !== void 0 ? { label: subject.label } : {},
|
|
6490
|
+
...subject.labelKind !== void 0 ? { labelKind: subject.labelKind } : {}
|
|
6284
6491
|
}),
|
|
6285
6492
|
inZones: ncInZonesText(texts, zoneLabels),
|
|
6286
6493
|
scope: occupancy?.zone ?? deviceName,
|
|
@@ -6312,6 +6519,7 @@ function bodyKeyFor(entry) {
|
|
|
6312
6519
|
const subject = entry.payload.subject;
|
|
6313
6520
|
if (subject.systemEvent !== void 0) return subject.systemEvent.bodyKey ?? "";
|
|
6314
6521
|
if (subject.occupancy !== void 0) return "occupancy.body";
|
|
6522
|
+
if (entry.payload.enhancement === true) return "detection.body.enhanced";
|
|
6315
6523
|
return entry.recordKind === "track-end" ? "detection.body.trackEnd" : "detection.body";
|
|
6316
6524
|
}
|
|
6317
6525
|
/** The default TITLE key — same shape, same pre-catalog reasoning. */
|
|
@@ -7301,6 +7509,73 @@ var NcLivenessLedger = class {
|
|
|
7301
7509
|
async load() {
|
|
7302
7510
|
return (await this.ledger.load()).length;
|
|
7303
7511
|
}
|
|
7512
|
+
/**
|
|
7513
|
+
* Boot seed from the DEVICE DIRECTORY — the second half of the reseed, and
|
|
7514
|
+
* the fix for the outage this ledger used to swallow.
|
|
7515
|
+
*
|
|
7516
|
+
* ## The hole
|
|
7517
|
+
*
|
|
7518
|
+
* {@link load} recovers what the ledger itself accepted. A device that has
|
|
7519
|
+
* never produced a liveness event since the ledger was created has no row at
|
|
7520
|
+
* all, so its FIRST observation ever seeds silently — and if that first
|
|
7521
|
+
* observation is the camera DYING, the outage is thrown away. Live cost,
|
|
7522
|
+
* 2026-08-13 21:36 CEST: the operator unplugged four cameras and received two
|
|
7523
|
+
* notifications. `device:584` and `device:587` had never spoken; their
|
|
7524
|
+
* unplugging was recorded and never announced. A camera that is quiet until
|
|
7525
|
+
* it dies is a camera whose death is silent, indefinitely.
|
|
7526
|
+
*
|
|
7527
|
+
* ## Why only the ONLINE ones
|
|
7528
|
+
*
|
|
7529
|
+
* The directory is the device manager's own `online` flag (D132 §3: the
|
|
7530
|
+
* authority seeds the mirror at boot and stays the authority). Seeding only
|
|
7531
|
+
* the devices it calls ONLINE is what makes this change one-directional:
|
|
7532
|
+
*
|
|
7533
|
+
* - a row seeded `online` can only ever turn a later `device.offline` into a
|
|
7534
|
+
* notification — a true sentence about a device that just went away;
|
|
7535
|
+
* - it can NEVER turn a `device.online` into one, and that is the only shape
|
|
7536
|
+
* the 2026-08-13 notification flood (D130) ever had.
|
|
7537
|
+
*
|
|
7538
|
+
* A device the directory calls offline — which is also what a cold or
|
|
7539
|
+
* still-settling directory would say — gets no row and keeps today's
|
|
7540
|
+
* behaviour exactly. Failing toward discard, unchanged.
|
|
7541
|
+
*
|
|
7542
|
+
* ## D49
|
|
7543
|
+
*
|
|
7544
|
+
* An EXISTING row is never overwritten. The ledger is the authority for what
|
|
7545
|
+
* it accepted; the directory is a mirror refreshed on a tick and may be
|
|
7546
|
+
* stale. A stale `online` overwriting an accepted `offline` would make the
|
|
7547
|
+
* camera's real recovery a no-flip and lose it. A failed directory read
|
|
7548
|
+
* simply yields an empty list here and seeds nothing.
|
|
7549
|
+
*
|
|
7550
|
+
* ## Staged, not persisted
|
|
7551
|
+
*
|
|
7552
|
+
* The seed is DERIVED from an authority that is read again on every boot, so
|
|
7553
|
+
* losing it costs nothing — the next boot re-derives it. `stage` therefore
|
|
7554
|
+
* advances the mirror without a write, which matters on this hub: the
|
|
7555
|
+
* pipeline-analytics runner respawns several times an hour, and persisting
|
|
7556
|
+
* ~500 re-derivable rows on each respawn would offer thousands of pointless
|
|
7557
|
+
* commits a day to the checkpoint lottery (D96). The first real
|
|
7558
|
+
* {@link observe} flip persists through the ordinary write-behind path.
|
|
7559
|
+
*
|
|
7560
|
+
* Returns how many rows were seeded, so the caller can say it out loud.
|
|
7561
|
+
*/
|
|
7562
|
+
seedFromDirectory(identities, now) {
|
|
7563
|
+
let seeded = 0;
|
|
7564
|
+
for (const identity of identities) {
|
|
7565
|
+
if (identity.online !== true) continue;
|
|
7566
|
+
const subject = `device:${identity.deviceId}`;
|
|
7567
|
+
if (this.ledger.has(subject)) continue;
|
|
7568
|
+
this.ledger.stage({
|
|
7569
|
+
subject,
|
|
7570
|
+
state: "online",
|
|
7571
|
+
kind: "device-online",
|
|
7572
|
+
deviceId: identity.deviceId,
|
|
7573
|
+
updatedAt: now
|
|
7574
|
+
});
|
|
7575
|
+
seeded += 1;
|
|
7576
|
+
}
|
|
7577
|
+
return seeded;
|
|
7578
|
+
}
|
|
7304
7579
|
/** The in-RAM mirror, subject-ordered so two readers agree. */
|
|
7305
7580
|
snapshot() {
|
|
7306
7581
|
return this.ledger.snapshot().toSorted((a, b) => a.subject.localeCompare(b.subject));
|
|
@@ -7793,7 +8068,7 @@ function probeOccupancyNow(input) {
|
|
|
7793
8068
|
* field for them would be a cross-package change for a sentence.
|
|
7794
8069
|
*/
|
|
7795
8070
|
function occupancyProbeLabel(occupancy, probe, cooldownSec) {
|
|
7796
|
-
const classNote =
|
|
8071
|
+
const classNote = occupancyClassWarning(occupancy.className);
|
|
7797
8072
|
if (probe.kind === "unavailable") {
|
|
7798
8073
|
const reason = probe.reason === "no-snapshot" ? "no ZoneAnalytics snapshot for this camera — the condition fails closed until one arrives" : "that zone is not in the camera’s current snapshot — the condition fails closed (deleted zone?)";
|
|
7799
8074
|
return classNote === null ? reason : `${classNote} · ${reason}`;
|
|
@@ -7818,7 +8093,7 @@ function occupancyProbeLabel(occupancy, probe, cooldownSec) {
|
|
|
7818
8093
|
* pixel-identical to an empty parking space. `byClass` is MACRO-keyed, so that
|
|
7819
8094
|
* counter is 0 structurally and no scene can change it.
|
|
7820
8095
|
*/
|
|
7821
|
-
function
|
|
8096
|
+
function occupancyClassWarning(className) {
|
|
7822
8097
|
if (className === void 0 || isDetectionMacroClass(className)) return null;
|
|
7823
8098
|
return `⚠ “${className}” is a sub-type, not a macro class: this counter is always 0 (use ${[...DETECTION_MACRO_CLASSES].join(" / ")})`;
|
|
7824
8099
|
}
|
|
@@ -8072,7 +8347,7 @@ var DEFAULT_DRAIN_BATCH = 20;
|
|
|
8072
8347
|
var DEDUP_HYDRATE_WINDOW_MS = 168 * 36e5;
|
|
8073
8348
|
/** Stable dedup id — the durable per-track/per-record fire guarantee. */
|
|
8074
8349
|
function outboxEntryId(input) {
|
|
8075
|
-
const dedupRef = input.trackId ?? input.recordId;
|
|
8350
|
+
const dedupRef = input.dedupRef ?? input.trackId ?? input.recordId;
|
|
8076
8351
|
return `${input.ruleId}:${dedupRef}:${input.targetId}`;
|
|
8077
8352
|
}
|
|
8078
8353
|
var NcOutbox = class {
|
|
@@ -8624,7 +8899,130 @@ var NcRuleActionRunner = class {
|
|
|
8624
8899
|
}
|
|
8625
8900
|
};
|
|
8626
8901
|
//#endregion
|
|
8902
|
+
//#region src/notification-center/rule-status.ts
|
|
8903
|
+
/**
|
|
8904
|
+
* The live occupancy of the scope ONE rule counts.
|
|
8905
|
+
*
|
|
8906
|
+
* `count`/`occupied` come from the SAME probe the dry-run uses
|
|
8907
|
+
* (`probeOccupancyNow`), so the status and the dry-run can never disagree about
|
|
8908
|
+
* a camera. `since` comes from the watcher's CONFIRMED row, which is a
|
|
8909
|
+
* different fact and the one the operator actually asks for: the probe reads a
|
|
8910
|
+
* level, the watcher records when that level was last confirmed to change.
|
|
8911
|
+
*/
|
|
8912
|
+
var NcOccupancyRuleStatusSchema = object({
|
|
8913
|
+
kind: literal("occupancy"),
|
|
8914
|
+
deviceId: number().int(),
|
|
8915
|
+
/** Zone NAME when the snapshot knows one, else the zone id, else absent for
|
|
8916
|
+
* whole-frame — the words a card puts after "in". */
|
|
8917
|
+
scope: string().optional(),
|
|
8918
|
+
/** The class counted; absent = every class. */
|
|
8919
|
+
className: string().optional(),
|
|
8920
|
+
/** The capacity the predicate compares against (`<=C` keys on C+1). */
|
|
8921
|
+
threshold: number().int(),
|
|
8922
|
+
sustainSeconds: number().int(),
|
|
8923
|
+
/** The count read for the scope. Absent when the probe was unavailable. */
|
|
8924
|
+
count: number().int().optional(),
|
|
8925
|
+
/** `count >= threshold` at the probe. Absent when unavailable. */
|
|
8926
|
+
occupied: boolean().optional(),
|
|
8927
|
+
/** Frame time of the snapshot the count came from — how FRESH it is, which
|
|
8928
|
+
* is a different question from "now". */
|
|
8929
|
+
observedAt: number().int().optional(),
|
|
8930
|
+
/** When the watcher last CONFIRMED a change of this key's level. Absent when
|
|
8931
|
+
* the watcher holds no confirmed row (a cold boot, or a key nothing
|
|
8932
|
+
* watches). */
|
|
8933
|
+
since: number().int().optional(),
|
|
8934
|
+
/** Why there is no count: the two fail-closed shapes of the live path. */
|
|
8935
|
+
unavailable: _enum(["no-snapshot", "zone-absent"]).optional(),
|
|
8936
|
+
/** A scope the counter can never answer (a sub-type class) — stated, because
|
|
8937
|
+
* its reading of 0 is pixel-identical to an empty zone. */
|
|
8938
|
+
warning: string().optional()
|
|
8939
|
+
});
|
|
8940
|
+
/** One camera of a timelapse rule — each renders its own video. */
|
|
8941
|
+
var NcTimelapseDeviceStatusSchema = object({
|
|
8942
|
+
deviceId: number().int(),
|
|
8943
|
+
/** Last successful generation for THIS camera (the per-device map, with the
|
|
8944
|
+
* rule-wide fallback). Absent = never generated. */
|
|
8945
|
+
lastGeneratedAt: number().int().optional(),
|
|
8946
|
+
/** The artefact that generation produced, when it is still on the shelf. */
|
|
8947
|
+
lastArtifact: NcArtifactRecordSchema.optional(),
|
|
8948
|
+
/** A render this process is waiting on right now. */
|
|
8949
|
+
rendering: object({
|
|
8950
|
+
startedAt: number().int(),
|
|
8951
|
+
windowStartMs: number().int(),
|
|
8952
|
+
windowEndMs: number().int()
|
|
8953
|
+
}).optional()
|
|
8954
|
+
});
|
|
8955
|
+
var NcTimelapseRuleStatusSchema = object({
|
|
8956
|
+
kind: literal("timelapse"),
|
|
8957
|
+
devices: array(NcTimelapseDeviceStatusSchema),
|
|
8958
|
+
/** The most recent window that has already closed — what the next tick would
|
|
8959
|
+
* render for any camera that has not got it yet. Absent = none in the
|
|
8960
|
+
* lookback (a schedule that never closes, or a rule younger than its window). */
|
|
8961
|
+
lastWindow: object({
|
|
8962
|
+
startMs: number(),
|
|
8963
|
+
endMs: number()
|
|
8964
|
+
}).optional(),
|
|
8965
|
+
/** The next OPENING. `endMs` null = the close is beyond the scan horizon. */
|
|
8966
|
+
nextWindow: object({
|
|
8967
|
+
startMs: number(),
|
|
8968
|
+
endMs: number().nullable()
|
|
8969
|
+
}).optional(),
|
|
8970
|
+
/** How long after a close the producer waits for the footage to settle. */
|
|
8971
|
+
settleMs: number().int()
|
|
8972
|
+
});
|
|
8973
|
+
var NcRuleStatusSchema = discriminatedUnion("kind", [
|
|
8974
|
+
object({
|
|
8975
|
+
kind: literal("none"),
|
|
8976
|
+
reason: string()
|
|
8977
|
+
}),
|
|
8978
|
+
NcOccupancyRuleStatusSchema,
|
|
8979
|
+
NcTimelapseRuleStatusSchema
|
|
8980
|
+
]);
|
|
8981
|
+
/**
|
|
8982
|
+
* Assemble the occupancy status from the probe and the watcher's confirmed row.
|
|
8983
|
+
*
|
|
8984
|
+
* Pure. The two inputs are deliberately separate rather than one blob: they
|
|
8985
|
+
* answer different questions from different substrates, and merging them at the
|
|
8986
|
+
* source would let a missing snapshot erase a `since` the watcher still knows.
|
|
8987
|
+
*/
|
|
8988
|
+
function buildOccupancyStatus(input) {
|
|
8989
|
+
const { condition, probe, threshold } = input;
|
|
8990
|
+
const key = occupancyKey(input.deviceId, condition.zoneId, condition.className, threshold);
|
|
8991
|
+
const row = input.confirmed.find((r) => r.key === key);
|
|
8992
|
+
const scope = probe.kind === "probed" ? probe.zoneName ?? condition.zoneId : condition.zoneId;
|
|
8993
|
+
return {
|
|
8994
|
+
kind: "occupancy",
|
|
8995
|
+
deviceId: input.deviceId,
|
|
8996
|
+
...scope !== void 0 ? { scope } : {},
|
|
8997
|
+
...condition.className !== void 0 ? { className: condition.className } : {},
|
|
8998
|
+
threshold,
|
|
8999
|
+
sustainSeconds: condition.sustainSeconds,
|
|
9000
|
+
...probe.kind === "probed" ? {
|
|
9001
|
+
count: probe.count,
|
|
9002
|
+
occupied: probe.subject.occupancy?.occupied === true,
|
|
9003
|
+
observedAt: probe.observedAt
|
|
9004
|
+
} : { unavailable: probe.reason },
|
|
9005
|
+
...row !== void 0 ? { since: row.lastChangeAt } : {},
|
|
9006
|
+
...input.warning !== void 0 ? { warning: input.warning } : {}
|
|
9007
|
+
};
|
|
9008
|
+
}
|
|
9009
|
+
//#endregion
|
|
8627
9010
|
//#region src/notification-center/rule-store.ts
|
|
9011
|
+
/**
|
|
9012
|
+
* NcRuleStore — durable Notification Center rule set.
|
|
9013
|
+
*
|
|
9014
|
+
* Follows the `stationary-registry.ts` precedent: a declared SQLite
|
|
9015
|
+
* collection (via the central `SettingsStoreClient`) mirrored into an
|
|
9016
|
+
* in-memory cache. The cache serves the hot read path (per-persist rule
|
|
9017
|
+
* matching) with zero I/O; every mutation writes through to the store
|
|
9018
|
+
* FIRST and only then updates the cache (a failed persist never leaves a
|
|
9019
|
+
* phantom in-RAM rule).
|
|
9020
|
+
*
|
|
9021
|
+
* Cross-node note: the collection lives in the hub's centralized
|
|
9022
|
+
* settings-store, so a CRUD served on one node is visible to the
|
|
9023
|
+
* evaluating (post-processing) node after its periodic `load()` refresh —
|
|
9024
|
+
* bounded staleness, never wrongness (D8 reconcile-over-events).
|
|
9025
|
+
*/
|
|
8628
9026
|
var NC_RULES_COLLECTION = "notification-center:rules";
|
|
8629
9027
|
var NC_RULES_COLUMNS = [
|
|
8630
9028
|
{
|
|
@@ -8666,17 +9064,75 @@ var NC_RULES_INDEXES = [{
|
|
|
8666
9064
|
name: "idx_nc_rules_enabled",
|
|
8667
9065
|
columns: ["enabled"]
|
|
8668
9066
|
}];
|
|
9067
|
+
/**
|
|
9068
|
+
* Resolve display names to gallery ids inside one rule's identity conditions.
|
|
9069
|
+
*
|
|
9070
|
+
* NON-DESTRUCTIVE and IDEMPOTENT, in that order of importance:
|
|
9071
|
+
*
|
|
9072
|
+
* - a value the map answers for becomes the id;
|
|
9073
|
+
* - a value it does not answer for is LEFT AS IT STANDS, not dropped. It is
|
|
9074
|
+
* either already an id (the second load of a migrated rule, and every load
|
|
9075
|
+
* after that), or a name the gallery no longer holds — and a rule whose
|
|
9076
|
+
* scope silently shrank is exactly the failure this repo keeps paying for.
|
|
9077
|
+
* The engine's name leg keeps such a value matching what it always matched;
|
|
9078
|
+
* the caller reports it once.
|
|
9079
|
+
* - nothing is written back. The migration is a READ projection, so a gallery
|
|
9080
|
+
* that failed to load, or loaded empty, cannot rewrite a rule into one that
|
|
9081
|
+
* matches nobody. The stored rule keeps the operator's words forever.
|
|
9082
|
+
*
|
|
9083
|
+
* Returns the rule unchanged (by identity) when nothing resolved, so `load`
|
|
9084
|
+
* can tell a migrated rule from an untouched one without comparing.
|
|
9085
|
+
*/
|
|
9086
|
+
function migrateIdentityNames(rule, idsByName) {
|
|
9087
|
+
const identities = rule.conditions.identities;
|
|
9088
|
+
const excluded = rule.conditions.identitiesExclude;
|
|
9089
|
+
if (identities === void 0 && excluded === void 0) return {
|
|
9090
|
+
rule,
|
|
9091
|
+
unresolved: []
|
|
9092
|
+
};
|
|
9093
|
+
const unresolved = [];
|
|
9094
|
+
const known = new Set(idsByName.values());
|
|
9095
|
+
const resolve = (values) => values.map((value) => {
|
|
9096
|
+
if (known.has(value)) return value;
|
|
9097
|
+
const id = idsByName.get(value.trim().toLowerCase());
|
|
9098
|
+
if (id !== void 0) return id;
|
|
9099
|
+
unresolved.push(value);
|
|
9100
|
+
return value;
|
|
9101
|
+
});
|
|
9102
|
+
const nextIdentities = identities !== void 0 ? resolve(identities) : void 0;
|
|
9103
|
+
const nextExcluded = excluded !== void 0 ? resolve(excluded) : void 0;
|
|
9104
|
+
if (!(nextIdentities !== void 0 && !sameList(identities ?? [], nextIdentities) || nextExcluded !== void 0 && !sameList(excluded ?? [], nextExcluded))) return {
|
|
9105
|
+
rule,
|
|
9106
|
+
unresolved
|
|
9107
|
+
};
|
|
9108
|
+
return {
|
|
9109
|
+
rule: {
|
|
9110
|
+
...rule,
|
|
9111
|
+
conditions: {
|
|
9112
|
+
...rule.conditions,
|
|
9113
|
+
...nextIdentities !== void 0 ? { identities: nextIdentities } : {},
|
|
9114
|
+
...nextExcluded !== void 0 ? { identitiesExclude: nextExcluded } : {}
|
|
9115
|
+
}
|
|
9116
|
+
},
|
|
9117
|
+
unresolved
|
|
9118
|
+
};
|
|
9119
|
+
}
|
|
9120
|
+
function sameList(a, b) {
|
|
9121
|
+
return a.length === b.length && a.every((v, i) => v === b[i]);
|
|
9122
|
+
}
|
|
8669
9123
|
var NcRuleStore = class {
|
|
8670
9124
|
byId = /* @__PURE__ */ new Map();
|
|
8671
9125
|
store;
|
|
8672
9126
|
logger;
|
|
8673
9127
|
now;
|
|
8674
9128
|
newId;
|
|
9129
|
+
identityIdsByName;
|
|
8675
9130
|
constructor(deps) {
|
|
8676
9131
|
this.store = deps.store;
|
|
8677
9132
|
this.logger = deps.logger;
|
|
8678
9133
|
this.now = deps.now ?? (() => Date.now());
|
|
8679
9134
|
this.newId = deps.newId ?? (() => randomUUID());
|
|
9135
|
+
if (deps.identityIdsByName !== void 0) this.identityIdsByName = deps.identityIdsByName;
|
|
8680
9136
|
}
|
|
8681
9137
|
static async declare(store) {
|
|
8682
9138
|
await store.declareCollection.mutate({
|
|
@@ -8697,24 +9153,48 @@ var NcRuleStore = class {
|
|
|
8697
9153
|
collection: NC_RULES_COLLECTION,
|
|
8698
9154
|
filter: { limit: 1e4 }
|
|
8699
9155
|
});
|
|
9156
|
+
const idsByName = await this.readIdentityIds();
|
|
8700
9157
|
this.byId.clear();
|
|
8701
9158
|
let skipped = 0;
|
|
9159
|
+
let migrated = 0;
|
|
9160
|
+
const unresolved = /* @__PURE__ */ new Set();
|
|
8702
9161
|
for (const row of rows) {
|
|
8703
9162
|
const parsed = NcRuleSchema.safeParse(row.data["rule"]);
|
|
8704
9163
|
if (!parsed.success) {
|
|
8705
9164
|
skipped += 1;
|
|
8706
9165
|
continue;
|
|
8707
9166
|
}
|
|
8708
|
-
|
|
9167
|
+
const result = migrateIdentityNames(parsed.data, idsByName);
|
|
9168
|
+
if (result.rule !== parsed.data) migrated += 1;
|
|
9169
|
+
for (const name of result.unresolved) unresolved.add(name);
|
|
9170
|
+
this.byId.set(result.rule.id, result.rule);
|
|
8709
9171
|
}
|
|
8710
9172
|
this.logger.debug("notification rules loaded", { meta: {
|
|
8711
9173
|
rules: this.byId.size,
|
|
8712
|
-
...skipped > 0 ? { skippedInvalid: skipped } : {}
|
|
9174
|
+
...skipped > 0 ? { skippedInvalid: skipped } : {},
|
|
9175
|
+
...migrated > 0 ? { identitiesResolvedToIds: migrated } : {}
|
|
8713
9176
|
} });
|
|
9177
|
+
if (unresolved.size > 0) this.logger.info("notification rules name identities the gallery does not know", { meta: { names: [...unresolved].join(", ") } });
|
|
8714
9178
|
} catch (err) {
|
|
8715
9179
|
this.logger.warn("notification rules load failed", { meta: { error: String(err) } });
|
|
8716
9180
|
}
|
|
8717
9181
|
}
|
|
9182
|
+
/**
|
|
9183
|
+
* The name→id map, lower-cased, or EMPTY when there is none to be had.
|
|
9184
|
+
*
|
|
9185
|
+
* Empty is the safe answer and the only one: `migrateIdentityNames` resolves
|
|
9186
|
+
* nothing from an empty map and leaves every rule as the operator wrote it.
|
|
9187
|
+
* A gallery read that throws must never be able to change what a rule matches.
|
|
9188
|
+
*/
|
|
9189
|
+
async readIdentityIds() {
|
|
9190
|
+
if (this.identityIdsByName === void 0) return /* @__PURE__ */ new Map();
|
|
9191
|
+
try {
|
|
9192
|
+
return await this.identityIdsByName();
|
|
9193
|
+
} catch (err) {
|
|
9194
|
+
this.logger.debug("identity gallery read failed — rules left unmigrated", { meta: { error: String(err) } });
|
|
9195
|
+
return /* @__PURE__ */ new Map();
|
|
9196
|
+
}
|
|
9197
|
+
}
|
|
8718
9198
|
list() {
|
|
8719
9199
|
return [...this.byId.values()].sort((a, b) => b.updatedAt - a.updatedAt);
|
|
8720
9200
|
}
|
|
@@ -9618,6 +10098,47 @@ function findLastClosedWindow(input) {
|
|
|
9618
10098
|
};
|
|
9619
10099
|
return null;
|
|
9620
10100
|
}
|
|
10101
|
+
/**
|
|
10102
|
+
* The next window that will OPEN after `nowMs`, and when it closes.
|
|
10103
|
+
*
|
|
10104
|
+
* The forward twin of {@link findLastClosedWindow}, and the answer to the only
|
|
10105
|
+
* question an operator has about a timelapse rule that produced nothing yet:
|
|
10106
|
+
* "when is it going to run?". Standing INSIDE a window reports the NEXT one,
|
|
10107
|
+
* not the current one — the current one's boundaries are already answerable
|
|
10108
|
+
* from the last close, and reporting "next: now" would read as a render that is
|
|
10109
|
+
* owed.
|
|
10110
|
+
*
|
|
10111
|
+
* Derived by scanning `isScheduleActive` for the same reason the backwards scan
|
|
10112
|
+
* is: that function is the authority on what a window means (midnight crossing,
|
|
10113
|
+
* `invert`, IANA timezone), and a second implementation of it is drift this
|
|
10114
|
+
* repo has already paid for.
|
|
10115
|
+
*
|
|
10116
|
+
* @returns `null` for an always-active schedule (it never opens — it never
|
|
10117
|
+
* closed either), and for one with no opening inside the lookahead. A window
|
|
10118
|
+
* whose CLOSE falls beyond the horizon is reported with `endMs: null`: the
|
|
10119
|
+
* opening is the fact the operator asked for, and clamping the close to the
|
|
10120
|
+
* scan floor would report a window that ends when the scan gave up.
|
|
10121
|
+
*/
|
|
10122
|
+
function findNextWindowOpen(input) {
|
|
10123
|
+
const step = Math.max(1e3, input.stepMs ?? 6e4);
|
|
10124
|
+
const from = Math.floor(input.nowMs / step) * step;
|
|
10125
|
+
const ceiling = input.nowMs + input.lookaheadMs;
|
|
10126
|
+
const active = (t) => isScheduleActive(input.schedule, t);
|
|
10127
|
+
let startMs = null;
|
|
10128
|
+
for (let t = from; t + step <= ceiling; t += step) if (!active(t) && active(t + step)) {
|
|
10129
|
+
startMs = t + step;
|
|
10130
|
+
break;
|
|
10131
|
+
}
|
|
10132
|
+
if (startMs === null) return null;
|
|
10133
|
+
for (let t = startMs; t + step <= ceiling; t += step) if (!active(t + step)) return {
|
|
10134
|
+
startMs,
|
|
10135
|
+
endMs: t + step
|
|
10136
|
+
};
|
|
10137
|
+
return {
|
|
10138
|
+
startMs,
|
|
10139
|
+
endMs: null
|
|
10140
|
+
};
|
|
10141
|
+
}
|
|
9621
10142
|
/** The canvas behind a tile whose aspect does not fill its cell. */
|
|
9622
10143
|
var MOSAIC_BACKGROUND = {
|
|
9623
10144
|
r: 17,
|
|
@@ -10249,11 +10770,23 @@ var NcSummaryProducer = class {
|
|
|
10249
10770
|
this.tickMs = deps.tickMs ?? 6e4;
|
|
10250
10771
|
this.texts = deps.texts ?? new NcTextCatalog();
|
|
10251
10772
|
}
|
|
10773
|
+
/**
|
|
10774
|
+
* Boot: arm the interval, then fire the back-fill tick WITHOUT waiting on it.
|
|
10775
|
+
*
|
|
10776
|
+
* Same contract as the timelapse producer, and for the same reason: a digest
|
|
10777
|
+
* tick renders a mosaic and publishes it, so awaiting it here would hold
|
|
10778
|
+
* `NotificationCenter.start()` → the addon's `initialize()` → the runner's
|
|
10779
|
+
* capability manifest. `start()` resolving means the producer is running,
|
|
10780
|
+
* never that the back-fill has finished. See `timelapse-scheduler.start()`
|
|
10781
|
+
* for the incident this pins.
|
|
10782
|
+
*/
|
|
10252
10783
|
async start() {
|
|
10253
|
-
await this.tick();
|
|
10254
10784
|
this.timer = setInterval(() => {
|
|
10255
10785
|
this.tick();
|
|
10256
10786
|
}, this.tickMs);
|
|
10787
|
+
this.tick().catch((err) => {
|
|
10788
|
+
this.deps.logger.warn("summary back-fill tick failed", { meta: { error: String(err) } });
|
|
10789
|
+
});
|
|
10257
10790
|
}
|
|
10258
10791
|
stop() {
|
|
10259
10792
|
if (this.timer !== null) {
|
|
@@ -11180,7 +11713,9 @@ var NcTestEventInputSchema = object({
|
|
|
11180
11713
|
"device-event",
|
|
11181
11714
|
"package-event",
|
|
11182
11715
|
"audio-event",
|
|
11183
|
-
"
|
|
11716
|
+
"audio-window",
|
|
11717
|
+
"occupancy-event",
|
|
11718
|
+
"system-event"
|
|
11184
11719
|
]),
|
|
11185
11720
|
/** The CAMERA the event is attributed to (what a notification shows). */
|
|
11186
11721
|
deviceId: number().int(),
|
|
@@ -11236,6 +11771,47 @@ var NcTestEventInputSchema = object({
|
|
|
11236
11771
|
occupied: boolean(),
|
|
11237
11772
|
threshold: number().int().min(1)
|
|
11238
11773
|
}).optional(),
|
|
11774
|
+
/**
|
|
11775
|
+
* AUDIO-WINDOW: the confirmed sampling window.
|
|
11776
|
+
*
|
|
11777
|
+
* Every field of the engine's {@link NcAudioWindowSubject}, because
|
|
11778
|
+
* `matchesAudio` pairs a rule to the window that BELONGS to it — the spec
|
|
11779
|
+
* fields (`samplingSeconds`, `dbThreshold`, `specLabels`) are compared for
|
|
11780
|
+
* equality, so a window built from anything but the rule's own condition
|
|
11781
|
+
* fails closed and the test would report a rule that works as broken.
|
|
11782
|
+
*/
|
|
11783
|
+
audioWindow: object({
|
|
11784
|
+
hitPercent: number().min(0).max(100),
|
|
11785
|
+
samplingSeconds: number().int().min(1).max(300),
|
|
11786
|
+
samples: number().int().min(0),
|
|
11787
|
+
hits: number().int().min(0),
|
|
11788
|
+
dbThreshold: number().optional(),
|
|
11789
|
+
peakDbfs: number().optional(),
|
|
11790
|
+
specLabels: array(string().min(1)).optional(),
|
|
11791
|
+
labels: array(string().min(1)).default([])
|
|
11792
|
+
}).optional(),
|
|
11793
|
+
/**
|
|
11794
|
+
* SYSTEM-EVENT: the normalized infrastructure event.
|
|
11795
|
+
*
|
|
11796
|
+
* The catalog KEYS travel, never the sentence — exactly as the live intake
|
|
11797
|
+
* freezes them (`event-intake.ts`), so a tested system notification is
|
|
11798
|
+
* worded by the same text catalog, in the same language, as the real one. A
|
|
11799
|
+
* tester that froze English prose here would be testing its own prose.
|
|
11800
|
+
*/
|
|
11801
|
+
systemEvent: object({
|
|
11802
|
+
kind: NcSystemEventKindSchema,
|
|
11803
|
+
/** The dedup subject (`device:617`, `node:agent-1`, …). */
|
|
11804
|
+
subject: string().min(1),
|
|
11805
|
+
titleKey: string().min(1),
|
|
11806
|
+
bodyKey: string().min(1),
|
|
11807
|
+
textParams: record(string(), string()).default({}),
|
|
11808
|
+
/** The device the event is ABOUT; absent = an event with no device. */
|
|
11809
|
+
deviceId: number().int().optional(),
|
|
11810
|
+
/** `DeviceType` value — what a `deviceTypes` condition is judged against. */
|
|
11811
|
+
deviceType: string().min(1).optional(),
|
|
11812
|
+
nodeId: string().min(1).optional(),
|
|
11813
|
+
packageName: string().min(1).optional()
|
|
11814
|
+
}).optional(),
|
|
11239
11815
|
/** The instant the event happened. Absent = now. Set it to test a schedule
|
|
11240
11816
|
* condition without waiting for the hour. */
|
|
11241
11817
|
timestamp: number().int().optional(),
|
|
@@ -11323,6 +11899,12 @@ function isSyntheticId(id) {
|
|
|
11323
11899
|
function triggerCanCarryStill(kind) {
|
|
11324
11900
|
return kind === "object-event" || kind === "package-event" || kind === "track-end" || kind === "device-event" || kind === "occupancy-event";
|
|
11325
11901
|
}
|
|
11902
|
+
/** The detection provenance production stamps on a subject of this kind. */
|
|
11903
|
+
function syntheticSource(kind) {
|
|
11904
|
+
if (kind === "device-event") return "sensor";
|
|
11905
|
+
if (kind === "audio-window") return "audio";
|
|
11906
|
+
return "pipeline";
|
|
11907
|
+
}
|
|
11326
11908
|
/**
|
|
11327
11909
|
* Build the synthetic envelope. Pure: same input + same id + same clock ⇒ same
|
|
11328
11910
|
* event, so the red-green test can assert that this subject and a
|
|
@@ -11336,7 +11918,7 @@ function buildSyntheticEvent(input, recordId, now) {
|
|
|
11336
11918
|
subject: {
|
|
11337
11919
|
kind,
|
|
11338
11920
|
recordId,
|
|
11339
|
-
deviceId: input.deviceId,
|
|
11921
|
+
deviceId: kind === "system-event" ? 0 : input.deviceId,
|
|
11340
11922
|
...input.sourceDeviceId !== void 0 && input.sourceDeviceId !== input.deviceId ? { sourceDeviceId: input.sourceDeviceId } : {},
|
|
11341
11923
|
timestamp,
|
|
11342
11924
|
classNames: input.classNames,
|
|
@@ -11344,7 +11926,7 @@ function buildSyntheticEvent(input, recordId, now) {
|
|
|
11344
11926
|
...input.confidence !== void 0 ? { confidence: input.confidence } : {},
|
|
11345
11927
|
zones: input.zones,
|
|
11346
11928
|
...carriesTrackId ? { trackId: recordId } : {},
|
|
11347
|
-
source: input.source ?? (kind
|
|
11929
|
+
source: input.source ?? syntheticSource(kind),
|
|
11348
11930
|
...input.importance !== void 0 ? { importance: input.importance } : {},
|
|
11349
11931
|
...input.dwellSeconds !== void 0 ? { dwellSeconds: input.dwellSeconds } : {},
|
|
11350
11932
|
...input.labelConfidence !== void 0 ? { labelConfidence: input.labelConfidence } : {},
|
|
@@ -11353,6 +11935,28 @@ function buildSyntheticEvent(input, recordId, now) {
|
|
|
11353
11935
|
...input.packagePhase !== void 0 ? { packagePhase: input.packagePhase } : {},
|
|
11354
11936
|
...input.bbox !== void 0 ? { bbox: input.bbox } : {},
|
|
11355
11937
|
...input.crossing !== void 0 ? { crossing: input.crossing } : {},
|
|
11938
|
+
...input.audioWindow !== void 0 ? { audioWindow: {
|
|
11939
|
+
hitPercent: input.audioWindow.hitPercent,
|
|
11940
|
+
samplingSeconds: input.audioWindow.samplingSeconds,
|
|
11941
|
+
samples: input.audioWindow.samples,
|
|
11942
|
+
hits: input.audioWindow.hits,
|
|
11943
|
+
...input.audioWindow.dbThreshold !== void 0 ? { dbThreshold: input.audioWindow.dbThreshold } : {},
|
|
11944
|
+
...input.audioWindow.peakDbfs !== void 0 ? { peakDbfs: input.audioWindow.peakDbfs } : {},
|
|
11945
|
+
...input.audioWindow.specLabels !== void 0 ? { specLabels: input.audioWindow.specLabels } : {},
|
|
11946
|
+
labels: input.audioWindow.labels
|
|
11947
|
+
} } : {},
|
|
11948
|
+
...input.systemEvent !== void 0 ? { systemEvent: {
|
|
11949
|
+
kind: input.systemEvent.kind,
|
|
11950
|
+
subject: input.systemEvent.subject,
|
|
11951
|
+
titleKey: input.systemEvent.titleKey,
|
|
11952
|
+
bodyKey: input.systemEvent.bodyKey,
|
|
11953
|
+
textParams: input.systemEvent.textParams,
|
|
11954
|
+
...input.systemEvent.deviceId !== void 0 ? { deviceId: input.systemEvent.deviceId } : {},
|
|
11955
|
+
...input.systemEvent.deviceType !== void 0 ? { deviceType: input.systemEvent.deviceType } : {},
|
|
11956
|
+
...input.systemEvent.nodeId !== void 0 ? { nodeId: input.systemEvent.nodeId } : {},
|
|
11957
|
+
...input.systemEvent.packageName !== void 0 ? { packageName: input.systemEvent.packageName } : {},
|
|
11958
|
+
data: { category: "synthetic.test" }
|
|
11959
|
+
} } : {},
|
|
11356
11960
|
...input.occupancy !== void 0 ? { occupancy: {
|
|
11357
11961
|
...input.occupancy.zoneId !== void 0 ? { zoneId: input.occupancy.zoneId } : {},
|
|
11358
11962
|
...input.occupancy.zoneName !== void 0 ? { zoneName: input.occupancy.zoneName } : {},
|
|
@@ -12011,6 +12615,84 @@ function planDenseRanges(input) {
|
|
|
12011
12615
|
denseSec
|
|
12012
12616
|
};
|
|
12013
12617
|
}
|
|
12618
|
+
function newGroup(event) {
|
|
12619
|
+
const classCounts = /* @__PURE__ */ new Map();
|
|
12620
|
+
if (event.className !== void 0) classCounts.set(event.className, 1);
|
|
12621
|
+
return {
|
|
12622
|
+
...event.trackId !== void 0 ? { trackId: event.trackId } : {},
|
|
12623
|
+
firstSeen: event.timestamp,
|
|
12624
|
+
lastSeen: event.timestamp,
|
|
12625
|
+
classCounts
|
|
12626
|
+
};
|
|
12627
|
+
}
|
|
12628
|
+
function absorb(group, event) {
|
|
12629
|
+
if (event.timestamp < group.firstSeen) group.firstSeen = event.timestamp;
|
|
12630
|
+
if (event.timestamp > group.lastSeen) group.lastSeen = event.timestamp;
|
|
12631
|
+
if (event.className === void 0) return;
|
|
12632
|
+
group.classCounts.set(event.className, (group.classCounts.get(event.className) ?? 0) + 1);
|
|
12633
|
+
}
|
|
12634
|
+
/** The class most rows agreed on; ties go to whichever appeared first, so the
|
|
12635
|
+
* same window always produces the same sentence. */
|
|
12636
|
+
function dominantClass(group) {
|
|
12637
|
+
let best;
|
|
12638
|
+
let bestCount = 0;
|
|
12639
|
+
for (const [className, count] of group.classCounts) if (count > bestCount) {
|
|
12640
|
+
best = className;
|
|
12641
|
+
bestCount = count;
|
|
12642
|
+
}
|
|
12643
|
+
return best;
|
|
12644
|
+
}
|
|
12645
|
+
function toInterval(group) {
|
|
12646
|
+
const className = dominantClass(group);
|
|
12647
|
+
return {
|
|
12648
|
+
...group.trackId !== void 0 ? { trackId: group.trackId } : {},
|
|
12649
|
+
firstSeen: group.firstSeen,
|
|
12650
|
+
lastSeen: group.lastSeen,
|
|
12651
|
+
...className !== void 0 ? { className } : {}
|
|
12652
|
+
};
|
|
12653
|
+
}
|
|
12654
|
+
/**
|
|
12655
|
+
* Fold persisted tracks and object events into one subject-per-entry array.
|
|
12656
|
+
*
|
|
12657
|
+
* @returns intervals sorted by `firstSeen` ascending. A persisted track is
|
|
12658
|
+
* returned unchanged and its own events are discarded: the store knows when
|
|
12659
|
+
* the track really began, the events only know when the emitter chose to
|
|
12660
|
+
* write one, and widening a persisted interval from event timestamps would
|
|
12661
|
+
* move a range boundary for no operator-visible gain.
|
|
12662
|
+
*/
|
|
12663
|
+
function mergeDetectionIntervals(input) {
|
|
12664
|
+
const gapMs = Math.max(0, input.tracklessGapMs ?? 1e4);
|
|
12665
|
+
const persistedIds = /* @__PURE__ */ new Set();
|
|
12666
|
+
for (const track of input.tracks) if (track.trackId !== void 0) persistedIds.add(track.trackId);
|
|
12667
|
+
const ordered = input.events.toSorted((a, b) => a.timestamp - b.timestamp);
|
|
12668
|
+
const byTrackId = /* @__PURE__ */ new Map();
|
|
12669
|
+
const trackless = [];
|
|
12670
|
+
const openByClass = /* @__PURE__ */ new Map();
|
|
12671
|
+
for (const event of ordered) {
|
|
12672
|
+
const trackId = event.trackId;
|
|
12673
|
+
if (trackId !== void 0) {
|
|
12674
|
+
if (persistedIds.has(trackId)) continue;
|
|
12675
|
+
const existing = byTrackId.get(trackId);
|
|
12676
|
+
if (existing === void 0) byTrackId.set(trackId, newGroup(event));
|
|
12677
|
+
else absorb(existing, event);
|
|
12678
|
+
continue;
|
|
12679
|
+
}
|
|
12680
|
+
const key = event.className ?? "";
|
|
12681
|
+
const open = openByClass.get(key);
|
|
12682
|
+
if (open !== void 0 && event.timestamp - open.lastSeen <= gapMs) {
|
|
12683
|
+
absorb(open, event);
|
|
12684
|
+
continue;
|
|
12685
|
+
}
|
|
12686
|
+
const group = newGroup(event);
|
|
12687
|
+
trackless.push(group);
|
|
12688
|
+
openByClass.set(key, group);
|
|
12689
|
+
}
|
|
12690
|
+
return [
|
|
12691
|
+
...input.tracks,
|
|
12692
|
+
...[...byTrackId.values()].map(toInterval),
|
|
12693
|
+
...trackless.map(toInterval)
|
|
12694
|
+
].toSorted((a, b) => a.firstSeen - b.firstSeen);
|
|
12695
|
+
}
|
|
12014
12696
|
//#endregion
|
|
12015
12697
|
//#region src/notification-center/timelapse/footage-coverage.ts
|
|
12016
12698
|
/**
|
|
@@ -12063,8 +12745,6 @@ function footageCoverage(ranges, windowStartMs, windowEndMs) {
|
|
|
12063
12745
|
complete: windowMs - coveredMs <= COVERAGE_COMPLETE_SLACK_MS
|
|
12064
12746
|
};
|
|
12065
12747
|
}
|
|
12066
|
-
/** How long a scheduled timelapse's file is kept by the export engine. */
|
|
12067
|
-
var TIMELAPSE_EXPORT_LIFE_MS = 7 * 864e5;
|
|
12068
12748
|
var TimelapseRenderError = class extends Error {
|
|
12069
12749
|
reason;
|
|
12070
12750
|
constructor(reason, message) {
|
|
@@ -12092,8 +12772,79 @@ function pickProfile(ranges, window) {
|
|
|
12092
12772
|
}
|
|
12093
12773
|
return best;
|
|
12094
12774
|
}
|
|
12095
|
-
/**
|
|
12096
|
-
|
|
12775
|
+
/**
|
|
12776
|
+
* Is `record` the render this request is about to order?
|
|
12777
|
+
*
|
|
12778
|
+
* The range, the source profile and the BASE cadence must match exactly — those
|
|
12779
|
+
* three decide what the video is. The dense overlay deliberately does NOT: it
|
|
12780
|
+
* is derived from the tracks the window happened to hold, and a retry minutes
|
|
12781
|
+
* later plans it from a slightly longer list. Refusing to adopt over a moved
|
|
12782
|
+
* range boundary would re-order the whole ten hours for a difference nobody can
|
|
12783
|
+
* see, which is precisely the loop this exists to break.
|
|
12784
|
+
*/
|
|
12785
|
+
function coversTheSameRender(record, want) {
|
|
12786
|
+
const timelapse = record.options.timelapse;
|
|
12787
|
+
if (timelapse === void 0) return false;
|
|
12788
|
+
return record.profile === want.profile && record.fromMs === want.fromMs && record.toMs === want.toMs && timelapse.everyMs === want.everyMs && timelapse.outputFps === want.outputFps;
|
|
12789
|
+
}
|
|
12790
|
+
/**
|
|
12791
|
+
* Would adopting `record` LOSE the slowdown this render planned?
|
|
12792
|
+
*
|
|
12793
|
+
* The adoption check deliberately ignores the dense RANGES (a retry plans them
|
|
12794
|
+
* from a slightly longer track list, and re-ordering ten hours over a moved
|
|
12795
|
+
* boundary is the loop adoption exists to break). It must NOT ignore whether a
|
|
12796
|
+
* dense overlay exists at all.
|
|
12797
|
+
*
|
|
12798
|
+
* The failure this closes: attempt one reads the detections while the store is
|
|
12799
|
+
* unreachable, plans nothing, and queues a UNIFORM ten-hour export; every retry
|
|
12800
|
+
* then plans ranges correctly, finds that uniform export alive under the same
|
|
12801
|
+
* (profile, range, base cadence, fps) key, adopts it, and delivers a video that
|
|
12802
|
+
* never slows down — for the whole night, no matter how many retries run. The
|
|
12803
|
+
* notification would even caption it "2 persone". A re-order costs ~20 min of
|
|
12804
|
+
* ffmpeg ONCE; adopting the wrong product costs the night.
|
|
12805
|
+
*
|
|
12806
|
+
* Bounded, and that is why it is safe: the verdict is a function of the plan, so
|
|
12807
|
+
* the export ordered after a refusal carries the dense overlay and the NEXT
|
|
12808
|
+
* retry adopts it. Only a plan that changed direction refuses, never a retry.
|
|
12809
|
+
* A candidate DENSER than what we want is adopted — more slowdown than asked is
|
|
12810
|
+
* not a defect, and refusing it would re-order for nothing.
|
|
12811
|
+
*/
|
|
12812
|
+
function keepsThePlannedSlowdown(record, want) {
|
|
12813
|
+
if (want === void 0) return true;
|
|
12814
|
+
const dense = record.options.timelapse?.dense;
|
|
12815
|
+
if (dense === void 0 || dense.ranges.length === 0) return false;
|
|
12816
|
+
return dense.everyMs <= want.everyMs;
|
|
12817
|
+
}
|
|
12818
|
+
/** Can this row still become — or already be — a file we can fetch? */
|
|
12819
|
+
function isAdoptable(record, nowMs) {
|
|
12820
|
+
if (record.expiresAt <= nowMs) return false;
|
|
12821
|
+
if (record.state === "ready") return record.fileBytes !== null;
|
|
12822
|
+
return record.state === "queued" || record.state === "rendering";
|
|
12823
|
+
}
|
|
12824
|
+
/**
|
|
12825
|
+
* `HH:MM` in the RULE's timezone, for the export title an operator reads.
|
|
12826
|
+
*
|
|
12827
|
+
* The zone is not decoration. The hub container runs on UTC while the operator
|
|
12828
|
+
* and every one of his schedules run on Europe/Rome, so the host-local
|
|
12829
|
+
* derivation this used to do titled a 21:00→07:00 window `19:00→05:00` — two
|
|
12830
|
+
* hours off, on the one string whose whole job is to say which night the file
|
|
12831
|
+
* is. The window boundaries themselves were always correct: `isScheduleActive`
|
|
12832
|
+
* has resolved minutes through `schedule.timezone` from the start, and only the
|
|
12833
|
+
* label lied.
|
|
12834
|
+
*
|
|
12835
|
+
* An absent or unknown IANA name falls back to the host zone, which is exactly
|
|
12836
|
+
* what `localDayMinute` does for the same input — one behaviour for a bad
|
|
12837
|
+
* zone, not two.
|
|
12838
|
+
*/
|
|
12839
|
+
function clockOf$1(atMs, timeZone) {
|
|
12840
|
+
if (timeZone !== void 0) try {
|
|
12841
|
+
return new Intl.DateTimeFormat("en-GB", {
|
|
12842
|
+
timeZone,
|
|
12843
|
+
hour: "2-digit",
|
|
12844
|
+
minute: "2-digit",
|
|
12845
|
+
hourCycle: "h23"
|
|
12846
|
+
}).format(new Date(atMs));
|
|
12847
|
+
} catch {}
|
|
12097
12848
|
const d = new Date(atMs);
|
|
12098
12849
|
return `${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}`;
|
|
12099
12850
|
}
|
|
@@ -12125,12 +12876,12 @@ var TimelapseRenderer = class {
|
|
|
12125
12876
|
});
|
|
12126
12877
|
throw new TimelapseRenderError("no-footage", `no footage for ${deviceId} in ${window.startMs}..${window.endMs}`);
|
|
12127
12878
|
}
|
|
12128
|
-
const tracks = await this.ports.listTracks({
|
|
12879
|
+
const [tracks, events] = await Promise.all([this.ports.listTracks({
|
|
12129
12880
|
deviceId,
|
|
12130
12881
|
since: window.startMs,
|
|
12131
12882
|
until: window.endMs
|
|
12132
12883
|
}).catch((err) => {
|
|
12133
|
-
this.ports.logger.warn("timelapse
|
|
12884
|
+
this.ports.logger.warn("timelapse track read failed — dense plan degraded", {
|
|
12134
12885
|
tags,
|
|
12135
12886
|
meta: {
|
|
12136
12887
|
ruleId: request.ruleId,
|
|
@@ -12138,13 +12889,30 @@ var TimelapseRenderer = class {
|
|
|
12138
12889
|
}
|
|
12139
12890
|
});
|
|
12140
12891
|
return [];
|
|
12892
|
+
}), this.ports.listDetectionEvents({
|
|
12893
|
+
deviceId,
|
|
12894
|
+
since: window.startMs,
|
|
12895
|
+
until: window.endMs
|
|
12896
|
+
}).catch((err) => {
|
|
12897
|
+
this.ports.logger.warn("timelapse detection-event read failed — dense plan degraded", {
|
|
12898
|
+
tags,
|
|
12899
|
+
meta: {
|
|
12900
|
+
ruleId: request.ruleId,
|
|
12901
|
+
error: String(err)
|
|
12902
|
+
}
|
|
12903
|
+
});
|
|
12904
|
+
return [];
|
|
12905
|
+
})]);
|
|
12906
|
+
const subjects = mergeDetectionIntervals({
|
|
12907
|
+
tracks,
|
|
12908
|
+
events
|
|
12141
12909
|
});
|
|
12142
12910
|
const detections = countDetections({
|
|
12143
|
-
tracks,
|
|
12911
|
+
tracks: subjects,
|
|
12144
12912
|
...request.reportClasses !== void 0 ? { reportClasses: request.reportClasses } : {}
|
|
12145
12913
|
});
|
|
12146
12914
|
const plan = planDenseRanges({
|
|
12147
|
-
tracks,
|
|
12915
|
+
tracks: subjects,
|
|
12148
12916
|
windowStartMs: window.startMs,
|
|
12149
12917
|
windowEndMs: window.endMs,
|
|
12150
12918
|
padSec: 5,
|
|
@@ -12177,16 +12945,25 @@ var TimelapseRenderer = class {
|
|
|
12177
12945
|
mergedRanges: plan.mergedCount
|
|
12178
12946
|
}
|
|
12179
12947
|
});
|
|
12180
|
-
const
|
|
12948
|
+
const adopted = await this.findAdoptable({
|
|
12949
|
+
deviceId,
|
|
12950
|
+
profile: chosen.profile,
|
|
12951
|
+
fromMs: window.startMs,
|
|
12952
|
+
toMs: window.endMs
|
|
12953
|
+
}, {
|
|
12954
|
+
everyMs,
|
|
12955
|
+
outputFps: request.framerate
|
|
12956
|
+
}, dense, tags);
|
|
12957
|
+
const queued = adopted ?? await this.ports.createExport({
|
|
12181
12958
|
deviceId,
|
|
12182
12959
|
profile: chosen.profile,
|
|
12183
12960
|
fromMs: window.startMs,
|
|
12184
12961
|
toMs: window.endMs,
|
|
12185
12962
|
options: {
|
|
12186
12963
|
includeAudio: false,
|
|
12187
|
-
maxLifeMs:
|
|
12964
|
+
maxLifeMs: 6048e5,
|
|
12188
12965
|
deleteAfterDownload: false,
|
|
12189
|
-
title: `${request.ruleName} ${clockOf$1(window.startMs)}→${clockOf$1(window.endMs)}`,
|
|
12966
|
+
title: `${request.ruleName} ${clockOf$1(window.startMs, request.timezone)}→${clockOf$1(window.endMs, request.timezone)}`,
|
|
12190
12967
|
timelapse: {
|
|
12191
12968
|
everyMs,
|
|
12192
12969
|
outputFps: request.framerate,
|
|
@@ -12194,7 +12971,7 @@ var TimelapseRenderer = class {
|
|
|
12194
12971
|
}
|
|
12195
12972
|
}
|
|
12196
12973
|
});
|
|
12197
|
-
this.ports.logger.info("timelapse export queued", {
|
|
12974
|
+
this.ports.logger.info(adopted === null ? "timelapse export queued" : "timelapse export adopted", {
|
|
12198
12975
|
tags,
|
|
12199
12976
|
meta: {
|
|
12200
12977
|
ruleId: request.ruleId,
|
|
@@ -12205,7 +12982,11 @@ var TimelapseRenderer = class {
|
|
|
12205
12982
|
denseSource: cadence.source,
|
|
12206
12983
|
denseDwellApplied: cadence.dwellApplied,
|
|
12207
12984
|
denseRanges: plan.ranges.length,
|
|
12208
|
-
|
|
12985
|
+
denseTracks: tracks.length,
|
|
12986
|
+
denseEvents: events.length,
|
|
12987
|
+
denseSubjects: subjects.length,
|
|
12988
|
+
coveragePct: chosen.coverage.percent,
|
|
12989
|
+
...adopted !== null ? { adoptedState: adopted.state } : {}
|
|
12209
12990
|
}
|
|
12210
12991
|
});
|
|
12211
12992
|
const finished = await this.awaitReady(queued, tags);
|
|
@@ -12223,14 +13004,79 @@ var TimelapseRenderer = class {
|
|
|
12223
13004
|
fileBytes: finished.fileBytes
|
|
12224
13005
|
};
|
|
12225
13006
|
}
|
|
12226
|
-
/**
|
|
13007
|
+
/**
|
|
13008
|
+
* The render this request would order, if the engine already owes it.
|
|
13009
|
+
*
|
|
13010
|
+
* Never throws: an adoption lookup that fails costs one duplicate export,
|
|
13011
|
+
* which is strictly better than costing the window. It says so, though — a
|
|
13012
|
+
* branch that silently drops the protection reads as "there was nothing to
|
|
13013
|
+
* adopt".
|
|
13014
|
+
*/
|
|
13015
|
+
async findAdoptable(want, cadence, wantDense, tags) {
|
|
13016
|
+
const rows = await this.ports.listExports({ deviceId: want.deviceId }).catch((err) => {
|
|
13017
|
+
this.ports.logger.warn("timelapse could not check for an export already in flight", {
|
|
13018
|
+
tags,
|
|
13019
|
+
meta: { error: String(err) }
|
|
13020
|
+
});
|
|
13021
|
+
return null;
|
|
13022
|
+
});
|
|
13023
|
+
if (rows === null) return null;
|
|
13024
|
+
const now = this.ports.now();
|
|
13025
|
+
const sameRender = rows.filter((r) => isAdoptable(r, now) && coversTheSameRender(r, {
|
|
13026
|
+
...want,
|
|
13027
|
+
...cadence
|
|
13028
|
+
}));
|
|
13029
|
+
const usable = sameRender.filter((r) => keepsThePlannedSlowdown(r, wantDense));
|
|
13030
|
+
if (usable.length < sameRender.length) this.ports.logger.info("timelapse refused to adopt an export with no dense overlay", {
|
|
13031
|
+
tags,
|
|
13032
|
+
meta: {
|
|
13033
|
+
candidates: sameRender.length,
|
|
13034
|
+
refused: sameRender.length - usable.length,
|
|
13035
|
+
wantDenseEveryMs: wantDense?.everyMs ?? null,
|
|
13036
|
+
wantDenseRanges: wantDense?.ranges.length ?? 0
|
|
13037
|
+
}
|
|
13038
|
+
});
|
|
13039
|
+
const ready = usable.find((r) => r.state === "ready");
|
|
13040
|
+
if (ready !== void 0) return ready;
|
|
13041
|
+
return usable.toSorted((a, b) => a.createdAt - b.createdAt)[0] ?? null;
|
|
13042
|
+
}
|
|
13043
|
+
/**
|
|
13044
|
+
* Poll to a terminal state, under a bound.
|
|
13045
|
+
*
|
|
13046
|
+
* The bound belongs to the RENDER, not to any single poll of it. A
|
|
13047
|
+
* `getExport` that fails on the transport says nothing at all about the
|
|
13048
|
+
* ffmpeg on the recording node — and treating it as fatal is how a finished
|
|
13049
|
+
* 12.5 MB video was abandoned on 2026-08-14: one `UDS request timed out after
|
|
13050
|
+
* 60000ms` at 05:10:47 threw straight out of the wait for an export that
|
|
13051
|
+
* reached `ready` at 05:23:47. So a failed poll is retried until the
|
|
13052
|
+
* deadline, and only a TERMINAL state the engine actually reported ends the
|
|
13053
|
+
* wait early.
|
|
13054
|
+
*/
|
|
12227
13055
|
async awaitReady(queued, tags) {
|
|
12228
13056
|
if (queued.state === "ready") return queued;
|
|
12229
13057
|
const deadline = this.ports.now() + this.timeoutMs;
|
|
12230
13058
|
let current = queued;
|
|
13059
|
+
let pollFailures = 0;
|
|
13060
|
+
let lastPollError = null;
|
|
12231
13061
|
while (this.ports.now() < deadline) {
|
|
12232
13062
|
await this.ports.sleep(this.pollMs);
|
|
12233
|
-
|
|
13063
|
+
const polled = await this.ports.getExport({ exportId: queued.id }).catch((err) => {
|
|
13064
|
+
pollFailures += 1;
|
|
13065
|
+
lastPollError = String(err);
|
|
13066
|
+
const entry = {
|
|
13067
|
+
tags,
|
|
13068
|
+
meta: {
|
|
13069
|
+
exportId: queued.id,
|
|
13070
|
+
pollFailures,
|
|
13071
|
+
error: lastPollError
|
|
13072
|
+
}
|
|
13073
|
+
};
|
|
13074
|
+
if (pollFailures === 1) this.ports.logger.warn("timelapse export poll failed — the render is still owed", entry);
|
|
13075
|
+
else this.ports.logger.debug("timelapse export poll failed again", entry);
|
|
13076
|
+
return null;
|
|
13077
|
+
});
|
|
13078
|
+
if (polled === null) continue;
|
|
13079
|
+
current = polled;
|
|
12234
13080
|
if (current.state === "ready") return current;
|
|
12235
13081
|
if (current.state === "failed" || current.state === "expired" || current.state === "deleted") {
|
|
12236
13082
|
this.ports.logger.warn("timelapse export did not finish", {
|
|
@@ -12249,10 +13095,12 @@ var TimelapseRenderer = class {
|
|
|
12249
13095
|
meta: {
|
|
12250
13096
|
exportId: queued.id,
|
|
12251
13097
|
timeoutMs: this.timeoutMs,
|
|
12252
|
-
lastState: current.state
|
|
13098
|
+
lastState: current.state,
|
|
13099
|
+
pollFailures,
|
|
13100
|
+
...lastPollError !== null ? { lastPollError } : {}
|
|
12253
13101
|
}
|
|
12254
13102
|
});
|
|
12255
|
-
throw new TimelapseRenderError("export-timeout", `export ${queued.id} timed out after ${this.timeoutMs}ms`);
|
|
13103
|
+
throw new TimelapseRenderError("export-timeout", `export ${queued.id} timed out after ${this.timeoutMs}ms` + (pollFailures > 0 ? ` (${pollFailures} poll failures, last: ${lastPollError})` : ""));
|
|
12256
13104
|
}
|
|
12257
13105
|
/**
|
|
12258
13106
|
* Mint the public link, or say why there is none.
|
|
@@ -12461,6 +13309,25 @@ function buildTimelapseOutboxInputs(input) {
|
|
|
12461
13309
|
};
|
|
12462
13310
|
});
|
|
12463
13311
|
}
|
|
13312
|
+
//#endregion
|
|
13313
|
+
//#region src/notification-center/timelapse/timelapse-scheduler.ts
|
|
13314
|
+
/**
|
|
13315
|
+
* How long after a window closes before it is rendered.
|
|
13316
|
+
*
|
|
13317
|
+
* A track is persisted when it CLOSES, and a track that was open at 06:00
|
|
13318
|
+
* lands minutes later. Rendering at the stroke of the boundary would miss
|
|
13319
|
+
* exactly the tracks the dense plan is built from — the ones still moving when
|
|
13320
|
+
* the window ended.
|
|
13321
|
+
*/
|
|
13322
|
+
var TIMELAPSE_SETTLE_MS = 5 * 6e4;
|
|
13323
|
+
/**
|
|
13324
|
+
* How far back a tick may look for a close.
|
|
13325
|
+
*
|
|
13326
|
+
* Eight days, so a hub that was down over a long weekend still finds last
|
|
13327
|
+
* night. The scan short-circuits at the first close it meets, so a rule with a
|
|
13328
|
+
* nightly window costs about a day's worth of minute steps, not eight.
|
|
13329
|
+
*/
|
|
13330
|
+
var TIMELAPSE_LOOKBACK_MS = 8 * 864e5;
|
|
12464
13331
|
/**
|
|
12465
13332
|
* The earliest window CLOSE a (rule, device) pair may ever be offered.
|
|
12466
13333
|
*
|
|
@@ -12490,10 +13357,15 @@ function buildTimelapseOutboxInputs(input) {
|
|
|
12490
13357
|
function timelapseEligibilityFloor(rule, deviceId) {
|
|
12491
13358
|
return Math.max(readTimelapseGeneratedAt(rule, deviceId), rule.createdAt);
|
|
12492
13359
|
}
|
|
13360
|
+
/** The in-flight map's key. One render per (rule, camera) pair by construction. */
|
|
13361
|
+
function renderFlightKey(ruleId, deviceId) {
|
|
13362
|
+
return `${ruleId}:${String(deviceId)}`;
|
|
13363
|
+
}
|
|
12493
13364
|
var TimelapseScheduler = class {
|
|
12494
13365
|
deps;
|
|
12495
13366
|
timer = null;
|
|
12496
13367
|
tickInFlight = false;
|
|
13368
|
+
inFlight = /* @__PURE__ */ new Map();
|
|
12497
13369
|
settleMs;
|
|
12498
13370
|
lookbackMs;
|
|
12499
13371
|
tickMs;
|
|
@@ -12506,16 +13378,32 @@ var TimelapseScheduler = class {
|
|
|
12506
13378
|
this.texts = deps.texts ?? new NcTextCatalog();
|
|
12507
13379
|
}
|
|
12508
13380
|
/**
|
|
12509
|
-
* Boot:
|
|
13381
|
+
* Boot: arm the interval, then fire the back-fill tick WITHOUT waiting on it.
|
|
12510
13382
|
*
|
|
12511
13383
|
* The back-fill needs no special path — it IS a tick. A window that closed
|
|
12512
13384
|
* while the process was down is simply a window whose pair has no stamp.
|
|
13385
|
+
*
|
|
13386
|
+
* `start()` resolving must mean "the producer is running", never "the
|
|
13387
|
+
* back-fill has finished". A back-fill tick renders video: one window can sit
|
|
13388
|
+
* in `awaitReady` for the full `RENDER_TIMEOUT_MS` (30 min), once per (rule,
|
|
13389
|
+
* camera) pair. Awaiting it here held `NotificationCenter.start()`, which held
|
|
13390
|
+
* the addon's `initialize()`, which is what publishes the runner's capability
|
|
13391
|
+
* manifest — so on 2026-08-14 a single 10-hour timelapse window took
|
|
13392
|
+
* `notification-rules`, `videoclips`, `face-gallery`, `plate-gallery`,
|
|
13393
|
+
* `zone-analytics`, `audio-metrics` and `pipeline-analytics` off the cluster
|
|
13394
|
+
* entirely, and the admin UI answered "provider not available".
|
|
13395
|
+
*
|
|
13396
|
+
* `tick()` never throws for a render failure — `produce()` swallows those and
|
|
13397
|
+
* leaves the pair unstamped — so the catch here only covers a store read that
|
|
13398
|
+
* fails, and it must not become an unhandled rejection.
|
|
12513
13399
|
*/
|
|
12514
13400
|
async start() {
|
|
12515
|
-
await this.tick();
|
|
12516
13401
|
this.timer = setInterval(() => {
|
|
12517
13402
|
this.tick();
|
|
12518
13403
|
}, this.tickMs);
|
|
13404
|
+
this.tick().catch((err) => {
|
|
13405
|
+
this.deps.logger.warn("timelapse back-fill tick failed", { meta: { error: String(err) } });
|
|
13406
|
+
});
|
|
12519
13407
|
}
|
|
12520
13408
|
stop() {
|
|
12521
13409
|
if (this.timer !== null) {
|
|
@@ -12624,6 +13512,13 @@ var TimelapseScheduler = class {
|
|
|
12624
13512
|
*/
|
|
12625
13513
|
async produce(rule, deviceId, window, opts) {
|
|
12626
13514
|
const tags = { deviceId };
|
|
13515
|
+
const flightKey = renderFlightKey(rule.id, deviceId);
|
|
13516
|
+
this.inFlight.set(flightKey, {
|
|
13517
|
+
ruleId: rule.id,
|
|
13518
|
+
deviceId,
|
|
13519
|
+
window,
|
|
13520
|
+
startedAt: this.deps.now()
|
|
13521
|
+
});
|
|
12627
13522
|
try {
|
|
12628
13523
|
const deviceName = await this.resolveDeviceName(deviceId);
|
|
12629
13524
|
const outcome = await this.deps.render({
|
|
@@ -12635,6 +13530,7 @@ var TimelapseScheduler = class {
|
|
|
12635
13530
|
framerate: rule.framerate,
|
|
12636
13531
|
...rule.denseCadenceSec !== void 0 ? { denseCadenceSec: rule.denseCadenceSec } : {},
|
|
12637
13532
|
...rule.minDwellSec !== void 0 ? { minDwellSec: rule.minDwellSec } : {},
|
|
13533
|
+
...rule.schedule.timezone !== void 0 ? { timezone: rule.schedule.timezone } : {},
|
|
12638
13534
|
previewText: timelapsePreviewText({
|
|
12639
13535
|
deviceId,
|
|
12640
13536
|
...deviceName !== null ? { deviceName } : {},
|
|
@@ -12707,8 +13603,15 @@ var TimelapseScheduler = class {
|
|
|
12707
13603
|
window,
|
|
12708
13604
|
error: err instanceof Error ? err.message : String(err)
|
|
12709
13605
|
};
|
|
13606
|
+
} finally {
|
|
13607
|
+
this.inFlight.delete(flightKey);
|
|
12710
13608
|
}
|
|
12711
13609
|
}
|
|
13610
|
+
/** Renders this process currently owes, newest last. Read by the rule-status
|
|
13611
|
+
* surface; never a decision input for the scheduler itself. */
|
|
13612
|
+
renderInFlight() {
|
|
13613
|
+
return [...this.inFlight.values()];
|
|
13614
|
+
}
|
|
12712
13615
|
async resolveDeviceName(deviceId) {
|
|
12713
13616
|
const get = this.deps.getDeviceName;
|
|
12714
13617
|
if (get === void 0) return null;
|
|
@@ -13120,6 +14023,14 @@ var NcZoneOwnershipCache = class {
|
|
|
13120
14023
|
/** The one cap whose `trigger` the combined alarm notification joins on. */
|
|
13121
14024
|
var ALARM_PANEL_CAP = alarmPanelCapability.name;
|
|
13122
14025
|
/**
|
|
14026
|
+
* How far ahead a status may scan for the next window opening.
|
|
14027
|
+
*
|
|
14028
|
+
* Nine days rather than "as far as it takes": a weekly schedule opens within
|
|
14029
|
+
* seven, and a schedule that opens less often than that is one whose next run
|
|
14030
|
+
* is not the fact an operator is looking at a card for.
|
|
14031
|
+
*/
|
|
14032
|
+
var TIMELAPSE_LOOKAHEAD_MS = 9 * 864e5;
|
|
14033
|
+
/**
|
|
13123
14034
|
* The synthetic rule id an alarm ANNOUNCEMENT is enqueued under.
|
|
13124
14035
|
*
|
|
13125
14036
|
* The arm announcement is addressed to the panel's own `announceTargets`, not
|
|
@@ -13303,6 +14214,44 @@ function historyRecordKind(kind) {
|
|
|
13303
14214
|
function contactReadingOf(state) {
|
|
13304
14215
|
if (state === "open" || state === "closed") return state;
|
|
13305
14216
|
}
|
|
14217
|
+
/**
|
|
14218
|
+
* The subject kind a persisted record kind maps to.
|
|
14219
|
+
*
|
|
14220
|
+
* `NcOutboxRecordKind` carries two kinds a rule subject never has —
|
|
14221
|
+
* `timelapse` and `ai-summary` are SCHEDULE boundaries, not records. They are
|
|
14222
|
+
* mapped to `device-event`, which is class-agnostic in `cooldownKey`, so their
|
|
14223
|
+
* key is the one the seeder already built for them. A switch rather than a
|
|
14224
|
+
* cast: a kind added to either union has to be answered for here.
|
|
14225
|
+
*/
|
|
14226
|
+
function cooldownSubjectKind(kind) {
|
|
14227
|
+
switch (kind) {
|
|
14228
|
+
case "timelapse":
|
|
14229
|
+
case "ai-summary": return "device-event";
|
|
14230
|
+
default: return kind;
|
|
14231
|
+
}
|
|
14232
|
+
}
|
|
14233
|
+
/**
|
|
14234
|
+
* A persisted outbox row, reduced to exactly what `cooldownKey` reads.
|
|
14235
|
+
*
|
|
14236
|
+
* The point is that it reads NOTHING else: the boot-time cooldown reseed calls
|
|
14237
|
+
* the same builder the live path does, so the two can never address different
|
|
14238
|
+
* windows. Only the four fields the key is a function of are reconstructed —
|
|
14239
|
+
* the class (for `per-class` and audio), the subject kind (which decides
|
|
14240
|
+
* whether audio is keyed per class at all), the system event (subject +
|
|
14241
|
+
* direction) and the device.
|
|
14242
|
+
*/
|
|
14243
|
+
function cooldownSubjectFromOutbox(entry) {
|
|
14244
|
+
const subject = entry.payload.subject;
|
|
14245
|
+
return {
|
|
14246
|
+
kind: cooldownSubjectKind(entry.recordKind),
|
|
14247
|
+
recordId: entry.recordId,
|
|
14248
|
+
deviceId: entry.deviceId,
|
|
14249
|
+
timestamp: subject.timestamp,
|
|
14250
|
+
classNames: subject.className.length > 0 ? [subject.className] : [],
|
|
14251
|
+
zones: [...subject.zones],
|
|
14252
|
+
...subject.systemEvent !== void 0 ? { systemEvent: subject.systemEvent } : {}
|
|
14253
|
+
};
|
|
14254
|
+
}
|
|
13306
14255
|
function historySystemEventKind(kind) {
|
|
13307
14256
|
if (kind === "alarm-arming") return "alarm-armed";
|
|
13308
14257
|
if (kind === "alarm-arm-refused") return "alarm-disarmed";
|
|
@@ -13766,7 +14715,8 @@ var NotificationCenter = class NotificationCenter {
|
|
|
13766
14715
|
this.now = deps.now ?? (() => Date.now());
|
|
13767
14716
|
this.rules = new NcRuleStore({
|
|
13768
14717
|
store: deps.store,
|
|
13769
|
-
logger: this.logger.child("rules")
|
|
14718
|
+
logger: this.logger.child("rules"),
|
|
14719
|
+
...deps.identityIdsByName !== void 0 ? { identityIdsByName: deps.identityIdsByName } : {}
|
|
13770
14720
|
});
|
|
13771
14721
|
this.textStore = new NcTextCatalogStore({
|
|
13772
14722
|
store: deps.store,
|
|
@@ -14140,6 +15090,120 @@ var NotificationCenter = class NotificationCenter {
|
|
|
14140
15090
|
...report.droppedBy !== void 0 ? { droppedBy: report.droppedBy } : {}
|
|
14141
15091
|
};
|
|
14142
15092
|
}
|
|
15093
|
+
/**
|
|
15094
|
+
* The live status of ONE detection rule — see `rule-status.ts`.
|
|
15095
|
+
*
|
|
15096
|
+
* `none` is a real answer, not a failure: most rule kinds have no live state
|
|
15097
|
+
* a card could show, and returning an empty object would read as a read that
|
|
15098
|
+
* failed. Only occupancy has one today, and it is the kind whose entire
|
|
15099
|
+
* subject is a number nobody can see.
|
|
15100
|
+
*/
|
|
15101
|
+
async ruleStatusFor(rule) {
|
|
15102
|
+
const occupancy = rule.conditions.occupancy;
|
|
15103
|
+
if (occupancy === void 0) return {
|
|
15104
|
+
kind: "none",
|
|
15105
|
+
reason: "this rule has no live state to report"
|
|
15106
|
+
};
|
|
15107
|
+
const deviceId = rule.conditions.devices?.[0];
|
|
15108
|
+
const read = this.deps.readOccupancySnapshots;
|
|
15109
|
+
const threshold = occupancyThreshold(occupancy);
|
|
15110
|
+
if (deviceId === void 0 || read === void 0) return buildOccupancyStatus({
|
|
15111
|
+
deviceId: deviceId ?? 0,
|
|
15112
|
+
condition: occupancy,
|
|
15113
|
+
probe: {
|
|
15114
|
+
kind: "unavailable",
|
|
15115
|
+
reason: "no-snapshot"
|
|
15116
|
+
},
|
|
15117
|
+
confirmed: this.occupancyWatcher.snapshotState(),
|
|
15118
|
+
threshold
|
|
15119
|
+
});
|
|
15120
|
+
let snapshot = null;
|
|
15121
|
+
try {
|
|
15122
|
+
snapshot = (await read([deviceId])).get(deviceId) ?? null;
|
|
15123
|
+
} catch (err) {
|
|
15124
|
+
this.logger.warn("rule status could not read the live occupancy snapshot", {
|
|
15125
|
+
tags: { deviceId },
|
|
15126
|
+
meta: {
|
|
15127
|
+
ruleId: rule.id,
|
|
15128
|
+
error: String(err)
|
|
15129
|
+
}
|
|
15130
|
+
});
|
|
15131
|
+
}
|
|
15132
|
+
const probe = probeOccupancyNow({
|
|
15133
|
+
occupancy,
|
|
15134
|
+
deviceId,
|
|
15135
|
+
snapshot,
|
|
15136
|
+
now: this.now()
|
|
15137
|
+
});
|
|
15138
|
+
const warning = occupancyClassWarning(occupancy.className);
|
|
15139
|
+
return buildOccupancyStatus({
|
|
15140
|
+
deviceId,
|
|
15141
|
+
condition: occupancy,
|
|
15142
|
+
probe,
|
|
15143
|
+
confirmed: this.occupancyWatcher.snapshotState(),
|
|
15144
|
+
threshold,
|
|
15145
|
+
...warning !== null ? { warning } : {}
|
|
15146
|
+
});
|
|
15147
|
+
}
|
|
15148
|
+
/**
|
|
15149
|
+
* The live status of one TIMELAPSE rule.
|
|
15150
|
+
*
|
|
15151
|
+
* Three facts, and each answers a question the rule itself cannot: when each
|
|
15152
|
+
* camera last produced (and the artefact it produced, while the shelf still
|
|
15153
|
+
* holds it), whether a render is in flight RIGHT NOW, and when the next
|
|
15154
|
+
* window opens. The window boundaries are scanned through `isScheduleActive`,
|
|
15155
|
+
* the same authority the producer uses — a second reading of a schedule is
|
|
15156
|
+
* how a status starts promising a run that is not coming.
|
|
15157
|
+
*/
|
|
15158
|
+
async timelapseStatusFor(rule, lookup) {
|
|
15159
|
+
const now = this.now();
|
|
15160
|
+
const flights = this.timelapseScheduler?.renderInFlight() ?? [];
|
|
15161
|
+
const lastWindow = findLastClosedWindow({
|
|
15162
|
+
schedule: rule.schedule,
|
|
15163
|
+
nowMs: now,
|
|
15164
|
+
lookbackMs: TIMELAPSE_LOOKBACK_MS
|
|
15165
|
+
});
|
|
15166
|
+
const nextWindow = findNextWindowOpen({
|
|
15167
|
+
schedule: rule.schedule,
|
|
15168
|
+
nowMs: now,
|
|
15169
|
+
lookaheadMs: TIMELAPSE_LOOKAHEAD_MS
|
|
15170
|
+
});
|
|
15171
|
+
const devices = [];
|
|
15172
|
+
for (const deviceId of rule.deviceIds) {
|
|
15173
|
+
const generatedAt = readTimelapseGeneratedAt(rule, deviceId);
|
|
15174
|
+
const flight = flights.find((f) => f.ruleId === rule.id && f.deviceId === deviceId);
|
|
15175
|
+
const artifact = lookup === void 0 ? null : await lookup({
|
|
15176
|
+
ruleId: rule.id,
|
|
15177
|
+
deviceId
|
|
15178
|
+
}).catch((err) => {
|
|
15179
|
+
this.logger.warn("rule status could not read the timelapse artifact index", {
|
|
15180
|
+
tags: { deviceId },
|
|
15181
|
+
meta: {
|
|
15182
|
+
ruleId: rule.id,
|
|
15183
|
+
error: String(err)
|
|
15184
|
+
}
|
|
15185
|
+
});
|
|
15186
|
+
return null;
|
|
15187
|
+
});
|
|
15188
|
+
devices.push({
|
|
15189
|
+
deviceId,
|
|
15190
|
+
...generatedAt > 0 ? { lastGeneratedAt: generatedAt } : {},
|
|
15191
|
+
...artifact !== null ? { lastArtifact: artifact } : {},
|
|
15192
|
+
...flight !== void 0 ? { rendering: {
|
|
15193
|
+
startedAt: flight.startedAt,
|
|
15194
|
+
windowStartMs: flight.window.startMs,
|
|
15195
|
+
windowEndMs: flight.window.endMs
|
|
15196
|
+
} } : {}
|
|
15197
|
+
});
|
|
15198
|
+
}
|
|
15199
|
+
return {
|
|
15200
|
+
kind: "timelapse",
|
|
15201
|
+
devices,
|
|
15202
|
+
...lastWindow !== null ? { lastWindow } : {},
|
|
15203
|
+
...nextWindow !== null ? { nextWindow } : {},
|
|
15204
|
+
settleMs: TIMELAPSE_SETTLE_MS
|
|
15205
|
+
};
|
|
15206
|
+
}
|
|
14143
15207
|
/** One live frame, or null. Never throws into an injection. */
|
|
14144
15208
|
async captureLiveStill(deviceId, deps) {
|
|
14145
15209
|
const capture = deps.getSnapshot;
|
|
@@ -14914,7 +15978,7 @@ var NotificationCenter = class NotificationCenter {
|
|
|
14914
15978
|
return report({ droppedBy: "muted" });
|
|
14915
15979
|
}
|
|
14916
15980
|
const delivery = deliveryForKind(kind);
|
|
14917
|
-
const enabled = this.
|
|
15981
|
+
const enabled = this.rulesForTrigger(delivery, kind, subject);
|
|
14918
15982
|
const candidates = controls.onlyRuleId !== void 0 ? enabled.filter((r) => r.id === controls.onlyRuleId) : enabled;
|
|
14919
15983
|
if (candidates.length === 0) {
|
|
14920
15984
|
this.logger.debug("no enabled rule for this trigger", {
|
|
@@ -14926,6 +15990,7 @@ var NotificationCenter = class NotificationCenter {
|
|
|
14926
15990
|
rulesLoaded: this.rules.list().length
|
|
14927
15991
|
}
|
|
14928
15992
|
});
|
|
15993
|
+
if (kind === "track-end") await this.enqueueRecognitionUpgrades(subject, outcomes);
|
|
14929
15994
|
return report({ droppedBy: "no-candidate-rule" });
|
|
14930
15995
|
}
|
|
14931
15996
|
const now = this.now();
|
|
@@ -15026,6 +16091,7 @@ var NotificationCenter = class NotificationCenter {
|
|
|
15026
16091
|
at: now
|
|
15027
16092
|
});
|
|
15028
16093
|
const inserted = await this.outbox.enqueue(entries);
|
|
16094
|
+
this.noteRecognitionFire(rule, subject, kind, entries, inserted);
|
|
15029
16095
|
if (inserted > 0 && controls.bypassCooldown !== true) this.lastFiredAt.set(key, now);
|
|
15030
16096
|
outcomes.push({
|
|
15031
16097
|
ruleId: rule.id,
|
|
@@ -15039,9 +16105,124 @@ var NotificationCenter = class NotificationCenter {
|
|
|
15039
16105
|
anyMatched = true;
|
|
15040
16106
|
}
|
|
15041
16107
|
if (!anyMatched && rejections.length > 0) this.reportNoMatch(subject, kind, rejections, now);
|
|
16108
|
+
if (kind === "track-end") await this.enqueueRecognitionUpgrades(subject, outcomes);
|
|
15042
16109
|
return report({ candidates: candidates.length });
|
|
15043
16110
|
}
|
|
15044
16111
|
/**
|
|
16112
|
+
* The rules a trigger may fire, which is `listEnabled(delivery)` plus and
|
|
16113
|
+
* minus exactly one thing: `waitForEnhancement`.
|
|
16114
|
+
*
|
|
16115
|
+
* An `immediate` rule that agreed to wait for a name is REMOVED from the
|
|
16116
|
+
* object-event trigger and ADDED to the track close, because the closed track
|
|
16117
|
+
* is the first record that carries the name. Both halves are needed and the
|
|
16118
|
+
* removal is the load-bearing one — leaving it on the object event would fire
|
|
16119
|
+
* the early, nameless notification, and the dedup key (`ruleId:trackId:target`)
|
|
16120
|
+
* would then silently swallow the complete one as a duplicate. The operator
|
|
16121
|
+
* would have set the switch and received exactly what they set it to avoid.
|
|
16122
|
+
*
|
|
16123
|
+
* The drop is REPORTED. A rule vanishing from a trigger reads as a broken rule
|
|
16124
|
+
* from a log, and this repo has paid for that twice.
|
|
16125
|
+
*/
|
|
16126
|
+
rulesForTrigger(delivery, kind, subject) {
|
|
16127
|
+
const enabled = this.rules.listEnabled(delivery);
|
|
16128
|
+
if (kind === "object-event") {
|
|
16129
|
+
const deferred = enabled.filter((r) => ruleDefersToTrackEnd(r));
|
|
16130
|
+
if (deferred.length === 0) return enabled;
|
|
16131
|
+
this.logger.debug("rules deferred to track close — waiting for recognition", {
|
|
16132
|
+
tags: { deviceId: subject.deviceId },
|
|
16133
|
+
meta: {
|
|
16134
|
+
rules: deferred.map((r) => r.name).join(", "),
|
|
16135
|
+
eventId: subject.recordId,
|
|
16136
|
+
...subject.trackId !== void 0 ? { trackId: subject.trackId } : {}
|
|
16137
|
+
}
|
|
16138
|
+
});
|
|
16139
|
+
return enabled.filter((r) => !ruleDefersToTrackEnd(r));
|
|
16140
|
+
}
|
|
16141
|
+
if (kind === "track-end") return [...enabled, ...this.rules.listEnabled("immediate").filter((r) => ruleDefersToTrackEnd(r))];
|
|
16142
|
+
return enabled;
|
|
16143
|
+
}
|
|
16144
|
+
/**
|
|
16145
|
+
* What an `immediate` recognition rule already said about a track, kept only
|
|
16146
|
+
* until that track closes.
|
|
16147
|
+
*
|
|
16148
|
+
* Keyed `ruleIdtrackId`, holding the targets the first push reached and
|
|
16149
|
+
* the LABEL it froze. In RAM and deliberately not durable: losing it costs an
|
|
16150
|
+
* upgrade, which degrades to "the operator got the first notification and not
|
|
16151
|
+
* the second" — the state of the world before this feature. Making it durable
|
|
16152
|
+
* would trade that for a second write on the enqueue path and a table that
|
|
16153
|
+
* outlives the tracks it describes.
|
|
16154
|
+
*/
|
|
16155
|
+
recognitionFires = /* @__PURE__ */ new Map();
|
|
16156
|
+
/** Bound on {@link recognitionFires}. A track that never closes (a crash
|
|
16157
|
+
* between its birth and its close) must not pin a row forever. */
|
|
16158
|
+
static RECOGNITION_FIRE_TTL_MS = 30 * 6e4;
|
|
16159
|
+
recognitionFireKey(ruleId, trackId) {
|
|
16160
|
+
return `${ruleId}${trackId}`;
|
|
16161
|
+
}
|
|
16162
|
+
noteRecognitionFire(rule, subject, kind, entries, inserted) {
|
|
16163
|
+
if (kind !== "object-event" || inserted === 0) return;
|
|
16164
|
+
if (subject.trackId === void 0 || !ruleUpgradesAtTrackEnd(rule)) return;
|
|
16165
|
+
const now = this.now();
|
|
16166
|
+
for (const [key, held] of this.recognitionFires) if (now - held.at > NotificationCenter.RECOGNITION_FIRE_TTL_MS) this.recognitionFires.delete(key);
|
|
16167
|
+
this.recognitionFires.set(this.recognitionFireKey(rule.id, subject.trackId), {
|
|
16168
|
+
targetIds: entries.map((e) => e.targetId),
|
|
16169
|
+
...subject.label !== void 0 ? { label: subject.label } : {},
|
|
16170
|
+
at: now
|
|
16171
|
+
});
|
|
16172
|
+
}
|
|
16173
|
+
/**
|
|
16174
|
+
* Send the "…and it was Gianluca" follow-up for every rule that fired on this
|
|
16175
|
+
* track WITHOUT a name and has one now.
|
|
16176
|
+
*
|
|
16177
|
+
* Four gates, and each one exists because its absence is a notification the
|
|
16178
|
+
* operator did not ask for:
|
|
16179
|
+
*
|
|
16180
|
+
* - the rule fired for this track at all (`recognitionFires`) — an upgrade
|
|
16181
|
+
* with no original is a notification about a rule that never matched;
|
|
16182
|
+
* - the track ended RECOGNISED (`labelKind`) — a person nobody could name
|
|
16183
|
+
* has nothing to add;
|
|
16184
|
+
* - the name is NEW — plates are frequently read on the first frame (0.0 s
|
|
16185
|
+
* observed), and re-sending the same word is the duplicate this whole
|
|
16186
|
+
* feature exists to avoid;
|
|
16187
|
+
* - one row per (rule, track, target), enforced by the outbox on the
|
|
16188
|
+
* `<trackId>:enhanced` dedup ref, which survives a respawn.
|
|
16189
|
+
*
|
|
16190
|
+
* A rule that ALREADY matched this closed track through the ordinary loop
|
|
16191
|
+
* (`outcomes`) is skipped: it just sent a track-end notification carrying the
|
|
16192
|
+
* name, and an upgrade would be that same sentence twice.
|
|
16193
|
+
*/
|
|
16194
|
+
async enqueueRecognitionUpgrades(subject, outcomes) {
|
|
16195
|
+
const trackId = subject.trackId;
|
|
16196
|
+
if (trackId === void 0 || subject.labelKind === void 0) return;
|
|
16197
|
+
const alreadyFired = new Set(outcomes.filter((o) => o.matched).map((o) => o.ruleId));
|
|
16198
|
+
for (const rule of this.rules.listEnabled("immediate")) {
|
|
16199
|
+
if (!ruleUpgradesAtTrackEnd(rule) || alreadyFired.has(rule.id)) continue;
|
|
16200
|
+
const key = this.recognitionFireKey(rule.id, trackId);
|
|
16201
|
+
const held = this.recognitionFires.get(key);
|
|
16202
|
+
if (held === void 0) continue;
|
|
16203
|
+
this.recognitionFires.delete(key);
|
|
16204
|
+
if (held.label === subject.label) continue;
|
|
16205
|
+
const entries = this.buildEntries(rule, subject, "track-end", ["identities"], [], "pipeline", void 0, {
|
|
16206
|
+
dedupRef: `${trackId}:enhanced`,
|
|
16207
|
+
enhancement: true,
|
|
16208
|
+
targetIds: held.targetIds
|
|
16209
|
+
});
|
|
16210
|
+
const inserted = await this.outbox.enqueue(entries);
|
|
16211
|
+
this.logger.info("recognition upgrade — enqueueing a second notification", {
|
|
16212
|
+
tags: { deviceId: subject.deviceId },
|
|
16213
|
+
meta: {
|
|
16214
|
+
ruleId: rule.id,
|
|
16215
|
+
rule: rule.name,
|
|
16216
|
+
trackId,
|
|
16217
|
+
was: held.label ?? "",
|
|
16218
|
+
now: subject.label ?? "",
|
|
16219
|
+
labelKind: subject.labelKind,
|
|
16220
|
+
enqueued: inserted
|
|
16221
|
+
}
|
|
16222
|
+
});
|
|
16223
|
+
}
|
|
16224
|
+
}
|
|
16225
|
+
/**
|
|
15045
16226
|
* Run this rule's `onTrigger` sequences for this match.
|
|
15046
16227
|
*
|
|
15047
16228
|
* Called BEFORE the cooldown gate — see the call site for why. Everything
|
|
@@ -15109,7 +16290,7 @@ var NotificationCenter = class NotificationCenter {
|
|
|
15109
16290
|
}
|
|
15110
16291
|
});
|
|
15111
16292
|
}
|
|
15112
|
-
buildEntries(rule, subject, kind, matchedOn, userTargets = [], origin = "pipeline", cooldown) {
|
|
16293
|
+
buildEntries(rule, subject, kind, matchedOn, userTargets = [], origin = "pipeline", cooldown, upgrade) {
|
|
15113
16294
|
const hasEventMedia = kind === "object-event" || kind === "package-event";
|
|
15114
16295
|
const isTrackScoped = kind === "object-event" || kind === "track-end";
|
|
15115
16296
|
const isSystemEvent = kind === "system-event";
|
|
@@ -15120,11 +16301,13 @@ var NotificationCenter = class NotificationCenter {
|
|
|
15120
16301
|
const renderedZones = notificationZones(rule, subject);
|
|
15121
16302
|
const confirm = frozenConfirmPolicy(rule, isSystemEvent);
|
|
15122
16303
|
const cropZoneIds = rule.conditions.zones !== void 0 ? rule.conditions.zones.ids : rule.conditions.occupancy?.zoneId !== void 0 ? [rule.conditions.occupancy.zoneId] : void 0;
|
|
15123
|
-
|
|
16304
|
+
const allTargets = [...rule.targets, ...userTargets.filter((t) => !direct.has(t.targetId))];
|
|
16305
|
+
return (upgrade !== void 0 ? allTargets.filter((t) => upgrade.targetIds.includes(t.targetId)) : allTargets).filter((target) => !rule.disabledTargetIds.includes(target.targetId)).map((target) => {
|
|
15124
16306
|
const payload = {
|
|
15125
16307
|
ruleName: rule.name,
|
|
15126
16308
|
delivery: rule.delivery,
|
|
15127
16309
|
...origin === "synthetic" ? { synthetic: true } : {},
|
|
16310
|
+
...upgrade !== void 0 ? { enhancement: true } : {},
|
|
15128
16311
|
priority: rule.priority,
|
|
15129
16312
|
...rule.template !== void 0 ? { template: rule.template } : {},
|
|
15130
16313
|
media: noMedia ? "none" : mediaRule.media.attach,
|
|
@@ -15148,6 +16331,7 @@ var NotificationCenter = class NotificationCenter {
|
|
|
15148
16331
|
...alarmSource?.eventId !== void 0 ? { eventId: alarmSource.eventId } : hasEventMedia ? { eventId: subject.recordId } : {},
|
|
15149
16332
|
className: subject.classNames[0] ?? subject.sensorKind ?? "event",
|
|
15150
16333
|
...subject.label !== void 0 ? { label: subject.label } : {},
|
|
16334
|
+
...subject.labelKind !== void 0 ? { labelKind: subject.labelKind } : {},
|
|
15151
16335
|
...subject.confidence !== void 0 ? { confidence: subject.confidence } : {},
|
|
15152
16336
|
zones: renderedZones,
|
|
15153
16337
|
timestamp: subject.timestamp,
|
|
@@ -15162,6 +16346,7 @@ var NotificationCenter = class NotificationCenter {
|
|
|
15162
16346
|
recordKind: kind === "occupancy-event" ? "device-event" : kind,
|
|
15163
16347
|
recordId: subject.recordId,
|
|
15164
16348
|
...isTrackScoped && subject.trackId !== void 0 ? { trackId: subject.trackId } : {},
|
|
16349
|
+
...upgrade !== void 0 ? { dedupRef: upgrade.dedupRef } : {},
|
|
15165
16350
|
payload
|
|
15166
16351
|
};
|
|
15167
16352
|
});
|
|
@@ -15254,13 +16439,26 @@ var NotificationCenter = class NotificationCenter {
|
|
|
15254
16439
|
}
|
|
15255
16440
|
});
|
|
15256
16441
|
}
|
|
15257
|
-
/**
|
|
16442
|
+
/**
|
|
16443
|
+
* Rebuild the cooldown map from persisted outbox rows (restart-proof).
|
|
16444
|
+
*
|
|
16445
|
+
* The key comes from `cooldownKey` and NOWHERE else. This method used to
|
|
16446
|
+
* build the string itself, and the copy had drifted: it knew the
|
|
16447
|
+
* `r:<id>` / `r:<id>:d:<device>` shapes and neither the `:c:<class>` tail
|
|
16448
|
+
* (`granularity: 'per-class'`, and unconditional for audio) nor — after
|
|
16449
|
+
* 2026-08-14 — the `:k:<kind>` one. Every window it could not address was a
|
|
16450
|
+
* window that did not survive a restart, so a per-class or audio rule
|
|
16451
|
+
* re-fired immediately after every respawn despite the schema promising
|
|
16452
|
+
* otherwise. `notifications.md` had carried it as a known defect since
|
|
16453
|
+
* 2026-08-02 with the instruction "if you touch `cooldownKey`, fix the seeder
|
|
16454
|
+
* in the same commit"; this is that commit.
|
|
16455
|
+
*/
|
|
15258
16456
|
async seedCooldowns() {
|
|
15259
16457
|
const entries = await this.outbox.queryRecentPersisted(this.now() - 864e5);
|
|
15260
16458
|
for (const entry of entries) {
|
|
15261
16459
|
const rule = this.rules.get(entry.ruleId);
|
|
15262
16460
|
if (rule === null) continue;
|
|
15263
|
-
const key = rule
|
|
16461
|
+
const key = cooldownKey(rule, cooldownSubjectFromOutbox(entry));
|
|
15264
16462
|
const prev = this.lastFiredAt.get(key) ?? 0;
|
|
15265
16463
|
if (entry.createdAt > prev) this.lastFiredAt.set(key, entry.createdAt);
|
|
15266
16464
|
}
|
|
@@ -15366,7 +16564,12 @@ var NotificationCenter = class NotificationCenter {
|
|
|
15366
16564
|
*/
|
|
15367
16565
|
async hydrateLiveness() {
|
|
15368
16566
|
const subjects = await this.liveness.load();
|
|
15369
|
-
this.
|
|
16567
|
+
const seeded = this.liveness.seedFromDirectory(this.deviceDirectory.all(), this.now());
|
|
16568
|
+
this.logger.info("liveness ledger reseeded", { meta: {
|
|
16569
|
+
subjects,
|
|
16570
|
+
seededFromDirectory: seeded,
|
|
16571
|
+
directory: this.deviceDirectory.size()
|
|
16572
|
+
} });
|
|
15370
16573
|
}
|
|
15371
16574
|
/**
|
|
15372
16575
|
* Say that the ledger refused a liveness event, and why.
|
|
@@ -15788,6 +16991,307 @@ var NotificationCenter = class NotificationCenter {
|
|
|
15788
16991
|
}
|
|
15789
16992
|
};
|
|
15790
16993
|
//#endregion
|
|
16994
|
+
//#region src/notification-center/rule-test-plan.ts
|
|
16995
|
+
/**
|
|
16996
|
+
* Which subject kind THIS rule fires on.
|
|
16997
|
+
*
|
|
16998
|
+
* `delivery` alone cannot answer, because two features ride an existing
|
|
16999
|
+
* delivery and are discriminated by a CONDITION — exactly as the engine
|
|
17000
|
+
* discriminates them (`rule-engine.ts`: an audio window fires only for a rule
|
|
17001
|
+
* declaring `conditions.audio`, an occupancy edge only for one declaring
|
|
17002
|
+
* `conditions.occupancy`). A third case is the LEGACY audio-class rule: an
|
|
17003
|
+
* `immediate` rule whose classes are all `audio-*` and which declares no
|
|
17004
|
+
* `audio` condition fires on a classified episode and on nothing else.
|
|
17005
|
+
*/
|
|
17006
|
+
function testTriggerForRule(rule) {
|
|
17007
|
+
if (rule.delivery === "system-event") return "system-event";
|
|
17008
|
+
if (rule.delivery === "track-end") return "track-end";
|
|
17009
|
+
if (rule.delivery === "package-event") return "package-event";
|
|
17010
|
+
if (rule.delivery === "device-event") return rule.conditions.occupancy !== void 0 ? "occupancy-event" : "device-event";
|
|
17011
|
+
if (rule.conditions.audio !== void 0) return "audio-window";
|
|
17012
|
+
const classes = rule.conditions.classes ?? [];
|
|
17013
|
+
if (classes.length > 0 && classes.every((c) => c.trim().toLowerCase().startsWith("audio-"))) return "audio-event";
|
|
17014
|
+
return "object-event";
|
|
17015
|
+
}
|
|
17016
|
+
/** Reserved device id for an event with no camera (mirrors the engine's). */
|
|
17017
|
+
var NC_SYSTEM_DEVICE_ID = 0;
|
|
17018
|
+
/** The grid a schedule is authored on — the same one `window-bounds.ts` scans. */
|
|
17019
|
+
var SCHEDULE_SCAN_STEP_MS = 6e4;
|
|
17020
|
+
/** How far back a schedule scan may look for an active minute (one week + a day). */
|
|
17021
|
+
var SCHEDULE_SCAN_LOOKBACK_MS = 8 * 864e5;
|
|
17022
|
+
/**
|
|
17023
|
+
* Nudge a threshold to a value that PASSES it, and that reads like something a
|
|
17024
|
+
* detector would produce. Clamped to 1 so a rule with a floor of 1 still gets a
|
|
17025
|
+
* legal value.
|
|
17026
|
+
*/
|
|
17027
|
+
function justAbove(floor) {
|
|
17028
|
+
return Math.min(1, Math.round((floor + .05) * 100) / 100);
|
|
17029
|
+
}
|
|
17030
|
+
/**
|
|
17031
|
+
* A timestamp the rule's SCHEDULE is active at — `now` when it already is.
|
|
17032
|
+
*
|
|
17033
|
+
* Scans BACKWARDS on the schedule's own minute grid through `isScheduleActive`,
|
|
17034
|
+
* the single authority on what a window means (midnight crossing, `invert`,
|
|
17035
|
+
* IANA timezone). Backwards rather than forwards because a test must fire NOW:
|
|
17036
|
+
* a future instant would be a notification about something that has not
|
|
17037
|
+
* happened, and the outbox would deliver it immediately anyway.
|
|
17038
|
+
*
|
|
17039
|
+
* `null` when the schedule has no active minute in the lookback — a rule that
|
|
17040
|
+
* is currently unfireable, which is a note rather than a silent failure.
|
|
17041
|
+
*/
|
|
17042
|
+
function scheduleActiveAt(schedule, now) {
|
|
17043
|
+
if (schedule === void 0) return now;
|
|
17044
|
+
if (isScheduleActive(schedule, now)) return now;
|
|
17045
|
+
const floor = now - SCHEDULE_SCAN_LOOKBACK_MS;
|
|
17046
|
+
for (let t = now - SCHEDULE_SCAN_STEP_MS; t >= floor; t -= SCHEDULE_SCAN_STEP_MS) if (isScheduleActive(schedule, t)) return t;
|
|
17047
|
+
return null;
|
|
17048
|
+
}
|
|
17049
|
+
/**
|
|
17050
|
+
* The bbox that satisfies a `customZones` polygon: the polygon's own bounding
|
|
17051
|
+
* box, clamped to the frame.
|
|
17052
|
+
*
|
|
17053
|
+
* The engine's test is `bboxPolygonOverlap(bbox, points) > 0`, and a polygon's
|
|
17054
|
+
* bounding box always overlaps it (a polygon with area is contained in its own
|
|
17055
|
+
* box). The admin simulator's centred half-frame box does NOT — a polygon drawn
|
|
17056
|
+
* in a corner fails it, and the operator reads a working rule as broken.
|
|
17057
|
+
*/
|
|
17058
|
+
function bboxForPolygon(points) {
|
|
17059
|
+
if (points.length === 0) return void 0;
|
|
17060
|
+
const xs = points.map((p) => p.x);
|
|
17061
|
+
const ys = points.map((p) => p.y);
|
|
17062
|
+
const minX = Math.max(0, Math.min(...xs));
|
|
17063
|
+
const minY = Math.max(0, Math.min(...ys));
|
|
17064
|
+
const maxX = Math.min(1, Math.max(...xs));
|
|
17065
|
+
const maxY = Math.min(1, Math.max(...ys));
|
|
17066
|
+
const w = Math.max(.01, maxX - minX);
|
|
17067
|
+
const h = Math.max(.01, maxY - minY);
|
|
17068
|
+
return {
|
|
17069
|
+
x: minX,
|
|
17070
|
+
y: minY,
|
|
17071
|
+
w: Math.min(1 - minX, w),
|
|
17072
|
+
h: Math.min(1 - minY, h)
|
|
17073
|
+
};
|
|
17074
|
+
}
|
|
17075
|
+
/**
|
|
17076
|
+
* The window that answers an audio condition.
|
|
17077
|
+
*
|
|
17078
|
+
* Every spec field is copied from the CONDITION, because `matchesAudio`
|
|
17079
|
+
* compares them for equality — this is the pairing rule that stops a hub with
|
|
17080
|
+
* two sound rules on one camera answering one rule with the other's evidence.
|
|
17081
|
+
* `labels` are normalized and sorted exactly as `audioSpecFromCondition` does,
|
|
17082
|
+
* or the elementwise comparison fails on ordering alone.
|
|
17083
|
+
*/
|
|
17084
|
+
function audioWindowFor(condition) {
|
|
17085
|
+
const labels = condition.labels !== void 0 && condition.labels.length > 0 ? [...new Set(condition.labels.map(normalizeAudioLabel))].sort() : void 0;
|
|
17086
|
+
const samples = Math.max(2, condition.samplingSeconds);
|
|
17087
|
+
const hits = Math.max(1, Math.ceil(samples * condition.hitPercent / 100));
|
|
17088
|
+
return {
|
|
17089
|
+
hitPercent: Math.min(100, Math.round(hits / samples * 100)),
|
|
17090
|
+
samplingSeconds: condition.samplingSeconds,
|
|
17091
|
+
samples,
|
|
17092
|
+
hits,
|
|
17093
|
+
...condition.dbThreshold !== void 0 ? {
|
|
17094
|
+
dbThreshold: condition.dbThreshold,
|
|
17095
|
+
peakDbfs: Math.min(0, condition.dbThreshold + 6)
|
|
17096
|
+
} : {},
|
|
17097
|
+
...labels !== void 0 ? { specLabels: labels } : {},
|
|
17098
|
+
labels: labels ?? []
|
|
17099
|
+
};
|
|
17100
|
+
}
|
|
17101
|
+
/**
|
|
17102
|
+
* The edge that satisfies an occupancy condition.
|
|
17103
|
+
*
|
|
17104
|
+
* Each op has exactly ONE edge that passes it (see `matchesOccupancy`): the
|
|
17105
|
+
* count arrives AT the threshold from below, falls to zero from it, or falls TO
|
|
17106
|
+
* the threshold from one above.
|
|
17107
|
+
*/
|
|
17108
|
+
function occupancyEdgeFor(condition) {
|
|
17109
|
+
const threshold = Math.max(1, Math.round(condition.count));
|
|
17110
|
+
const shared = {
|
|
17111
|
+
...condition.zoneId !== void 0 ? { zoneId: condition.zoneId } : {},
|
|
17112
|
+
...condition.className !== void 0 ? { className: condition.className } : {}
|
|
17113
|
+
};
|
|
17114
|
+
if (condition.op === "became-free") return {
|
|
17115
|
+
...shared,
|
|
17116
|
+
count: 0,
|
|
17117
|
+
previousCount: threshold,
|
|
17118
|
+
occupied: false,
|
|
17119
|
+
threshold
|
|
17120
|
+
};
|
|
17121
|
+
if (condition.op === "<=") {
|
|
17122
|
+
const count = Math.max(0, Math.round(condition.count));
|
|
17123
|
+
return {
|
|
17124
|
+
...shared,
|
|
17125
|
+
count,
|
|
17126
|
+
previousCount: count + 1,
|
|
17127
|
+
occupied: false,
|
|
17128
|
+
threshold: count + 1
|
|
17129
|
+
};
|
|
17130
|
+
}
|
|
17131
|
+
return {
|
|
17132
|
+
...shared,
|
|
17133
|
+
count: threshold,
|
|
17134
|
+
previousCount: Math.max(0, threshold - 1),
|
|
17135
|
+
occupied: true,
|
|
17136
|
+
threshold
|
|
17137
|
+
};
|
|
17138
|
+
}
|
|
17139
|
+
/**
|
|
17140
|
+
* The catalog keys a synthetic system event of this kind carries.
|
|
17141
|
+
*
|
|
17142
|
+
* MIRRORS `incomingFromSystemEvent`'s key derivation, and the mirror is pinned
|
|
17143
|
+
* by a spec that asserts every key exists in the shipped English bundle — a key
|
|
17144
|
+
* nothing resolves renders an EMPTY notification, which is the one failure a
|
|
17145
|
+
* tester must not be able to produce. The BODY VARIANTS the intake chooses from
|
|
17146
|
+
* the payload (`.profiled`, `.agent`, `.onNode`) are deliberately not chosen
|
|
17147
|
+
* here: the plain variant is the one every kind has.
|
|
17148
|
+
*/
|
|
17149
|
+
function systemEventKeys(kind) {
|
|
17150
|
+
if (kind === "node-online" || kind === "node-offline") return {
|
|
17151
|
+
titleKey: `system.${kind}.title`,
|
|
17152
|
+
bodyKey: `system.${kind}.body.agent`
|
|
17153
|
+
};
|
|
17154
|
+
if (kind === "alarm-armed" || kind === "alarm-arming" || kind === "alarm-arm-refused") return {
|
|
17155
|
+
titleKey: `system.${kind}.title.${TEST_ALARM_MODE}`,
|
|
17156
|
+
bodyKey: `system.${kind}.body`
|
|
17157
|
+
};
|
|
17158
|
+
return {
|
|
17159
|
+
titleKey: `system.${kind}.title`,
|
|
17160
|
+
bodyKey: `system.${kind}.body`
|
|
17161
|
+
};
|
|
17162
|
+
}
|
|
17163
|
+
/** The arm mode a synthetic alarm event stands in for — see {@link systemEventKeys}. */
|
|
17164
|
+
var TEST_ALARM_MODE = "away";
|
|
17165
|
+
/** The dedup subject the live intake stamps for this kind — same families. */
|
|
17166
|
+
function systemEventSubject(kind, scope) {
|
|
17167
|
+
if (kind === "device-disabled" || kind === "device-enabled") return `device-switch:${scope.deviceId ?? 0}`;
|
|
17168
|
+
if (kind === "device-online" || kind === "device-offline") return `device:${scope.deviceId ?? 0}`;
|
|
17169
|
+
if (kind === "stream-online" || kind === "stream-offline") return `stream:${scope.deviceId ?? 0}`;
|
|
17170
|
+
if (kind === "node-online" || kind === "node-offline") return `node:${scope.nodeId ?? "hub"}`;
|
|
17171
|
+
if (kind === "addon-update-available" || kind === "server-update-available") return `package:${scope.packageName ?? "@camstack/server"}`;
|
|
17172
|
+
return `alarm:${scope.deviceId ?? 0}`;
|
|
17173
|
+
}
|
|
17174
|
+
/**
|
|
17175
|
+
* The class the subject carries, chosen so it PASSES the rule's class
|
|
17176
|
+
* conditions rather than merely existing.
|
|
17177
|
+
*
|
|
17178
|
+
* A macro selection (`vehicle`) is written verbatim: the engine expands macro →
|
|
17179
|
+
* leaves, so the macro itself is always in its own expansion. The exclude list
|
|
17180
|
+
* is then honoured — a class that is both wanted and vetoed is a rule that can
|
|
17181
|
+
* never fire, and the plan must not paper over it.
|
|
17182
|
+
*/
|
|
17183
|
+
function classNamesFor(conditions) {
|
|
17184
|
+
const excluded = new Set((conditions.classesExclude ?? []).map((c) => c.trim().toLowerCase()));
|
|
17185
|
+
const first = (conditions.classes ?? []).filter((c) => !excluded.has(c.trim().toLowerCase()))[0] ?? conditions.classes?.[0];
|
|
17186
|
+
if (first !== void 0) return [first];
|
|
17187
|
+
return excluded.has("person") ? [] : ["person"];
|
|
17188
|
+
}
|
|
17189
|
+
/** The label that satisfies the rule's identity / plate / label matchers. */
|
|
17190
|
+
function labelFor(conditions) {
|
|
17191
|
+
return conditions.identities?.[0] ?? conditions.labelEquals?.[0] ?? conditions.plates?.values?.[0] ?? void 0;
|
|
17192
|
+
}
|
|
17193
|
+
/**
|
|
17194
|
+
* Plan the injection that fires this rule.
|
|
17195
|
+
*
|
|
17196
|
+
* Every condition the engine reads off the SUBJECT is satisfied here; the two
|
|
17197
|
+
* that read the world are reported in {@link NcRuleTestPlan.notes}.
|
|
17198
|
+
*/
|
|
17199
|
+
function planRuleTest(input) {
|
|
17200
|
+
const { rule, now } = input;
|
|
17201
|
+
const c = rule.conditions;
|
|
17202
|
+
const trigger = testTriggerForRule(rule);
|
|
17203
|
+
const notes = [];
|
|
17204
|
+
const systemScope = {
|
|
17205
|
+
...c.systemEvent?.deviceIds?.[0] !== void 0 ? { deviceId: c.systemEvent.deviceIds[0] } : {},
|
|
17206
|
+
...c.systemEvent?.nodeIds?.[0] !== void 0 ? { nodeId: c.systemEvent.nodeIds[0] } : {},
|
|
17207
|
+
...c.systemEvent?.packageNames?.[0] !== void 0 ? { packageName: c.systemEvent.packageNames[0] } : {}
|
|
17208
|
+
};
|
|
17209
|
+
const scopedDeviceId = trigger === "system-event" ? systemScope.deviceId ?? input.fallbackDeviceId ?? NC_SYSTEM_DEVICE_ID : c.devices?.[0] ?? input.fallbackDeviceId;
|
|
17210
|
+
if (scopedDeviceId === void 0) notes.push({
|
|
17211
|
+
conditionId: "devices",
|
|
17212
|
+
note: "This rule names no camera and none was supplied, so the event is attributed to no device and carries no picture. Scope the rule to a camera, or pick one, to see the notification it would really send."
|
|
17213
|
+
});
|
|
17214
|
+
const deviceId = scopedDeviceId ?? NC_SYSTEM_DEVICE_ID;
|
|
17215
|
+
const scheduledAt = scheduleActiveAt(rule.schedule, now);
|
|
17216
|
+
if (scheduledAt === null) notes.push({
|
|
17217
|
+
conditionId: "schedule",
|
|
17218
|
+
note: "This rule’s weekly schedule has no active minute in the last eight days, so the test fires outside it and the schedule condition will refuse the event — exactly as it refuses every real one."
|
|
17219
|
+
});
|
|
17220
|
+
else if (scheduledAt !== now) notes.push({
|
|
17221
|
+
conditionId: "schedule",
|
|
17222
|
+
note: "The rule is outside its schedule right now, so the event is stamped with the most recent instant inside a window. The notification therefore reports a past time."
|
|
17223
|
+
});
|
|
17224
|
+
if (c.deviceState !== void 0) notes.push({
|
|
17225
|
+
conditionId: "deviceState",
|
|
17226
|
+
note: `This rule only fires while device #${c.deviceState.deviceId} is in one of its chosen states. That gate reads the REAL device and cannot be simulated — put the device in that state first, or the test fails closed exactly as production would.`
|
|
17227
|
+
});
|
|
17228
|
+
if (c.customZones !== void 0 && c.customZones.length > 0) notes.push({
|
|
17229
|
+
conditionId: "customZones",
|
|
17230
|
+
note: "The subject is placed over the first drawn polygon so the condition passes. A rule that needs a specific polygon should be checked in the simulator, where the box can be moved."
|
|
17231
|
+
});
|
|
17232
|
+
const zones = c.zones?.ids ?? [];
|
|
17233
|
+
const label = labelFor(c);
|
|
17234
|
+
const bbox = c.customZones !== void 0 && c.customZones.length > 0 ? bboxForPolygon(c.customZones[0]?.points ?? []) : void 0;
|
|
17235
|
+
const audioWindow = trigger === "audio-window" && c.audio !== void 0 ? audioWindowFor(c.audio) : void 0;
|
|
17236
|
+
const classNames = trigger === "audio-window" ? (audioWindow?.labels ?? []).map((l) => `audio-${l}`) : trigger === "occupancy-event" ? [c.occupancy?.className ?? "occupancy"] : trigger === "device-event" || trigger === "system-event" ? [] : classNamesFor(c);
|
|
17237
|
+
const systemEventKind = c.systemEvent?.kinds[0];
|
|
17238
|
+
if (trigger === "system-event" && systemEventKind === void 0) notes.push({
|
|
17239
|
+
conditionId: "systemEvent",
|
|
17240
|
+
note: "This rule names no system event, so nothing can be injected for it — the hub refuses such a rule at save time and the engine matches nothing."
|
|
17241
|
+
});
|
|
17242
|
+
return {
|
|
17243
|
+
trigger,
|
|
17244
|
+
input: {
|
|
17245
|
+
trigger,
|
|
17246
|
+
deviceId,
|
|
17247
|
+
classNames: [...classNames],
|
|
17248
|
+
...label !== void 0 ? { label } : {},
|
|
17249
|
+
...c.minConfidence !== void 0 ? { confidence: justAbove(c.minConfidence) } : {},
|
|
17250
|
+
...c.minLabelConfidence !== void 0 ? { labelConfidence: justAbove(c.minLabelConfidence) } : {},
|
|
17251
|
+
...c.minImportance !== void 0 ? { importance: justAbove(c.minImportance) } : {},
|
|
17252
|
+
...c.minDwellSeconds !== void 0 ? { dwellSeconds: Math.round(c.minDwellSeconds) + 1 } : {},
|
|
17253
|
+
zones: [...zones],
|
|
17254
|
+
...trigger === "object-event" && c.crossing === "exit" ? { crossing: {
|
|
17255
|
+
zoneId: zones[0] ?? "zone",
|
|
17256
|
+
direction: "exit"
|
|
17257
|
+
} } : {},
|
|
17258
|
+
...c.sensorKinds?.[0] !== void 0 ? { sensorKind: c.sensorKinds[0] } : {},
|
|
17259
|
+
...c.eventTypeTokens?.[0] !== void 0 ? { eventType: c.eventTypeTokens[0] } : {},
|
|
17260
|
+
...trigger === "package-event" ? { packagePhase: c.packagePhase === "picked-up" ? "picked-up" : "delivered" } : {},
|
|
17261
|
+
...c.source !== void 0 && c.source !== "any" ? { source: c.source } : {},
|
|
17262
|
+
...bbox !== void 0 ? { bbox } : {},
|
|
17263
|
+
...trigger === "occupancy-event" && c.occupancy !== void 0 ? { occupancy: occupancyEdgeFor(c.occupancy) } : {},
|
|
17264
|
+
...audioWindow !== void 0 ? { audioWindow } : {},
|
|
17265
|
+
...trigger === "system-event" && systemEventKind !== void 0 ? { systemEvent: {
|
|
17266
|
+
kind: systemEventKind,
|
|
17267
|
+
subject: systemEventSubject(systemEventKind, systemScope),
|
|
17268
|
+
...systemEventKeys(systemEventKind),
|
|
17269
|
+
textParams: {
|
|
17270
|
+
deviceId: String(systemScope.deviceId ?? deviceId),
|
|
17271
|
+
deviceName: `camera ${String(systemScope.deviceId ?? deviceId)}`,
|
|
17272
|
+
nodeId: systemScope.nodeId ?? "hub",
|
|
17273
|
+
packageName: systemScope.packageName ?? "@camstack/server",
|
|
17274
|
+
currentVersion: "0.0.0",
|
|
17275
|
+
latestVersion: "0.0.0",
|
|
17276
|
+
profile: "low",
|
|
17277
|
+
seconds: "30"
|
|
17278
|
+
},
|
|
17279
|
+
...systemScope.deviceId !== void 0 ? { deviceId: systemScope.deviceId } : {},
|
|
17280
|
+
...c.systemEvent?.deviceTypes?.[0] !== void 0 ? { deviceType: c.systemEvent.deviceTypes[0] } : {},
|
|
17281
|
+
...systemScope.nodeId !== void 0 ? { nodeId: systemScope.nodeId } : {},
|
|
17282
|
+
...systemScope.packageName !== void 0 ? { packageName: systemScope.packageName } : {}
|
|
17283
|
+
} } : {},
|
|
17284
|
+
timestamp: scheduledAt ?? now,
|
|
17285
|
+
onlyRuleId: rule.id,
|
|
17286
|
+
bypassCooldown: true,
|
|
17287
|
+
bypassMute: true,
|
|
17288
|
+
runActions: false,
|
|
17289
|
+
still: "live"
|
|
17290
|
+
},
|
|
17291
|
+
notes
|
|
17292
|
+
};
|
|
17293
|
+
}
|
|
17294
|
+
//#endregion
|
|
15791
17295
|
//#region src/notification-center/nc-actions.ts
|
|
15792
17296
|
/**
|
|
15793
17297
|
* nc-actions — the `pipeline-analytics` rule-bridge action catalog.
|
|
@@ -15887,6 +17391,58 @@ var ncActions = defineCustomActions({
|
|
|
15887
17391
|
auth: "admin",
|
|
15888
17392
|
caller: "required"
|
|
15889
17393
|
}),
|
|
17394
|
+
/**
|
|
17395
|
+
* FIRE THIS RULE NOW — the per-rule Test button.
|
|
17396
|
+
*
|
|
17397
|
+
* `nc.injectTestEvent` asks the operator to describe an event; this asks the
|
|
17398
|
+
* RULE. The server plans the synthetic subject from the rule's own conditions
|
|
17399
|
+
* (`rule-test-plan.ts`) and hands it to exactly the same injection, so the
|
|
17400
|
+
* notification travels the whole real path: `evaluateRule` → durable outbox →
|
|
17401
|
+
* the ordinary drain → the ordinary media ladders → the notifier. It arrives
|
|
17402
|
+
* on the phone, which is the entire point.
|
|
17403
|
+
*
|
|
17404
|
+
* It BYPASSES the cooldown and the per-camera mute, deliberately: the
|
|
17405
|
+
* operator pressed a button and must receive the notification, not a report
|
|
17406
|
+
* that a window from ten minutes ago swallowed it. Neither window is
|
|
17407
|
+
* CONSUMED, so the next real fire is unaffected. It never runs the rule's
|
|
17408
|
+
* action sequences — those unlock doors, and "does this notify" is not a
|
|
17409
|
+
* request to open a gate.
|
|
17410
|
+
*
|
|
17411
|
+
* `auth: 'admin'` for the same reason as the injection it wraps: it delivers
|
|
17412
|
+
* to every target the rule addresses, on other people's phones.
|
|
17413
|
+
*/
|
|
17414
|
+
"nc.testRule": customAction(object({
|
|
17415
|
+
ruleId: string(),
|
|
17416
|
+
/** The camera to attribute the event to when the rule scopes none. */
|
|
17417
|
+
deviceId: number().int().optional()
|
|
17418
|
+
}), NcTestEventResultSchema.extend({
|
|
17419
|
+
/** The subject kind the rule was tested with — the answer to "why was my
|
|
17420
|
+
* rule not a candidate". */
|
|
17421
|
+
trigger: string(),
|
|
17422
|
+
/** What the plan could not reproduce (a device-state gate, a polygon). */
|
|
17423
|
+
planNotes: array(object({
|
|
17424
|
+
conditionId: string(),
|
|
17425
|
+
note: string()
|
|
17426
|
+
}))
|
|
17427
|
+
}), {
|
|
17428
|
+
kind: "mutation",
|
|
17429
|
+
auth: "admin",
|
|
17430
|
+
caller: "required"
|
|
17431
|
+
}),
|
|
17432
|
+
/**
|
|
17433
|
+
* What this rule is doing RIGHT NOW — a discriminated union per rule kind.
|
|
17434
|
+
*
|
|
17435
|
+
* Answers the question a rule LIST structurally cannot: an occupancy rule's
|
|
17436
|
+
* subject is a live count, and a timelapse rule's output arrives hours after
|
|
17437
|
+
* the window it is about. Everything returned is DERIVED from the watcher's
|
|
17438
|
+
* mirror, the live snapshot and the stores — nothing is a second copy of
|
|
17439
|
+
* state that could disagree with them.
|
|
17440
|
+
*
|
|
17441
|
+
* `caller: 'required'` and not admin-only: a personal rule's owner may read
|
|
17442
|
+
* the status of their own rule, and the handler refuses a rule they cannot
|
|
17443
|
+
* see rather than returning a redacted one.
|
|
17444
|
+
*/
|
|
17445
|
+
"nc.getRuleStatus": customAction(object({ ruleId: string() }), NcRuleStatusSchema, { caller: "required" }),
|
|
15890
17446
|
"nc.listTimelapseRules": customAction(object({}), object({ rules: array(NcViewerTimelapseRuleSchema) }), { caller: "required" }),
|
|
15891
17447
|
/**
|
|
15892
17448
|
* ONE action for create AND update, unlike the detection-rule pair.
|
|
@@ -16220,6 +17776,54 @@ function makeNcActionHandlers(deps) {
|
|
|
16220
17776
|
});
|
|
16221
17777
|
return inject(parsed);
|
|
16222
17778
|
},
|
|
17779
|
+
"nc.testRule": async (input, caller) => {
|
|
17780
|
+
const c = requireCaller(caller);
|
|
17781
|
+
if (!c.isAdmin) throw new Error("forbidden: admin required to test a rule");
|
|
17782
|
+
const inject = deps.injectTestEvent;
|
|
17783
|
+
if (inject === void 0) throw new Error("this node does not host the notification centre — test on the hub");
|
|
17784
|
+
const rule = assertRuleVisible(input.ruleId, c);
|
|
17785
|
+
const plan = planRuleTest({
|
|
17786
|
+
rule,
|
|
17787
|
+
now: Date.now(),
|
|
17788
|
+
...input.deviceId !== void 0 ? { fallbackDeviceId: input.deviceId } : {}
|
|
17789
|
+
});
|
|
17790
|
+
deps.logger.info("rule test requested", {
|
|
17791
|
+
tags: { deviceId: plan.input.deviceId },
|
|
17792
|
+
meta: {
|
|
17793
|
+
by: c.userId,
|
|
17794
|
+
ruleId: rule.id,
|
|
17795
|
+
ruleName: rule.name,
|
|
17796
|
+
trigger: plan.trigger,
|
|
17797
|
+
bypassCooldown: true,
|
|
17798
|
+
bypassMute: true,
|
|
17799
|
+
planNotes: plan.notes.map((n) => n.conditionId)
|
|
17800
|
+
}
|
|
17801
|
+
});
|
|
17802
|
+
return {
|
|
17803
|
+
...await inject(plan.input),
|
|
17804
|
+
trigger: plan.trigger,
|
|
17805
|
+
planNotes: plan.notes.map((n) => ({
|
|
17806
|
+
conditionId: n.conditionId,
|
|
17807
|
+
note: n.note
|
|
17808
|
+
}))
|
|
17809
|
+
};
|
|
17810
|
+
},
|
|
17811
|
+
"nc.getRuleStatus": async (input, caller) => {
|
|
17812
|
+
const c = requireCaller(caller);
|
|
17813
|
+
const status = deps.ruleStatus;
|
|
17814
|
+
if (status === void 0) throw new Error("this node does not host the notification centre — ask the hub");
|
|
17815
|
+
const detection = deps.ruleStore.get(input.ruleId);
|
|
17816
|
+
if (detection !== null) {
|
|
17817
|
+
assertRuleVisible(input.ruleId, c);
|
|
17818
|
+
return status.forRule(detection);
|
|
17819
|
+
}
|
|
17820
|
+
const timelapse = deps.timelapse?.store.get(input.ruleId) ?? null;
|
|
17821
|
+
if (timelapse !== null) {
|
|
17822
|
+
if (!c.isAdmin && timelapse.ownerUserId !== c.userId) throw new Error(`forbidden: timelapse rule not owned: ${input.ruleId}`);
|
|
17823
|
+
return status.forTimelapseRule(timelapse);
|
|
17824
|
+
}
|
|
17825
|
+
throw new Error(`rule not found: ${input.ruleId}`);
|
|
17826
|
+
},
|
|
16223
17827
|
"nc.listTimelapseRules": async (_input, caller) => {
|
|
16224
17828
|
const c = requireCaller(caller);
|
|
16225
17829
|
const tl = requireTimelapse();
|
|
@@ -18250,9 +19854,14 @@ async function recomputeTrackImportance(deps, trackId) {
|
|
|
18250
19854
|
*/
|
|
18251
19855
|
async function applyTrackLabelCandidate(deps, deviceId, trackId, candidate) {
|
|
18252
19856
|
let trackWritten = false;
|
|
19857
|
+
/** The patch the TRACK accepted — the ONE decision the events copy. */
|
|
19858
|
+
let accepted;
|
|
18253
19859
|
try {
|
|
18254
19860
|
const decision = await deps.trackStore?.applyLabelCandidate(trackId, candidate);
|
|
18255
|
-
if (decision?.kind === "write")
|
|
19861
|
+
if (decision?.kind === "write") {
|
|
19862
|
+
trackWritten = true;
|
|
19863
|
+
accepted = decision.patch;
|
|
19864
|
+
}
|
|
18256
19865
|
if (decision?.kind === "refused") {
|
|
18257
19866
|
const message = decision.reason.kind === "macro-class" ? "label tier collapse refused" : "label write refused";
|
|
18258
19867
|
deps.logger.info(message, {
|
|
@@ -18274,7 +19883,7 @@ async function applyTrackLabelCandidate(deps, deviceId, trackId, candidate) {
|
|
|
18274
19883
|
}
|
|
18275
19884
|
let eventsUpdated = 0;
|
|
18276
19885
|
try {
|
|
18277
|
-
eventsUpdated = await deps.eventStore?.
|
|
19886
|
+
eventsUpdated = accepted !== void 0 ? await deps.eventStore?.projectTrackLabel(trackId, accepted) ?? 0 : 0;
|
|
18278
19887
|
} catch (err) {
|
|
18279
19888
|
deps.logger.warn("event label back-patch failed", {
|
|
18280
19889
|
tags: { deviceId },
|
|
@@ -18647,7 +20256,8 @@ var FaceGalleryProvider = class {
|
|
|
18647
20256
|
score: 1,
|
|
18648
20257
|
stepId: OPERATOR_FACE_STEP_ID,
|
|
18649
20258
|
decidedAt: Date.now(),
|
|
18650
|
-
operatorAuthority: true
|
|
20259
|
+
operatorAuthority: true,
|
|
20260
|
+
identityId
|
|
18651
20261
|
});
|
|
18652
20262
|
this.refreshGallery();
|
|
18653
20263
|
this.safeEmitFaceGalleryChanged({
|
|
@@ -20172,6 +21782,12 @@ var FrameProcessor = class {
|
|
|
20172
21782
|
/** Optional stationary-object gate (parked-object suppression). Null until
|
|
20173
21783
|
* the addon wires it via {@link setStationaryGate}. */
|
|
20174
21784
|
stationaryGate = null;
|
|
21785
|
+
/**
|
|
21786
|
+
* The label the TRACK currently carries, read (never mirrored) from the
|
|
21787
|
+
* active-track cache. See {@link setTrackLabelState} — and the use site in
|
|
21788
|
+
* `toObjectEvent`, which is the reason it exists.
|
|
21789
|
+
*/
|
|
21790
|
+
trackLabelState = null;
|
|
20175
21791
|
constructor(deviceId, trackerConfig = {}, stateConfig = {}, emitterConfig = {}, source = "pipeline") {
|
|
20176
21792
|
this.deviceId = deviceId;
|
|
20177
21793
|
this.source = source;
|
|
@@ -20188,6 +21804,19 @@ var FrameProcessor = class {
|
|
|
20188
21804
|
this.zones = zones;
|
|
20189
21805
|
}
|
|
20190
21806
|
/**
|
|
21807
|
+
* Wire the reader that answers "what does this track already say it is".
|
|
21808
|
+
*
|
|
21809
|
+
* A READ, not a mirror the late-label path has to remember to feed: the
|
|
21810
|
+
* active-track cache is in-RAM and already the authority every other surface
|
|
21811
|
+
* resolves against, and a mirror that can be forgotten is how a stamped row
|
|
21812
|
+
* and an unstamped one end up in the same track. Absent (tests, and any
|
|
21813
|
+
* caller that has not wired it) ⇒ the emitter behaves exactly as it did
|
|
21814
|
+
* before, stamping only what the current frame decided.
|
|
21815
|
+
*/
|
|
21816
|
+
setTrackLabelState(read) {
|
|
21817
|
+
this.trackLabelState = read;
|
|
21818
|
+
}
|
|
21819
|
+
/**
|
|
20191
21820
|
* How much of a detection's box must lie inside a zone for the zone to be
|
|
20192
21821
|
* stamped onto it (0–1 fraction of the box's own area).
|
|
20193
21822
|
*
|
|
@@ -20516,7 +22145,7 @@ var FrameProcessor = class {
|
|
|
20516
22145
|
const state = forcedState ?? mapObjectStateToTrackState(objectStates.find((o) => o.trackId === e.detection.trackId)?.state);
|
|
20517
22146
|
const zones = zonesByTrack.get(e.detection.trackId) ?? [];
|
|
20518
22147
|
const crossing = crossingOf(e);
|
|
20519
|
-
const labelPatch = labelPatchByTrack.get(e.detection.trackId);
|
|
22148
|
+
const labelPatch = labelPatchByTrack.get(e.detection.trackId) ?? knownTrackLabelPatch(this.trackLabelState?.(e.detection.trackId));
|
|
20520
22149
|
return {
|
|
20521
22150
|
id: randomUUID(),
|
|
20522
22151
|
deviceId: this.deviceId,
|
|
@@ -22187,7 +23816,8 @@ var PlateGalleryProvider = class {
|
|
|
22187
23816
|
score: 1,
|
|
22188
23817
|
stepId: OPERATOR_PLATE_STEP_ID,
|
|
22189
23818
|
decidedAt: Date.now(),
|
|
22190
|
-
operatorAuthority: true
|
|
23819
|
+
operatorAuthority: true,
|
|
23820
|
+
identityId: vehicle.id
|
|
22191
23821
|
});
|
|
22192
23822
|
this.refreshGallery();
|
|
22193
23823
|
this.safeEmitPlateGalleryChanged({
|
|
@@ -25562,10 +27192,13 @@ var FaceRecognizer = class {
|
|
|
25562
27192
|
tier: 2,
|
|
25563
27193
|
score: work.matchScore ?? 0,
|
|
25564
27194
|
stepId: FACE_RECOGNIZER_STEP_ID,
|
|
25565
|
-
decidedAt: Date.now()
|
|
27195
|
+
decidedAt: Date.now(),
|
|
27196
|
+
identityId: work.assignedIdentityId
|
|
25566
27197
|
};
|
|
27198
|
+
let accepted;
|
|
25567
27199
|
try {
|
|
25568
|
-
await this.deps.trackStore.applyLabelCandidate(work.trackId, candidate);
|
|
27200
|
+
const decision = await this.deps.trackStore.applyLabelCandidate(work.trackId, candidate);
|
|
27201
|
+
if (decision?.kind === "write") accepted = decision.patch;
|
|
25569
27202
|
} catch (err) {
|
|
25570
27203
|
this.deps.logger.warn("track identity label failed", {
|
|
25571
27204
|
tags: { deviceId: input.deviceId },
|
|
@@ -25575,8 +27208,8 @@ var FaceRecognizer = class {
|
|
|
25575
27208
|
}
|
|
25576
27209
|
});
|
|
25577
27210
|
}
|
|
25578
|
-
try {
|
|
25579
|
-
await this.deps.eventStore.
|
|
27211
|
+
if (accepted !== void 0) try {
|
|
27212
|
+
await this.deps.eventStore.projectTrackLabel(work.trackId, accepted);
|
|
25580
27213
|
} catch (err) {
|
|
25581
27214
|
this.deps.logger.warn("event identity label failed", {
|
|
25582
27215
|
tags: { deviceId: input.deviceId },
|
|
@@ -30777,8 +32410,25 @@ var PlateRecognizer = class {
|
|
|
30777
32410
|
* for an implausible read (junk OCR off a distant/oblique plate) — the
|
|
30778
32411
|
* caller must NOT stamp a label then. */
|
|
30779
32412
|
resolveLabel(text, score) {
|
|
32413
|
+
return this.resolveLabelWithVehicle(text, score)?.text ?? null;
|
|
32414
|
+
}
|
|
32415
|
+
/**
|
|
32416
|
+
* {@link resolveLabel} plus the GALLERY ROW it named, when a vehicle claimed
|
|
32417
|
+
* the read.
|
|
32418
|
+
*
|
|
32419
|
+
* The id is what a notification rule matches on — a display name is
|
|
32420
|
+
* renameable and a rule authored on the old spelling goes silently dark
|
|
32421
|
+
* (`LabelAttribution.identityId`). An unclaimed but plausible read still
|
|
32422
|
+
* returns its normalized text with NO `vehicleId`: it is a plate, it is just
|
|
32423
|
+
* nobody's plate.
|
|
32424
|
+
*/
|
|
32425
|
+
resolveLabelWithVehicle(text, score) {
|
|
30780
32426
|
if (!isPlausiblePlateRead(text, score)) return null;
|
|
30781
|
-
|
|
32427
|
+
const matched = this.matchVehicle(text, score);
|
|
32428
|
+
return matched !== null ? {
|
|
32429
|
+
text: matched.name,
|
|
32430
|
+
vehicleId: matched.vehicleId
|
|
32431
|
+
} : { text: normalizePlate(text) };
|
|
30782
32432
|
}
|
|
30783
32433
|
async processFrame(input) {
|
|
30784
32434
|
const minConfidence = input.minConfidence ?? 0;
|
|
@@ -32337,48 +33987,49 @@ var EventStore = class {
|
|
|
32337
33987
|
}
|
|
32338
33988
|
}
|
|
32339
33989
|
/**
|
|
32340
|
-
*
|
|
32341
|
-
*
|
|
32342
|
-
* which is NOT the number of rows, because the write rule is applied PER ROW
|
|
32343
|
-
* against that row's own tiers.
|
|
33990
|
+
* Project the TRACK's label decision onto every object event of that track.
|
|
33991
|
+
* Returns how many rows took it.
|
|
32344
33992
|
*
|
|
32345
|
-
*
|
|
32346
|
-
*
|
|
32347
|
-
*
|
|
32348
|
-
*
|
|
32349
|
-
*
|
|
33993
|
+
* ONE DECISION, EVERY ROW — and that is the correction, not the design it
|
|
33994
|
+
* replaced. This used to re-run `resolveTieredLabelWrite` per row against
|
|
33995
|
+
* that row's own tiers, on the theory that "the events of one track do not
|
|
33996
|
+
* all carry the same label". They do: a track has ONE subject, and every
|
|
33997
|
+
* read surface (the timeline, the event page, a notification body, a rule's
|
|
33998
|
+
* `identities` condition) shows the track's answer. Re-deciding per row meant
|
|
33999
|
+
* a row whose incumbent happened to outscore the track's winner kept a
|
|
34000
|
+
* different word, and the rows drifted apart from the track and from each
|
|
34001
|
+
* other — measured 2026-08-14 as 407 of 437 tracks whose event rows
|
|
34002
|
+
* disagreed with the track they belong to. A notification quoting one row and
|
|
34003
|
+
* a timeline quoting another then told the operator two different names for
|
|
34004
|
+
* the same person.
|
|
34005
|
+
*
|
|
34006
|
+
* The caller passes the patch the TRACK row accepted, so the two can never
|
|
34007
|
+
* disagree by construction. A refused track write projects nothing: nothing
|
|
34008
|
+
* changed, so there is nothing to copy.
|
|
32350
34009
|
*
|
|
32351
34010
|
* Used when face recognition, plate matching or a classifier answers after
|
|
32352
34011
|
* the events were emitted.
|
|
32353
34012
|
*/
|
|
32354
|
-
async
|
|
34013
|
+
async projectTrackLabel(trackId, patch) {
|
|
32355
34014
|
const rows = await this.store.query.query({
|
|
32356
34015
|
collection: OBJECT_EVENTS_COLLECTION,
|
|
32357
34016
|
filter: { where: { trackId } }
|
|
32358
34017
|
});
|
|
34018
|
+
const data = tieredLabelColumnData(patch);
|
|
32359
34019
|
let updated = 0;
|
|
32360
|
-
for (const row of rows) {
|
|
32361
|
-
|
|
32362
|
-
|
|
32363
|
-
|
|
32364
|
-
|
|
32365
|
-
|
|
32366
|
-
|
|
32367
|
-
|
|
32368
|
-
|
|
32369
|
-
|
|
32370
|
-
|
|
32371
|
-
|
|
32372
|
-
|
|
32373
|
-
});
|
|
32374
|
-
updated++;
|
|
32375
|
-
} catch (err) {
|
|
32376
|
-
this.logger.warn("applyLabelCandidateForTrack update failed", { meta: {
|
|
32377
|
-
trackId,
|
|
32378
|
-
eventId: row.id,
|
|
32379
|
-
error: String(err)
|
|
32380
|
-
} });
|
|
32381
|
-
}
|
|
34020
|
+
for (const row of rows) try {
|
|
34021
|
+
await this.store.update.mutate({
|
|
34022
|
+
collection: OBJECT_EVENTS_COLLECTION,
|
|
34023
|
+
id: row.id,
|
|
34024
|
+
data
|
|
34025
|
+
});
|
|
34026
|
+
updated++;
|
|
34027
|
+
} catch (err) {
|
|
34028
|
+
this.logger.warn("projectTrackLabel update failed", { meta: {
|
|
34029
|
+
trackId,
|
|
34030
|
+
eventId: row.id,
|
|
34031
|
+
error: String(err)
|
|
34032
|
+
} });
|
|
32382
34033
|
}
|
|
32383
34034
|
return updated;
|
|
32384
34035
|
}
|
|
@@ -36793,138 +38444,6 @@ var BindingCache = class {
|
|
|
36793
38444
|
}
|
|
36794
38445
|
};
|
|
36795
38446
|
//#endregion
|
|
36796
|
-
//#region src/pipeline-analytics/runtime/detection-coverage-cache.ts
|
|
36797
|
-
/**
|
|
36798
|
-
* DetectionCoverageCache — fleet-seeded, event-invalidated mirror of WHICH
|
|
36799
|
-
* devices carry a WRAPPED `detection-pipeline` binding. This is the alarm's
|
|
36800
|
-
* coverage authority ("Away armed, 12 cameras" is false if three of them are
|
|
36801
|
-
* offline or no longer wrapped), read by the notification centre's device
|
|
36802
|
-
* directory (`listDeviceIdentities`).
|
|
36803
|
-
*
|
|
36804
|
-
* WHY a mirror, not a per-tick sweep. The directory refreshes on the NC's 30 s
|
|
36805
|
-
* rule-reload tick. Answering "who still has detection?" with a fleet-wide
|
|
36806
|
-
* `getAllBindings` on EVERY tick cost 903 ms / 373 KB / ~505 `getBindings:
|
|
36807
|
-
* default wrappers skipped` log lines on the live hub (524 devices), forever —
|
|
36808
|
-
* ~3% of hub-main's main thread plus a permanent log-spam baseline. Same defect
|
|
36809
|
-
* and fix as `SystemMirror.refreshBinding` (639073e8): ONE fleet read to warm,
|
|
36810
|
-
* then per-device updates driven by `DeviceBindingsChanged` events. A wrapper
|
|
36811
|
-
* toggle emits that event carrying the device's id and `capName`
|
|
36812
|
-
* (`detection-pipeline`), so a single `getBindings(deviceId)` — 21 ms / 613 B —
|
|
36813
|
-
* keeps the mirror current without ever touching the fleet again.
|
|
36814
|
-
*
|
|
36815
|
-
* ── Destructive-direction discipline (D49) ──────────────────────────────────
|
|
36816
|
-
*
|
|
36817
|
-
* `detectionActive: false` reports a camera as UNCOVERED / switched-off (D62) —
|
|
36818
|
-
* the destructive answer, and the one an operator reads as "my alarm is not
|
|
36819
|
-
* watching that door". A transient `getBindings` blip must never flip a
|
|
36820
|
-
* known-covered camera to uncovered:
|
|
36821
|
-
* • a failed refresh KEEPS the previous value (a read that throws changes
|
|
36822
|
-
* nothing), and
|
|
36823
|
-
* • a single read that would flip a known `true → false` is confirmed by a
|
|
36824
|
-
* concordant SECOND read before it lands — an empty binding list is not
|
|
36825
|
-
* proof the wrapper is gone.
|
|
36826
|
-
* A COLD mirror (seed not yet landed, or a seed that failed) answers `undefined`
|
|
36827
|
-
* for every id — and unknown never reports a camera as excluded from coverage.
|
|
36828
|
-
*/
|
|
36829
|
-
var DetectionCoverageCache = class {
|
|
36830
|
-
api;
|
|
36831
|
-
logger;
|
|
36832
|
-
/** deviceId → "has a WRAPPED detection-pipeline binding". Absent = unknown. */
|
|
36833
|
-
state = /* @__PURE__ */ new Map();
|
|
36834
|
-
warm = false;
|
|
36835
|
-
seeding = null;
|
|
36836
|
-
constructor(deps) {
|
|
36837
|
-
this.api = deps.api;
|
|
36838
|
-
this.logger = deps.logger;
|
|
36839
|
-
}
|
|
36840
|
-
/** Has the ONE fleet seed ever landed? A cold mirror knows nothing. */
|
|
36841
|
-
isWarm() {
|
|
36842
|
-
return this.warm;
|
|
36843
|
-
}
|
|
36844
|
-
/** Do we have an answer for this id at all? (`false` ⇒ absent ⇒ unknown.) */
|
|
36845
|
-
has(deviceId) {
|
|
36846
|
-
return this.state.has(deviceId);
|
|
36847
|
-
}
|
|
36848
|
-
/** The coverage answer, or `undefined` when this id is unknown. No I/O. */
|
|
36849
|
-
get(deviceId) {
|
|
36850
|
-
return this.state.get(deviceId);
|
|
36851
|
-
}
|
|
36852
|
-
/**
|
|
36853
|
-
* Seed the whole fleet ONCE with a single `getAllBindings`. Idempotent: a
|
|
36854
|
-
* warm cache is a no-op, and concurrent callers coalesce onto one read. A
|
|
36855
|
-
* failed seed leaves the mirror COLD so the next tick retries — during that
|
|
36856
|
-
* window every id answers unknown, which never reports a camera as excluded.
|
|
36857
|
-
*/
|
|
36858
|
-
async ensureWarm() {
|
|
36859
|
-
if (this.warm) return;
|
|
36860
|
-
if (this.seeding) return this.seeding;
|
|
36861
|
-
this.seeding = this.seed();
|
|
36862
|
-
try {
|
|
36863
|
-
await this.seeding;
|
|
36864
|
-
} finally {
|
|
36865
|
-
this.seeding = null;
|
|
36866
|
-
}
|
|
36867
|
-
}
|
|
36868
|
-
async seed() {
|
|
36869
|
-
try {
|
|
36870
|
-
const rows = await this.api.deviceManager.getAllBindings.query({});
|
|
36871
|
-
for (const row of rows) {
|
|
36872
|
-
if (this.state.has(row.deviceId)) continue;
|
|
36873
|
-
this.state.set(row.deviceId, hasWrappedDetection(row.entries));
|
|
36874
|
-
}
|
|
36875
|
-
this.warm = true;
|
|
36876
|
-
this.logger.info("detection-coverage mirror warm", { meta: { devices: this.state.size } });
|
|
36877
|
-
} catch (err) {
|
|
36878
|
-
this.logger.warn("detection-coverage seed failed — coverage unknown until it lands", { meta: { error: String(err) } });
|
|
36879
|
-
}
|
|
36880
|
-
}
|
|
36881
|
-
/**
|
|
36882
|
-
* A `DeviceBindingsChanged` for the detection cap re-reads THAT ONE device
|
|
36883
|
-
* (never the fleet). Fire-and-forget from the synchronous event handler.
|
|
36884
|
-
*/
|
|
36885
|
-
onBindingsChanged(event) {
|
|
36886
|
-
if (event.capName !== "detection-pipeline") return Promise.resolve();
|
|
36887
|
-
return this.refreshDevice(event.deviceId);
|
|
36888
|
-
}
|
|
36889
|
-
/** Drop a device the fleet no longer has (`device.unregistered`). */
|
|
36890
|
-
forget(deviceId) {
|
|
36891
|
-
this.state.delete(deviceId);
|
|
36892
|
-
}
|
|
36893
|
-
/** Return to cold — used on addon shutdown. */
|
|
36894
|
-
clearAll() {
|
|
36895
|
-
this.state.clear();
|
|
36896
|
-
this.warm = false;
|
|
36897
|
-
this.seeding = null;
|
|
36898
|
-
}
|
|
36899
|
-
async refreshDevice(deviceId) {
|
|
36900
|
-
try {
|
|
36901
|
-
const active = await this.readWrappedDetection(deviceId);
|
|
36902
|
-
const prev = this.state.get(deviceId);
|
|
36903
|
-
if (!active && prev === true) {
|
|
36904
|
-
if (await this.readWrappedDetection(deviceId)) {
|
|
36905
|
-
this.state.set(deviceId, true);
|
|
36906
|
-
return;
|
|
36907
|
-
}
|
|
36908
|
-
}
|
|
36909
|
-
this.state.set(deviceId, active);
|
|
36910
|
-
} catch (err) {
|
|
36911
|
-
this.logger.debug("detection-coverage refresh failed — keeping the previous value", {
|
|
36912
|
-
tags: { deviceId },
|
|
36913
|
-
meta: { error: String(err) }
|
|
36914
|
-
});
|
|
36915
|
-
}
|
|
36916
|
-
}
|
|
36917
|
-
async readWrappedDetection(deviceId) {
|
|
36918
|
-
return hasWrappedDetection((await this.api.deviceManager.getBindings.query({ deviceId })).entries);
|
|
36919
|
-
}
|
|
36920
|
-
};
|
|
36921
|
-
/** The one authority: a WRAPPED `detection-pipeline` binding is present. A
|
|
36922
|
-
* native binding of the same cap is NOT coverage (D62: absence of the WRAPPED
|
|
36923
|
-
* binding is how a deactivated wrapper is represented). */
|
|
36924
|
-
function hasWrappedDetection(entries) {
|
|
36925
|
-
return entries.some((e) => e.capName === "detection-pipeline" && e.kind === "wrapped");
|
|
36926
|
-
}
|
|
36927
|
-
//#endregion
|
|
36928
38447
|
//#region src/pipeline-analytics/runtime/capture-scheduler.ts
|
|
36929
38448
|
/**
|
|
36930
38449
|
* Kind-derived default priorities (0 = highest). Explicit `priority` wins.
|
|
@@ -37197,6 +38716,138 @@ var CaptureScheduler = class {
|
|
|
37197
38716
|
}
|
|
37198
38717
|
};
|
|
37199
38718
|
//#endregion
|
|
38719
|
+
//#region src/pipeline-analytics/runtime/detection-coverage-cache.ts
|
|
38720
|
+
/**
|
|
38721
|
+
* DetectionCoverageCache — fleet-seeded, event-invalidated mirror of WHICH
|
|
38722
|
+
* devices carry a WRAPPED `detection-pipeline` binding. This is the alarm's
|
|
38723
|
+
* coverage authority ("Away armed, 12 cameras" is false if three of them are
|
|
38724
|
+
* offline or no longer wrapped), read by the notification centre's device
|
|
38725
|
+
* directory (`listDeviceIdentities`).
|
|
38726
|
+
*
|
|
38727
|
+
* WHY a mirror, not a per-tick sweep. The directory refreshes on the NC's 30 s
|
|
38728
|
+
* rule-reload tick. Answering "who still has detection?" with a fleet-wide
|
|
38729
|
+
* `getAllBindings` on EVERY tick cost 903 ms / 373 KB / ~505 `getBindings:
|
|
38730
|
+
* default wrappers skipped` log lines on the live hub (524 devices), forever —
|
|
38731
|
+
* ~3% of hub-main's main thread plus a permanent log-spam baseline. Same defect
|
|
38732
|
+
* and fix as `SystemMirror.refreshBinding` (639073e8): ONE fleet read to warm,
|
|
38733
|
+
* then per-device updates driven by `DeviceBindingsChanged` events. A wrapper
|
|
38734
|
+
* toggle emits that event carrying the device's id and `capName`
|
|
38735
|
+
* (`detection-pipeline`), so a single `getBindings(deviceId)` — 21 ms / 613 B —
|
|
38736
|
+
* keeps the mirror current without ever touching the fleet again.
|
|
38737
|
+
*
|
|
38738
|
+
* ── Destructive-direction discipline (D49) ──────────────────────────────────
|
|
38739
|
+
*
|
|
38740
|
+
* `detectionActive: false` reports a camera as UNCOVERED / switched-off (D62) —
|
|
38741
|
+
* the destructive answer, and the one an operator reads as "my alarm is not
|
|
38742
|
+
* watching that door". A transient `getBindings` blip must never flip a
|
|
38743
|
+
* known-covered camera to uncovered:
|
|
38744
|
+
* • a failed refresh KEEPS the previous value (a read that throws changes
|
|
38745
|
+
* nothing), and
|
|
38746
|
+
* • a single read that would flip a known `true → false` is confirmed by a
|
|
38747
|
+
* concordant SECOND read before it lands — an empty binding list is not
|
|
38748
|
+
* proof the wrapper is gone.
|
|
38749
|
+
* A COLD mirror (seed not yet landed, or a seed that failed) answers `undefined`
|
|
38750
|
+
* for every id — and unknown never reports a camera as excluded from coverage.
|
|
38751
|
+
*/
|
|
38752
|
+
var DetectionCoverageCache = class {
|
|
38753
|
+
api;
|
|
38754
|
+
logger;
|
|
38755
|
+
/** deviceId → "has a WRAPPED detection-pipeline binding". Absent = unknown. */
|
|
38756
|
+
state = /* @__PURE__ */ new Map();
|
|
38757
|
+
warm = false;
|
|
38758
|
+
seeding = null;
|
|
38759
|
+
constructor(deps) {
|
|
38760
|
+
this.api = deps.api;
|
|
38761
|
+
this.logger = deps.logger;
|
|
38762
|
+
}
|
|
38763
|
+
/** Has the ONE fleet seed ever landed? A cold mirror knows nothing. */
|
|
38764
|
+
isWarm() {
|
|
38765
|
+
return this.warm;
|
|
38766
|
+
}
|
|
38767
|
+
/** Do we have an answer for this id at all? (`false` ⇒ absent ⇒ unknown.) */
|
|
38768
|
+
has(deviceId) {
|
|
38769
|
+
return this.state.has(deviceId);
|
|
38770
|
+
}
|
|
38771
|
+
/** The coverage answer, or `undefined` when this id is unknown. No I/O. */
|
|
38772
|
+
get(deviceId) {
|
|
38773
|
+
return this.state.get(deviceId);
|
|
38774
|
+
}
|
|
38775
|
+
/**
|
|
38776
|
+
* Seed the whole fleet ONCE with a single `getAllBindings`. Idempotent: a
|
|
38777
|
+
* warm cache is a no-op, and concurrent callers coalesce onto one read. A
|
|
38778
|
+
* failed seed leaves the mirror COLD so the next tick retries — during that
|
|
38779
|
+
* window every id answers unknown, which never reports a camera as excluded.
|
|
38780
|
+
*/
|
|
38781
|
+
async ensureWarm() {
|
|
38782
|
+
if (this.warm) return;
|
|
38783
|
+
if (this.seeding) return this.seeding;
|
|
38784
|
+
this.seeding = this.seed();
|
|
38785
|
+
try {
|
|
38786
|
+
await this.seeding;
|
|
38787
|
+
} finally {
|
|
38788
|
+
this.seeding = null;
|
|
38789
|
+
}
|
|
38790
|
+
}
|
|
38791
|
+
async seed() {
|
|
38792
|
+
try {
|
|
38793
|
+
const rows = await this.api.deviceManager.getAllBindings.query({});
|
|
38794
|
+
for (const row of rows) {
|
|
38795
|
+
if (this.state.has(row.deviceId)) continue;
|
|
38796
|
+
this.state.set(row.deviceId, hasWrappedDetection(row.entries));
|
|
38797
|
+
}
|
|
38798
|
+
this.warm = true;
|
|
38799
|
+
this.logger.info("detection-coverage mirror warm", { meta: { devices: this.state.size } });
|
|
38800
|
+
} catch (err) {
|
|
38801
|
+
this.logger.warn("detection-coverage seed failed — coverage unknown until it lands", { meta: { error: String(err) } });
|
|
38802
|
+
}
|
|
38803
|
+
}
|
|
38804
|
+
/**
|
|
38805
|
+
* A `DeviceBindingsChanged` for the detection cap re-reads THAT ONE device
|
|
38806
|
+
* (never the fleet). Fire-and-forget from the synchronous event handler.
|
|
38807
|
+
*/
|
|
38808
|
+
onBindingsChanged(event) {
|
|
38809
|
+
if (event.capName !== "detection-pipeline") return Promise.resolve();
|
|
38810
|
+
return this.refreshDevice(event.deviceId);
|
|
38811
|
+
}
|
|
38812
|
+
/** Drop a device the fleet no longer has (`device.unregistered`). */
|
|
38813
|
+
forget(deviceId) {
|
|
38814
|
+
this.state.delete(deviceId);
|
|
38815
|
+
}
|
|
38816
|
+
/** Return to cold — used on addon shutdown. */
|
|
38817
|
+
clearAll() {
|
|
38818
|
+
this.state.clear();
|
|
38819
|
+
this.warm = false;
|
|
38820
|
+
this.seeding = null;
|
|
38821
|
+
}
|
|
38822
|
+
async refreshDevice(deviceId) {
|
|
38823
|
+
try {
|
|
38824
|
+
const active = await this.readWrappedDetection(deviceId);
|
|
38825
|
+
const prev = this.state.get(deviceId);
|
|
38826
|
+
if (!active && prev === true) {
|
|
38827
|
+
if (await this.readWrappedDetection(deviceId)) {
|
|
38828
|
+
this.state.set(deviceId, true);
|
|
38829
|
+
return;
|
|
38830
|
+
}
|
|
38831
|
+
}
|
|
38832
|
+
this.state.set(deviceId, active);
|
|
38833
|
+
} catch (err) {
|
|
38834
|
+
this.logger.debug("detection-coverage refresh failed — keeping the previous value", {
|
|
38835
|
+
tags: { deviceId },
|
|
38836
|
+
meta: { error: String(err) }
|
|
38837
|
+
});
|
|
38838
|
+
}
|
|
38839
|
+
}
|
|
38840
|
+
async readWrappedDetection(deviceId) {
|
|
38841
|
+
return hasWrappedDetection((await this.api.deviceManager.getBindings.query({ deviceId })).entries);
|
|
38842
|
+
}
|
|
38843
|
+
};
|
|
38844
|
+
/** The one authority: a WRAPPED `detection-pipeline` binding is present. A
|
|
38845
|
+
* native binding of the same cap is NOT coverage (D62: absence of the WRAPPED
|
|
38846
|
+
* binding is how a deactivated wrapper is represented). */
|
|
38847
|
+
function hasWrappedDetection(entries) {
|
|
38848
|
+
return entries.some((e) => e.capName === "detection-pipeline" && e.kind === "wrapped");
|
|
38849
|
+
}
|
|
38850
|
+
//#endregion
|
|
37200
38851
|
//#region src/pipeline-analytics/pipeline/capture-crop.ts
|
|
37201
38852
|
function createCaptureCrop(deps) {
|
|
37202
38853
|
return async (frameHandle, bbox, frameWidth, frameHeight, padding, maxWidth, deviceId) => {
|
|
@@ -42069,6 +43720,17 @@ var REBUILD_FALLBACK_NODE_ID = "hub";
|
|
|
42069
43720
|
* count. Two thousand is far above any real night and still a bounded read.
|
|
42070
43721
|
*/
|
|
42071
43722
|
var TIMELAPSE_TRACK_QUERY_LIMIT = 2e3;
|
|
43723
|
+
/**
|
|
43724
|
+
* How many object-event rows ONE timelapse window may read.
|
|
43725
|
+
*
|
|
43726
|
+
* Higher than the track limit because these are per-FRAME rows, not per-subject:
|
|
43727
|
+
* one track routinely writes a handful (`entered`, some `moving`, `left`), and
|
|
43728
|
+
* the merge in `detection-intervals.ts` folds them back to one subject. Ten
|
|
43729
|
+
* thousand covers every night this deployment has produced and still bounds the
|
|
43730
|
+
* read; the store returns the NEWEST rows, so an overflow loses the start of the
|
|
43731
|
+
* window rather than a random slice of it.
|
|
43732
|
+
*/
|
|
43733
|
+
var TIMELAPSE_EVENT_QUERY_LIMIT = 1e4;
|
|
42072
43734
|
var PIPELINE_ANALYTICS_ADDON_ID = "pipeline-analytics";
|
|
42073
43735
|
/** Artefact index ⇄ shelf reconcile cadence. Hourly: the two only drift on the
|
|
42074
43736
|
* bounds' own timescale (14 days / 2000 entries / 2 GiB). */
|
|
@@ -43077,7 +44739,24 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
43077
44739
|
runNow: (input) => center.runSummaryNow(input)
|
|
43078
44740
|
},
|
|
43079
44741
|
texts: center.textCatalogEditor,
|
|
43080
|
-
...this.ncArtifactIndex !== null ? { artifacts: this.ncArtifactIndex } : {}
|
|
44742
|
+
...this.ncArtifactIndex !== null ? { artifacts: this.ncArtifactIndex } : {},
|
|
44743
|
+
ruleStatus: {
|
|
44744
|
+
forRule: (rule) => center.ruleStatusFor(rule),
|
|
44745
|
+
forTimelapseRule: (rule) => center.timelapseStatusFor(rule, async ({ ruleId, deviceId }) => {
|
|
44746
|
+
const index = this.ncArtifactIndex;
|
|
44747
|
+
if (index === null) return null;
|
|
44748
|
+
return (await index.list({
|
|
44749
|
+
kinds: ["timelapse"],
|
|
44750
|
+
ruleId,
|
|
44751
|
+
deviceIds: [deviceId],
|
|
44752
|
+
limit: 1,
|
|
44753
|
+
offset: 0
|
|
44754
|
+
}, {
|
|
44755
|
+
userId: "",
|
|
44756
|
+
isAdmin: true
|
|
44757
|
+
})).artifacts[0] ?? null;
|
|
44758
|
+
})
|
|
44759
|
+
}
|
|
43081
44760
|
});
|
|
43082
44761
|
}
|
|
43083
44762
|
/** Declare typed collections up-front so the first insert doesn't race with
|
|
@@ -43550,10 +45229,7 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
43550
45229
|
const buf = await this.captureCrop(frame.frameHandle, { ...frame.bbox }, frame.frameWidth, frame.frameHeight, DETAIL_FALLBACK_CROP_PADDING);
|
|
43551
45230
|
return buf ? buf.toString("base64") : null;
|
|
43552
45231
|
},
|
|
43553
|
-
hasTrackLabel: (trackId) =>
|
|
43554
|
-
const label = this.trackStore?.getActiveByTrack(trackId)?.label;
|
|
43555
|
-
return label !== void 0 && label.length > 0;
|
|
43556
|
-
}
|
|
45232
|
+
hasTrackLabel: (trackId) => hasResolvedLabel(this.trackStore?.getActiveByTrack(trackId))
|
|
43557
45233
|
});
|
|
43558
45234
|
}
|
|
43559
45235
|
/** Binding cache + the zone-analytics / audio-metrics cap providers. */
|
|
@@ -43645,6 +45321,14 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
43645
45321
|
addonId: device.addonId
|
|
43646
45322
|
};
|
|
43647
45323
|
},
|
|
45324
|
+
identityIdsByName: async () => {
|
|
45325
|
+
const byName = /* @__PURE__ */ new Map();
|
|
45326
|
+
const identities = await stores.identityStore.listIdentities();
|
|
45327
|
+
for (const identity of identities) byName.set(identity.name.trim().toLowerCase(), identity.id);
|
|
45328
|
+
const vehicles = await stores.vehicleStore.listVehicles();
|
|
45329
|
+
for (const vehicle of vehicles) byName.set(vehicle.name.trim().toLowerCase(), vehicle.id);
|
|
45330
|
+
return byName;
|
|
45331
|
+
},
|
|
43648
45332
|
listDeviceIdentities: async () => {
|
|
43649
45333
|
const all = await api.deviceManager.listAll.query({ projection: "slim" });
|
|
43650
45334
|
const coverage = this.detectionCoverage;
|
|
@@ -43907,11 +45591,40 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
43907
45591
|
projection: "slim",
|
|
43908
45592
|
limit: TIMELAPSE_TRACK_QUERY_LIMIT
|
|
43909
45593
|
})).map((t) => ({
|
|
45594
|
+
trackId: t.trackId,
|
|
43910
45595
|
firstSeen: t.firstSeen,
|
|
43911
45596
|
lastSeen: t.lastSeen,
|
|
43912
45597
|
className: t.className
|
|
43913
45598
|
}));
|
|
43914
45599
|
},
|
|
45600
|
+
/**
|
|
45601
|
+
* The other half of "did anything happen here": the object-event rows.
|
|
45602
|
+
*
|
|
45603
|
+
* The persisted track store is NOT a complete record of the window. Three
|
|
45604
|
+
* gates destroy a track before it is written — the confirmation gate
|
|
45605
|
+
* retracting a suspected false birth, stationary promotion dropping a
|
|
45606
|
+
* parked object, zero-media suppression cascading a thumbnail-less track
|
|
45607
|
+
* away — and on 2026-08-13 they left camera 615 with zero persisted tracks
|
|
45608
|
+
* across a ten-hour night that held a person pass at 23:31. These rows
|
|
45609
|
+
* survive all three.
|
|
45610
|
+
*
|
|
45611
|
+
* FULL projection, not slim: `slimObject` drops `trackId`, and without it
|
|
45612
|
+
* every event of one subject becomes its own detection. It costs nothing
|
|
45613
|
+
* at the store — the row is read whole either way and slim only strips
|
|
45614
|
+
* fields afterwards.
|
|
45615
|
+
*/
|
|
45616
|
+
listDetectionEvents: async ({ deviceId, since, until }) => {
|
|
45617
|
+
return (await stores.eventStore.queryObject({
|
|
45618
|
+
deviceId,
|
|
45619
|
+
since,
|
|
45620
|
+
until,
|
|
45621
|
+
limit: TIMELAPSE_EVENT_QUERY_LIMIT
|
|
45622
|
+
})).map((e) => ({
|
|
45623
|
+
timestamp: e.timestamp,
|
|
45624
|
+
className: e.className,
|
|
45625
|
+
...e.trackId !== void 0 ? { trackId: e.trackId } : {}
|
|
45626
|
+
}));
|
|
45627
|
+
},
|
|
43915
45628
|
createExport: (input) => api.recordingExport.createExport.mutate({
|
|
43916
45629
|
deviceId: input.deviceId,
|
|
43917
45630
|
profile: input.profile,
|
|
@@ -43936,6 +45649,7 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
43936
45649
|
}
|
|
43937
45650
|
}),
|
|
43938
45651
|
getExport: ({ exportId }) => api.recordingExport.getExport.query({ exportId }),
|
|
45652
|
+
listExports: ({ deviceId }) => api.recordingExport.listExports.query({ deviceId }),
|
|
43939
45653
|
publishArtifact: buildTimelapseArtifactPublisher({
|
|
43940
45654
|
logger: this.ctx.logger.child("Timelapse"),
|
|
43941
45655
|
readExportBytes: ({ exportId }) => api.recordingExport.readExportBytes.query({ exportId }),
|
|
@@ -45577,8 +47291,8 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
45577
47291
|
});
|
|
45578
47292
|
}
|
|
45579
47293
|
}
|
|
45580
|
-
const
|
|
45581
|
-
if (
|
|
47294
|
+
const resolved = d.className === "plate" ? this.plateRecognizer?.resolveLabelWithVehicle(d.label, d.score) ?? null : { text: d.label };
|
|
47295
|
+
if (resolved !== null && resolved.text !== void 0) await this.applyTrackEnrichmentLabel(deviceId, trackId, resolved.text, d, frame.timestamp, resolved.vehicleId);
|
|
45582
47296
|
}
|
|
45583
47297
|
} catch (err) {
|
|
45584
47298
|
this.ctx.logger.warn("detail result route failed", {
|
|
@@ -45666,7 +47380,7 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
45666
47380
|
* a runner older than the declaration, because the alternative is guessing
|
|
45667
47381
|
* and putting a species in the coarse slot.
|
|
45668
47382
|
*/
|
|
45669
|
-
async applyTrackEnrichmentLabel(deviceId, trackId, label, detail, decidedAt) {
|
|
47383
|
+
async applyTrackEnrichmentLabel(deviceId, trackId, label, detail, decidedAt, identityId) {
|
|
45670
47384
|
await applyTrackLabelCandidate({
|
|
45671
47385
|
...this.trackStore ? { trackStore: this.trackStore } : {},
|
|
45672
47386
|
...this.eventStore ? { eventStore: this.eventStore } : {},
|
|
@@ -45677,7 +47391,8 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
45677
47391
|
score: detail.score,
|
|
45678
47392
|
stepId: detail.stepId,
|
|
45679
47393
|
...detail.labelModelId !== void 0 ? { modelId: detail.labelModelId } : {},
|
|
45680
|
-
decidedAt
|
|
47394
|
+
decidedAt,
|
|
47395
|
+
...identityId !== void 0 ? { identityId } : {}
|
|
45681
47396
|
});
|
|
45682
47397
|
}
|
|
45683
47398
|
/** Active CLIP image-embedding model id (== the text encoder's), TTL-cached.
|
|
@@ -46747,6 +48462,18 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
46747
48462
|
cooldownSec,
|
|
46748
48463
|
minTrackAgeMs: trk.minTrackAgeMs
|
|
46749
48464
|
}, source);
|
|
48465
|
+
p.setTrackLabelState((trackId) => {
|
|
48466
|
+
const active = this.trackStore?.getActiveByTrack(trackId);
|
|
48467
|
+
return active !== null && active !== void 0 ? {
|
|
48468
|
+
className: active.className,
|
|
48469
|
+
...active.label !== void 0 ? { label: active.label } : {},
|
|
48470
|
+
...active.labelScore !== void 0 ? { labelScore: active.labelScore } : {},
|
|
48471
|
+
...active.labelMeta !== void 0 ? { labelMeta: active.labelMeta } : {},
|
|
48472
|
+
...active.subLabel !== void 0 ? { subLabel: active.subLabel } : {},
|
|
48473
|
+
...active.subLabelScore !== void 0 ? { subLabelScore: active.subLabelScore } : {},
|
|
48474
|
+
...active.subLabelMeta !== void 0 ? { subLabelMeta: active.subLabelMeta } : {}
|
|
48475
|
+
} : void 0;
|
|
48476
|
+
});
|
|
46750
48477
|
if (source === "pipeline" && this.stationaryRegistry) {
|
|
46751
48478
|
const registry = this.stationaryRegistry;
|
|
46752
48479
|
p.setStationaryGate({ filter: (input) => {
|