@camstack/addon-post-analysis 1.2.611 → 1.2.612
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/{clip-model-registry-D1PS6-db.js → clip-model-registry-C7WMvftu.js} +559 -40
- package/dist/{clip-model-registry-3TQUwS_R.mjs → clip-model-registry-JH4cTI06.mjs} +542 -41
- package/dist/embedding-encoder/index.js +1 -1
- package/dist/embedding-encoder/index.mjs +1 -1
- package/dist/pipeline-analytics/_stub.js +2 -2
- package/dist/pipeline-analytics/{_virtual_mf-localSharedImportMap___mfe_internal__addon_pipeline_analytics_widgets-D_6mbip_.mjs → _virtual_mf-localSharedImportMap___mfe_internal__addon_pipeline_analytics_widgets-DQE_Wu6_.mjs} +3 -3
- package/dist/pipeline-analytics/_virtual_mf___mfe_internal__addon_pipeline_analytics_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-7dLQKt_H.mjs +26 -0
- package/dist/pipeline-analytics/_virtual_mf___mfe_internal__addon_pipeline_analytics_widgets__loadShare___mf_0_camstack_mf_1_ui_mf_2_library__loadShare__.js-DbUPeg6m.mjs +26 -0
- package/dist/pipeline-analytics/{hostInit-Tp2cEslm.mjs → hostInit-Bl6Zvb1p.mjs} +3 -3
- package/dist/pipeline-analytics/index.js +1298 -359
- package/dist/pipeline-analytics/index.mjs +1298 -359
- 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_types__loadShare__.js-BX7AfZj6.mjs +0 -26
- package/dist/pipeline-analytics/_virtual_mf___mfe_internal__addon_pipeline_analytics_widgets__loadShare___mf_0_camstack_mf_1_ui_mf_2_library__loadShare__.js-DZcBAOjR.mjs +0 -26
|
@@ -2,7 +2,7 @@ Object.defineProperties(exports, {
|
|
|
2
2
|
__esModule: { value: true },
|
|
3
3
|
[Symbol.toStringTag]: { value: "Module" }
|
|
4
4
|
});
|
|
5
|
-
const require_clip_model_registry = require("../clip-model-registry-
|
|
5
|
+
const require_clip_model_registry = require("../clip-model-registry-C7WMvftu.js");
|
|
6
6
|
let node_fs = require("node:fs");
|
|
7
7
|
node_fs = require_clip_model_registry.__toESM(node_fs, 1);
|
|
8
8
|
let node_path = require("node:path");
|
|
@@ -935,6 +935,7 @@ function actionTtlMs(subject) {
|
|
|
935
935
|
case "snooze": return SNOOZE_ACTION_TTL_MS;
|
|
936
936
|
case "alarm":
|
|
937
937
|
if (subject.action === "arm-anyway") return ARM_ANYWAY_TTL_MS;
|
|
938
|
+
if (subject.action === "bypass-input") return DEFAULT_ACTION_TTL_MS;
|
|
938
939
|
return subject.action === "disarm" ? ALARM_DISARM_TTL_MS : DEFAULT_ACTION_TTL_MS;
|
|
939
940
|
case "sequence": return DEFAULT_ACTION_TTL_MS;
|
|
940
941
|
case "addon-update": return DEFAULT_ACTION_TTL_MS;
|
|
@@ -1019,6 +1020,7 @@ function describeGrant(grant) {
|
|
|
1019
1020
|
case "snooze": return grant.classes === void 0 || grant.classes.length === 0 ? `Silenced for ${grant.minutes} min` : `Silenced for ${grant.minutes} min — ${grant.classes.join(", ")}`;
|
|
1020
1021
|
case "alarm":
|
|
1021
1022
|
if (grant.action === "arm-anyway") return "Alarm armed, around the open devices";
|
|
1023
|
+
if (grant.action === "bypass-input") return `Input bypassed until disarm — ${grant.ruleName}`;
|
|
1022
1024
|
return grant.action === "disarm" ? "Alarm disarmed" : `Alarm armed${grant.mode !== void 0 ? ` (${grant.mode})` : ""}`;
|
|
1023
1025
|
case "addon-update": return `${grant.packages.length} addon update(s) on ${grant.nodeId}`;
|
|
1024
1026
|
case "server-update": return `Server update on ${grant.nodeId}`;
|
|
@@ -1046,17 +1048,26 @@ function grantMeta(grant) {
|
|
|
1046
1048
|
minutes: grant.minutes,
|
|
1047
1049
|
classes: grant.classes ?? null
|
|
1048
1050
|
};
|
|
1049
|
-
case "alarm":
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1051
|
+
case "alarm":
|
|
1052
|
+
if (grant.action === "bypass-input") return {
|
|
1053
|
+
...common,
|
|
1054
|
+
alarm: grant.action,
|
|
1055
|
+
ruleId: grant.ruleId,
|
|
1056
|
+
rule: grant.ruleName,
|
|
1057
|
+
inputDeviceId: grant.inputDeviceId,
|
|
1058
|
+
panelDeviceId: grant.panelDeviceId
|
|
1059
|
+
};
|
|
1060
|
+
return grant.action === "arm-anyway" ? {
|
|
1061
|
+
...common,
|
|
1062
|
+
alarm: grant.action,
|
|
1063
|
+
refusalId: grant.refusalId,
|
|
1064
|
+
panelDeviceId: grant.panelDeviceId
|
|
1065
|
+
} : {
|
|
1066
|
+
...common,
|
|
1067
|
+
alarm: grant.action,
|
|
1068
|
+
mode: grant.mode ?? null,
|
|
1069
|
+
panelDeviceId: grant.panelDeviceId
|
|
1070
|
+
};
|
|
1060
1071
|
case "addon-update": return {
|
|
1061
1072
|
...common,
|
|
1062
1073
|
nodeId: grant.nodeId,
|
|
@@ -1580,6 +1591,128 @@ var NcAlarmExclusions = class {
|
|
|
1580
1591
|
}
|
|
1581
1592
|
};
|
|
1582
1593
|
//#endregion
|
|
1594
|
+
//#region src/notification-center/alarm/alarm-input-bypasses.ts
|
|
1595
|
+
/**
|
|
1596
|
+
* The alarm INPUTS the operator silenced for the rest of this arm — "Bypass
|
|
1597
|
+
* this input until disarm".
|
|
1598
|
+
*
|
|
1599
|
+
* An input is ONE (rule, device) pair: the rule gated on `armed_*` and the
|
|
1600
|
+
* device whose match pressed the panel (the sensor when the match was about
|
|
1601
|
+
* one, else the camera — `intrusionDeviceOf`). A false positive that keeps
|
|
1602
|
+
* re-triggering the alarm is exactly one such pair; bypassing it leaves every
|
|
1603
|
+
* other device of the same rule, and the same device under every other rule,
|
|
1604
|
+
* armed.
|
|
1605
|
+
*
|
|
1606
|
+
* Not an exclusion (`alarm-exclusions.ts`): an exclusion is an opening that was
|
|
1607
|
+
* open at ARM time and lifts on its own two-phase edge. A bypass never lifts by
|
|
1608
|
+
* itself. It belongs to the arm SESSION and ends with it — on a disarm (any
|
|
1609
|
+
* path: the operator, the bridge, the cap, a `disableAfter` auto-disarm) and on
|
|
1610
|
+
* a new arm. A bounded siren that RE-ARMS itself is the same session, and the
|
|
1611
|
+
* bypass survives it — that re-trigger is the thing it exists to stop.
|
|
1612
|
+
*
|
|
1613
|
+
* Persisted with the panel's machine, in the panel's own device config, for the
|
|
1614
|
+
* exclusions' reason: a deploy in the middle of the night must not quietly hand
|
|
1615
|
+
* the false positive back its siren. No second store (D62): this is state of
|
|
1616
|
+
* the arm, and it lives where the arm lives.
|
|
1617
|
+
*/
|
|
1618
|
+
/** At most this many bypassed inputs per arm — a bound on the persisted blob, not a target. */
|
|
1619
|
+
var NC_ALARM_INPUT_BYPASS_MAX = 200;
|
|
1620
|
+
/** One bypassed input, as persisted. Read back through {@link NcAlarmInputBypasses.restore}. */
|
|
1621
|
+
var NcAlarmInputBypassRecordSchema = require_clip_model_registry.object({
|
|
1622
|
+
ruleId: require_clip_model_registry.string().min(1),
|
|
1623
|
+
/** The rule's name when it was bypassed — a fallback for the view; the mirror's name wins. */
|
|
1624
|
+
ruleName: require_clip_model_registry.string(),
|
|
1625
|
+
/** The INPUT: the sensor of the match, else its camera (`intrusionDeviceOf`). */
|
|
1626
|
+
deviceId: require_clip_model_registry.number().int(),
|
|
1627
|
+
/** The match's camera — kept so a rule edit that drops the camera can be recognised. */
|
|
1628
|
+
cameraId: require_clip_model_registry.number().int(),
|
|
1629
|
+
/** When the bypass was made. */
|
|
1630
|
+
at: require_clip_model_registry.number(),
|
|
1631
|
+
/** Presses dropped since — the count the status shows. */
|
|
1632
|
+
dropped: require_clip_model_registry.number().int().min(0)
|
|
1633
|
+
});
|
|
1634
|
+
var NcAlarmInputBypasses = class {
|
|
1635
|
+
entries = [];
|
|
1636
|
+
/** Bypass one input. Idempotent: the same pair twice is one bypass. */
|
|
1637
|
+
add(request, at) {
|
|
1638
|
+
if (this.find(request.ruleId, request.deviceId) !== void 0) return "already-bypassed";
|
|
1639
|
+
if (this.entries.length >= NC_ALARM_INPUT_BYPASS_MAX) return "full";
|
|
1640
|
+
this.entries = [...this.entries, {
|
|
1641
|
+
ruleId: request.ruleId,
|
|
1642
|
+
ruleName: request.ruleName,
|
|
1643
|
+
deviceId: request.deviceId,
|
|
1644
|
+
cameraId: request.cameraId,
|
|
1645
|
+
at,
|
|
1646
|
+
dropped: 0
|
|
1647
|
+
}];
|
|
1648
|
+
return "bypassed";
|
|
1649
|
+
}
|
|
1650
|
+
/** True while this (rule, input) pair must not press the panel. */
|
|
1651
|
+
blocks(ruleId, deviceId) {
|
|
1652
|
+
return this.find(ruleId, deviceId) !== void 0;
|
|
1653
|
+
}
|
|
1654
|
+
/** Count one dropped press of a bypassed input; returns the new count, or null when not bypassed. */
|
|
1655
|
+
recordDrop(ruleId, deviceId) {
|
|
1656
|
+
const current = this.find(ruleId, deviceId);
|
|
1657
|
+
if (current === void 0) return null;
|
|
1658
|
+
const dropped = current.dropped + 1;
|
|
1659
|
+
this.entries = this.entries.map((e) => e === current ? {
|
|
1660
|
+
...e,
|
|
1661
|
+
dropped
|
|
1662
|
+
} : e);
|
|
1663
|
+
return dropped;
|
|
1664
|
+
}
|
|
1665
|
+
/** Every bypassed input, in the order it was bypassed. */
|
|
1666
|
+
snapshot() {
|
|
1667
|
+
return this.entries;
|
|
1668
|
+
}
|
|
1669
|
+
get size() {
|
|
1670
|
+
return this.entries.length;
|
|
1671
|
+
}
|
|
1672
|
+
/** Drop every bypass — the arm session ended. Returns what was dropped. */
|
|
1673
|
+
clear() {
|
|
1674
|
+
const dropped = this.entries;
|
|
1675
|
+
this.entries = [];
|
|
1676
|
+
return dropped;
|
|
1677
|
+
}
|
|
1678
|
+
/** Drop the bypasses `keep` refuses (a rule deleted, disabled or no longer naming the input). */
|
|
1679
|
+
prune(keep) {
|
|
1680
|
+
const removed = this.entries.filter((e) => !keep(e));
|
|
1681
|
+
if (removed.length > 0) this.entries = this.entries.filter((e) => keep(e));
|
|
1682
|
+
return removed;
|
|
1683
|
+
}
|
|
1684
|
+
/**
|
|
1685
|
+
* Rebuild from the persisted blob — defensively, per entry: an entry a later
|
|
1686
|
+
* release wrote in another shape is dropped, never the whole list. Returns
|
|
1687
|
+
* how many entries were dropped so the caller can say so.
|
|
1688
|
+
*/
|
|
1689
|
+
restore(raw) {
|
|
1690
|
+
if (!Array.isArray(raw)) {
|
|
1691
|
+
this.entries = [];
|
|
1692
|
+
return 0;
|
|
1693
|
+
}
|
|
1694
|
+
const kept = [];
|
|
1695
|
+
let dropped = 0;
|
|
1696
|
+
for (const item of raw) {
|
|
1697
|
+
const parsed = NcAlarmInputBypassRecordSchema.safeParse(item);
|
|
1698
|
+
if (!parsed.success || kept.length >= NC_ALARM_INPUT_BYPASS_MAX) {
|
|
1699
|
+
dropped += 1;
|
|
1700
|
+
continue;
|
|
1701
|
+
}
|
|
1702
|
+
if (kept.some((e) => e.ruleId === parsed.data.ruleId && e.deviceId === parsed.data.deviceId)) {
|
|
1703
|
+
dropped += 1;
|
|
1704
|
+
continue;
|
|
1705
|
+
}
|
|
1706
|
+
kept.push(parsed.data);
|
|
1707
|
+
}
|
|
1708
|
+
this.entries = kept;
|
|
1709
|
+
return dropped;
|
|
1710
|
+
}
|
|
1711
|
+
find(ruleId, deviceId) {
|
|
1712
|
+
return this.entries.find((e) => e.ruleId === ruleId && e.deviceId === deviceId);
|
|
1713
|
+
}
|
|
1714
|
+
};
|
|
1715
|
+
//#endregion
|
|
1583
1716
|
//#region src/notification-center/alarm/alarm-mode-delays.ts
|
|
1584
1717
|
/**
|
|
1585
1718
|
* The persisted per-mode delay overrides, read through a guard (D697).
|
|
@@ -1880,6 +2013,10 @@ function alarmPushDecision(phase, heard) {
|
|
|
1880
2013
|
kind: "suppress",
|
|
1881
2014
|
reason: "alarm-sensor-excluded"
|
|
1882
2015
|
} };
|
|
2016
|
+
case "bypassed": return { verdict: {
|
|
2017
|
+
kind: "suppress",
|
|
2018
|
+
reason: "alarm-input-bypassed"
|
|
2019
|
+
} };
|
|
1883
2020
|
case "triggered": return { verdict: { kind: "intrusion" } };
|
|
1884
2021
|
case "starts-entry-delay": return heard.pending ? { verdict: {
|
|
1885
2022
|
kind: "suppress",
|
|
@@ -2178,6 +2315,18 @@ var ncAlarmPanelSchema = require_clip_model_registry.object({
|
|
|
2178
2315
|
*/
|
|
2179
2316
|
exclusions: require_clip_model_registry.array(require_clip_model_registry.unknown()).default([]),
|
|
2180
2317
|
/**
|
|
2318
|
+
* The (rule, input) pairs the operator bypassed for the rest of THIS arm
|
|
2319
|
+
* session (`alarm-input-bypasses.ts`). Persisted for the exclusions' reason;
|
|
2320
|
+
* read back through a guard, never a cast.
|
|
2321
|
+
*/
|
|
2322
|
+
inputBypasses: require_clip_model_registry.array(require_clip_model_registry.unknown()).default([]),
|
|
2323
|
+
/**
|
|
2324
|
+
* When the current arm session began (the last arm). A bypass request about a
|
|
2325
|
+
* match older than this belongs to an arm that is over. Absent on an older
|
|
2326
|
+
* blob ⇒ no session check until the next arm.
|
|
2327
|
+
*/
|
|
2328
|
+
armSessionAt: require_clip_model_registry.number().optional(),
|
|
2329
|
+
/**
|
|
2181
2330
|
* Per arm mode, the covered devices whose being open does not refuse the arm
|
|
2182
2331
|
* (D630). Loose on purpose — `mode` is a string here — and read back through
|
|
2183
2332
|
* {@link nonBlockingFromPersisted}, never a cast: a blob written by a later
|
|
@@ -2436,6 +2585,8 @@ var NcAlarmPanelDevice = class extends require_clip_model_registry.BaseDevice {
|
|
|
2436
2585
|
attribution = new NcAlarmAttribution();
|
|
2437
2586
|
/** Sensors this arm is ignoring — see {@link NcAlarmExclusions}. */
|
|
2438
2587
|
exclusions = new NcAlarmExclusions();
|
|
2588
|
+
/** The inputs this arm session ignores — see {@link NcAlarmInputBypasses}. */
|
|
2589
|
+
bypasses = new NcAlarmInputBypasses();
|
|
2439
2590
|
/** Who answers "is anything open" — null until the centre wires itself in. */
|
|
2440
2591
|
armGuard = null;
|
|
2441
2592
|
/**
|
|
@@ -2920,6 +3071,11 @@ var NcAlarmPanelDevice = class extends require_clip_model_registry.BaseDevice {
|
|
|
2920
3071
|
kind: "excluded",
|
|
2921
3072
|
sensorDeviceId: intrusionDeviceOf(ref)
|
|
2922
3073
|
};
|
|
3074
|
+
if (this.bypasses.blocks(ref.ruleId, intrusionDeviceOf(ref))) return {
|
|
3075
|
+
kind: "bypassed",
|
|
3076
|
+
ruleId: ref.ruleId,
|
|
3077
|
+
sensorDeviceId: intrusionDeviceOf(ref)
|
|
3078
|
+
};
|
|
2923
3079
|
const mode = armModeOf(this.machine.target, NC_ALARM_MODES);
|
|
2924
3080
|
if (mode === null) return { kind: "none" };
|
|
2925
3081
|
const state = stateAt(this.machine, now);
|
|
@@ -3112,6 +3268,77 @@ var NcAlarmPanelDevice = class extends require_clip_model_registry.BaseDevice {
|
|
|
3112
3268
|
this.publish();
|
|
3113
3269
|
}
|
|
3114
3270
|
/**
|
|
3271
|
+
* "Bypass this input until disarm" — ONE (rule, input) pair stops pressing
|
|
3272
|
+
* the panel for the rest of this arm session.
|
|
3273
|
+
*
|
|
3274
|
+
* Refused on a disarmed panel (there is no session to bypass in) and for a
|
|
3275
|
+
* notification about an arm that is already over (`stale`). It never cancels
|
|
3276
|
+
* an entry delay or a siren already running — it stops the NEXT presses of
|
|
3277
|
+
* that input; the disarm stays the operator's own button.
|
|
3278
|
+
*/
|
|
3279
|
+
bypassInput(input, now = Date.now()) {
|
|
3280
|
+
const refuse = (reason) => {
|
|
3281
|
+
this.ctx.logger.info("alarm input bypass refused", {
|
|
3282
|
+
tags: { deviceId: input.deviceId },
|
|
3283
|
+
meta: {
|
|
3284
|
+
panelDeviceId: this.id,
|
|
3285
|
+
ruleId: input.ruleId,
|
|
3286
|
+
rule: input.ruleName,
|
|
3287
|
+
reason
|
|
3288
|
+
}
|
|
3289
|
+
});
|
|
3290
|
+
return {
|
|
3291
|
+
outcome: "refused",
|
|
3292
|
+
reason
|
|
3293
|
+
};
|
|
3294
|
+
};
|
|
3295
|
+
if (this.machine.target === "disarmed") return refuse("disarmed");
|
|
3296
|
+
const sessionAt = this.config.get("armSessionAt");
|
|
3297
|
+
if (input.notedAt !== void 0 && sessionAt !== void 0 && input.notedAt < sessionAt) return refuse("stale");
|
|
3298
|
+
const added = this.bypasses.add(input, now);
|
|
3299
|
+
if (added === "full") return refuse("full");
|
|
3300
|
+
if (added === "bypassed") {
|
|
3301
|
+
this.persist();
|
|
3302
|
+
this.ctx.logger.info("alarm input bypassed until disarm", {
|
|
3303
|
+
tags: { deviceId: input.deviceId },
|
|
3304
|
+
meta: {
|
|
3305
|
+
panelDeviceId: this.id,
|
|
3306
|
+
ruleId: input.ruleId,
|
|
3307
|
+
rule: input.ruleName,
|
|
3308
|
+
cameraId: input.cameraId,
|
|
3309
|
+
state: stateAt(this.machine, now),
|
|
3310
|
+
bypassed: this.bypasses.size
|
|
3311
|
+
}
|
|
3312
|
+
});
|
|
3313
|
+
}
|
|
3314
|
+
return {
|
|
3315
|
+
outcome: added,
|
|
3316
|
+
bypassed: this.bypasses.snapshot()
|
|
3317
|
+
};
|
|
3318
|
+
}
|
|
3319
|
+
/** The inputs this arm session ignores — what the live view reports. A read, never a copy. */
|
|
3320
|
+
inputBypasses() {
|
|
3321
|
+
return this.bypasses.snapshot();
|
|
3322
|
+
}
|
|
3323
|
+
/**
|
|
3324
|
+
* Drop the bypasses whose input is no longer an input — the rule was
|
|
3325
|
+
* deleted, disabled, or no longer names the device. Called from the centre's
|
|
3326
|
+
* rule-reload tick; one line per drop, tagged with the input.
|
|
3327
|
+
*/
|
|
3328
|
+
pruneInputBypasses(keep) {
|
|
3329
|
+
const removed = this.bypasses.prune(keep);
|
|
3330
|
+
if (removed.length === 0) return;
|
|
3331
|
+
this.persist();
|
|
3332
|
+
for (const entry of removed) this.ctx.logger.info("alarm input bypass dropped — the rule no longer has this input", {
|
|
3333
|
+
tags: { deviceId: entry.deviceId },
|
|
3334
|
+
meta: {
|
|
3335
|
+
panelDeviceId: this.id,
|
|
3336
|
+
ruleId: entry.ruleId,
|
|
3337
|
+
rule: entry.ruleName
|
|
3338
|
+
}
|
|
3339
|
+
});
|
|
3340
|
+
}
|
|
3341
|
+
/**
|
|
3115
3342
|
* One opening's reading (`open` / `secure`, D630's vocabulary — a cover is
|
|
3116
3343
|
* secure only when `closed`, a lock only when `locked`), from whoever saw
|
|
3117
3344
|
* it: the sensor's own event, or the centre's reconcile tick.
|
|
@@ -3311,6 +3538,8 @@ var NcAlarmPanelDevice = class extends require_clip_model_registry.BaseDevice {
|
|
|
3311
3538
|
this.persist();
|
|
3312
3539
|
}
|
|
3313
3540
|
const resolved = this.delaysFor(mode);
|
|
3541
|
+
this.endInputBypasses("arm");
|
|
3542
|
+
this.config.set("armSessionAt", now);
|
|
3314
3543
|
this.machine = arm(mode, resolved, now);
|
|
3315
3544
|
this.ctx.logger.info("alarm delays applied", {
|
|
3316
3545
|
tags: { deviceId: this.id },
|
|
@@ -3436,6 +3665,21 @@ var NcAlarmPanelDevice = class extends require_clip_model_registry.BaseDevice {
|
|
|
3436
3665
|
this.announceIgnoredTrigger(sensor, now, moved);
|
|
3437
3666
|
return "ignored-excluded";
|
|
3438
3667
|
}
|
|
3668
|
+
if (source !== null && sensor !== void 0 && this.bypasses.blocks(source.ruleId, sensor)) {
|
|
3669
|
+
const dropped = this.bypasses.recordDrop(source.ruleId, sensor);
|
|
3670
|
+
this.persist();
|
|
3671
|
+
this.ctx.logger.info("alarm trigger dropped — this input is bypassed until disarm", {
|
|
3672
|
+
tags: { deviceId: sensor },
|
|
3673
|
+
meta: {
|
|
3674
|
+
panelDeviceId: this.id,
|
|
3675
|
+
ruleId: source.ruleId,
|
|
3676
|
+
rule: source.ruleName,
|
|
3677
|
+
state: stateAt(this.machine, now),
|
|
3678
|
+
dropped
|
|
3679
|
+
}
|
|
3680
|
+
});
|
|
3681
|
+
return "ignored-bypassed";
|
|
3682
|
+
}
|
|
3439
3683
|
const armed = armModeOf(this.machine.target, NC_ALARM_MODES);
|
|
3440
3684
|
const resolved = this.delaysFor(armed);
|
|
3441
3685
|
const next = trigger(this.machine, resolved, now);
|
|
@@ -3495,6 +3739,7 @@ var NcAlarmPanelDevice = class extends require_clip_model_registry.BaseDevice {
|
|
|
3495
3739
|
publish(now = Date.now()) {
|
|
3496
3740
|
const autoCleared = this.machine.clearAt !== void 0 && now >= this.machine.clearAt && this.machine.clearToDisarmed === true;
|
|
3497
3741
|
this.machine = settle(this.machine, now);
|
|
3742
|
+
if (this.machine.target === "disarmed") this.endInputBypasses("disarm");
|
|
3498
3743
|
const state = stateAt(this.machine, now);
|
|
3499
3744
|
this.runtimeState.setCapState("alarm-panel", this.status(now));
|
|
3500
3745
|
this.persist();
|
|
@@ -3743,9 +3988,31 @@ var NcAlarmPanelDevice = class extends require_clip_model_registry.BaseDevice {
|
|
|
3743
3988
|
persist() {
|
|
3744
3989
|
this.config.set("machine", { ...this.machine });
|
|
3745
3990
|
this.config.set("exclusions", [...this.exclusions.snapshot()]);
|
|
3991
|
+
this.config.set("inputBypasses", [...this.bypasses.snapshot()]);
|
|
3992
|
+
}
|
|
3993
|
+
/** The session is over: drop every bypass, one line per input. */
|
|
3994
|
+
endInputBypasses(why) {
|
|
3995
|
+
const ended = this.bypasses.clear();
|
|
3996
|
+
if (ended.length === 0) return;
|
|
3997
|
+
this.config.set("inputBypasses", []);
|
|
3998
|
+
for (const entry of ended) this.ctx.logger.info("alarm input bypass ended with the arm session — watched again", {
|
|
3999
|
+
tags: { deviceId: entry.deviceId },
|
|
4000
|
+
meta: {
|
|
4001
|
+
panelDeviceId: this.id,
|
|
4002
|
+
ruleId: entry.ruleId,
|
|
4003
|
+
rule: entry.ruleName,
|
|
4004
|
+
why,
|
|
4005
|
+
dropped: entry.dropped
|
|
4006
|
+
}
|
|
4007
|
+
});
|
|
3746
4008
|
}
|
|
3747
4009
|
restore() {
|
|
3748
4010
|
this.exclusions.restore(this.config.get("exclusions"));
|
|
4011
|
+
const unreadable = this.bypasses.restore(this.config.get("inputBypasses"));
|
|
4012
|
+
if (unreadable > 0) this.ctx.logger.info("alarm input bypasses: persisted entries this release cannot read were dropped", {
|
|
4013
|
+
tags: { deviceId: this.id },
|
|
4014
|
+
meta: { dropped: unreadable }
|
|
4015
|
+
});
|
|
3749
4016
|
const restored = machineFromPersisted(this.config.get("machine"));
|
|
3750
4017
|
if (restored === null) return;
|
|
3751
4018
|
this.machine = restored;
|
|
@@ -3799,6 +4066,9 @@ function ncAlarmPanelPort(panel) {
|
|
|
3799
4066
|
excludedDevices: () => panel.excludedDevices(),
|
|
3800
4067
|
exclusionPhases: () => panel.exclusionPhases(),
|
|
3801
4068
|
forgetExcluded: (deviceId) => panel.forgetExcluded(deviceId),
|
|
4069
|
+
bypassInput: (input) => panel.bypassInput(input),
|
|
4070
|
+
inputBypasses: () => panel.inputBypasses(),
|
|
4071
|
+
pruneInputBypasses: (keep) => panel.pruneInputBypasses(keep),
|
|
3802
4072
|
currentState: () => panel.currentState(),
|
|
3803
4073
|
currentTarget: () => panel.currentTarget(),
|
|
3804
4074
|
armWith: (mode) => panel.armWith(mode),
|
|
@@ -5374,6 +5644,53 @@ async function notificationLinkEndpoints(api, logger) {
|
|
|
5374
5644
|
});
|
|
5375
5645
|
}
|
|
5376
5646
|
//#endregion
|
|
5647
|
+
//#region src/notification-center/tracks-at-instant.ts
|
|
5648
|
+
/**
|
|
5649
|
+
* How many persisted rows the wiring reads per instant. A scene holds a handful
|
|
5650
|
+
* of subjects; the read is newest-first from the instant, so this bounds a
|
|
5651
|
+
* busy street without missing the tracks that were on screen.
|
|
5652
|
+
*/
|
|
5653
|
+
var NC_TRACKS_AT_INSTANT_LIMIT = 25;
|
|
5654
|
+
/**
|
|
5655
|
+
* Every source track with a position within `toleranceMs` of `timestamp`, its
|
|
5656
|
+
* nearest box normalised, deduplicated by track id (a live copy and a
|
|
5657
|
+
* persisted copy of one track are one track). Pure.
|
|
5658
|
+
*/
|
|
5659
|
+
function tracksAtInstant(sources, timestamp, toleranceMs, fallbackDims) {
|
|
5660
|
+
const out = /* @__PURE__ */ new Map();
|
|
5661
|
+
for (const track of sources) {
|
|
5662
|
+
if (out.has(track.trackId)) continue;
|
|
5663
|
+
if (track.firstSeen > timestamp + toleranceMs || track.lastSeen < timestamp - toleranceMs) continue;
|
|
5664
|
+
const w = track.frameWidth ?? fallbackDims?.w ?? 0;
|
|
5665
|
+
const h = track.frameHeight ?? fallbackDims?.h ?? 0;
|
|
5666
|
+
if (w <= 0 || h <= 0) continue;
|
|
5667
|
+
let best = null;
|
|
5668
|
+
let bestDelta = Number.POSITIVE_INFINITY;
|
|
5669
|
+
for (const p of track.positions) {
|
|
5670
|
+
const delta = Math.abs(p.timestamp - timestamp);
|
|
5671
|
+
if (delta < bestDelta) {
|
|
5672
|
+
bestDelta = delta;
|
|
5673
|
+
best = p;
|
|
5674
|
+
}
|
|
5675
|
+
}
|
|
5676
|
+
if (best === null || bestDelta > toleranceMs) continue;
|
|
5677
|
+
const label = track.subLabel ?? track.label;
|
|
5678
|
+
out.set(track.trackId, {
|
|
5679
|
+
trackId: track.trackId,
|
|
5680
|
+
className: track.className,
|
|
5681
|
+
...label !== void 0 && label !== track.className ? { label } : {},
|
|
5682
|
+
...best.score !== void 0 ? { confidence: best.score } : {},
|
|
5683
|
+
bbox: {
|
|
5684
|
+
x: best.bbox.x / w,
|
|
5685
|
+
y: best.bbox.y / h,
|
|
5686
|
+
w: best.bbox.w / w,
|
|
5687
|
+
h: best.bbox.h / h
|
|
5688
|
+
}
|
|
5689
|
+
});
|
|
5690
|
+
}
|
|
5691
|
+
return [...out.values()];
|
|
5692
|
+
}
|
|
5693
|
+
//#endregion
|
|
5377
5694
|
//#region src/notification-center/artifact-read-plane.ts
|
|
5378
5695
|
/**
|
|
5379
5696
|
* Parse a single-range `Range` header against a known length.
|
|
@@ -23075,7 +23392,7 @@ function referenceOf(boxes) {
|
|
|
23075
23392
|
function displaced(ref, b) {
|
|
23076
23393
|
return Math.hypot(b.x + b.w / 2 - ref.x, b.y + b.h / 2 - ref.y) > ref.span;
|
|
23077
23394
|
}
|
|
23078
|
-
function judge(ref, run, b) {
|
|
23395
|
+
function judge$1(ref, run, b) {
|
|
23079
23396
|
const next = displaced(ref, b) ? run + 1 : 0;
|
|
23080
23397
|
return {
|
|
23081
23398
|
early: [],
|
|
@@ -23095,7 +23412,7 @@ function judgeAll(boxes) {
|
|
|
23095
23412
|
};
|
|
23096
23413
|
for (const b of boxes) {
|
|
23097
23414
|
if (probe.moved) return probe;
|
|
23098
|
-
probe = judge(ref, probe.run, b);
|
|
23415
|
+
probe = judge$1(ref, probe.run, b);
|
|
23099
23416
|
}
|
|
23100
23417
|
return probe;
|
|
23101
23418
|
}
|
|
@@ -23110,7 +23427,7 @@ function isMeasurableBox(box) {
|
|
|
23110
23427
|
function stepMovedProbe(probe, box) {
|
|
23111
23428
|
if (probe.moved || !isMeasurableBox(box)) return probe;
|
|
23112
23429
|
if (probe.reference !== null) {
|
|
23113
|
-
const next = judge(probe.reference, probe.run, box);
|
|
23430
|
+
const next = judge$1(probe.reference, probe.run, box);
|
|
23114
23431
|
return next.run === probe.run && !next.moved ? probe : next;
|
|
23115
23432
|
}
|
|
23116
23433
|
const early = [...probe.early, box];
|
|
@@ -26221,6 +26538,11 @@ var MAX_ARM_BUTTONS = 3;
|
|
|
26221
26538
|
* | `alarm-arm-refused` | **Arma comunque**, bound to the row's refusal |
|
|
26222
26539
|
* | `alarm-sensor-bypass` | nothing — it reports, there is nothing to undo |
|
|
26223
26540
|
*
|
|
26541
|
+
* `alarm-triggered` and `alarm-pending` rows a RULE caused also carry **Bypass
|
|
26542
|
+
* this input**, bound to the row's (rule, input) pair: that one input stops
|
|
26543
|
+
* pressing the panel until the next disarm. It never cancels the countdown or
|
|
26544
|
+
* the siren already running — Disarm beside it does that.
|
|
26545
|
+
*
|
|
26224
26546
|
* The `alarm-arming` row is the one that earns its keep: that notification
|
|
26225
26547
|
* arrives at the START of the exit delay, so its Disarm is the only alarm
|
|
26226
26548
|
* button an operator can press to cancel something rather than to undo it.
|
|
@@ -26257,7 +26579,7 @@ function buildAlarmButtons(input) {
|
|
|
26257
26579
|
};
|
|
26258
26580
|
});
|
|
26259
26581
|
const actionId = "alarm-disarm";
|
|
26260
|
-
|
|
26582
|
+
const disarm = {
|
|
26261
26583
|
id: actionId,
|
|
26262
26584
|
label: input.labels.disarm,
|
|
26263
26585
|
url: input.mintUrl({
|
|
@@ -26267,6 +26589,19 @@ function buildAlarmButtons(input) {
|
|
|
26267
26589
|
icon: "disarm",
|
|
26268
26590
|
destructive: true,
|
|
26269
26591
|
mode: "background"
|
|
26592
|
+
};
|
|
26593
|
+
const bypassable = input.bypassable;
|
|
26594
|
+
if (bypassable === void 0 || input.kind !== "alarm-triggered" && input.kind !== "alarm-pending") return [disarm];
|
|
26595
|
+
const bypassId = "alarm-bypass-input";
|
|
26596
|
+
return [disarm, {
|
|
26597
|
+
id: bypassId,
|
|
26598
|
+
label: input.labels.bypassInput,
|
|
26599
|
+
url: input.mintUrl({
|
|
26600
|
+
actionId: bypassId,
|
|
26601
|
+
action: "bypass-input",
|
|
26602
|
+
...bypassable
|
|
26603
|
+
}),
|
|
26604
|
+
mode: "background"
|
|
26270
26605
|
}];
|
|
26271
26606
|
}
|
|
26272
26607
|
/**
|
|
@@ -26501,6 +26836,14 @@ function buildAlarmLive(input) {
|
|
|
26501
26836
|
deviceId: e.deviceId,
|
|
26502
26837
|
name: input.name(e.deviceId),
|
|
26503
26838
|
phase: e.phase
|
|
26839
|
+
})),
|
|
26840
|
+
bypassedInputs: input.bypasses.map((b) => ({
|
|
26841
|
+
ruleId: b.ruleId,
|
|
26842
|
+
ruleName: input.ruleName(b.ruleId) ?? b.ruleName,
|
|
26843
|
+
deviceId: b.deviceId,
|
|
26844
|
+
name: input.name(b.deviceId),
|
|
26845
|
+
since: b.at,
|
|
26846
|
+
dropped: b.dropped
|
|
26504
26847
|
}))
|
|
26505
26848
|
};
|
|
26506
26849
|
}
|
|
@@ -28726,78 +29069,31 @@ function isCoolingDown(rule, lastFiredAt, now) {
|
|
|
28726
29069
|
return now - lastFiredAt < rule.throttle.cooldownSec * 1e3;
|
|
28727
29070
|
}
|
|
28728
29071
|
/**
|
|
28729
|
-
* The media policy
|
|
28730
|
-
*
|
|
28731
|
-
*
|
|
28732
|
-
*
|
|
28733
|
-
*
|
|
28734
|
-
* none of to chase. No zone crop, no gif, no clip, no profile — a sensor
|
|
28735
|
-
* subject has no zone condition to crop to and no reason to animate.
|
|
28736
|
-
*/
|
|
28737
|
-
var NC_SENSOR_MEDIA_POLICY = { attach: "keyFrame" };
|
|
28738
|
-
/**
|
|
28739
|
-
* The media policy actually used to build a delivery, for a rule of this
|
|
28740
|
-
* KIND. A sensor rule's STORED `media` is never read at evaluation — the
|
|
28741
|
-
* editor offers no media control for one any more, and `media.attach` /
|
|
28742
|
-
* `.gif` / `.clip` / `.profile` on an existing sensor rule (authored before
|
|
28743
|
-
* this ruling, from the viewer or the API) are IGNORED here, never refused at
|
|
28744
|
-
* load. See {@link mediaPolicyMatchesSensorDefault} for the sampled-log gate.
|
|
29072
|
+
* The media policy a delivery is built from: the rule's own, with its picture
|
|
29073
|
+
* mode RESOLVED (`resolveNcMediaImage`) — a sensor rule included. Until D897 a
|
|
29074
|
+
* sensor rule's stored media was ignored for a fixed one-snapshot policy; it
|
|
29075
|
+
* now goes through the same selector and the same render stage as a detection
|
|
29076
|
+
* rule, its source frame being the linked camera's.
|
|
28745
29077
|
*/
|
|
28746
29078
|
function effectiveMediaPolicy(rule, kind) {
|
|
28747
|
-
return
|
|
28748
|
-
|
|
28749
|
-
|
|
28750
|
-
|
|
28751
|
-
* gate the boot/evaluation path uses to decide whether an existing sensor
|
|
28752
|
-
* rule's stored choice is being silently overridden (and so is worth a
|
|
28753
|
-
* sampled log line) or already matches and there is nothing to say.
|
|
28754
|
-
*/
|
|
28755
|
-
function mediaPolicyMatchesSensorDefault(media) {
|
|
28756
|
-
return media.attach === NC_SENSOR_MEDIA_POLICY.attach && media.frame === void 0 && media.zoneCrop !== true && media.gif !== true && media.clip !== true && media.profile === void 0;
|
|
29079
|
+
return {
|
|
29080
|
+
...rule.media,
|
|
29081
|
+
image: require_clip_model_registry.resolveNcMediaImage(rule.media, kind)
|
|
29082
|
+
};
|
|
28757
29083
|
}
|
|
28758
|
-
/** `effectiveMediaPolicy`, resolving the kind itself — the call-site shortcut
|
|
28759
|
-
* every production caller uses; `effectiveMediaPolicy` stays the unit under
|
|
28760
|
-
* test because `ruleKindOf` needs no fixture of its own there. */
|
|
29084
|
+
/** `effectiveMediaPolicy`, resolving the kind itself — the call-site shortcut. */
|
|
28761
29085
|
function effectiveMediaPolicyForRule(rule) {
|
|
28762
29086
|
return effectiveMediaPolicy(rule, require_clip_model_registry.ruleKindOf(rule));
|
|
28763
29087
|
}
|
|
28764
|
-
/**
|
|
28765
|
-
|
|
28766
|
-
|
|
28767
|
-
* (small variant first — the degrade engine enforces per-backend byte
|
|
28768
|
-
* caps, but there is no reason to ship a native 4K frame by default).
|
|
28769
|
-
*/
|
|
28770
|
-
function attachmentKindPreference(policy, ownerKind) {
|
|
28771
|
-
if (policy === "keyFrame") return ownerKind === "track" ? [
|
|
28772
|
-
"keyFrameSmall",
|
|
28773
|
-
"keyFrame",
|
|
28774
|
-
SCENE_TWIN_KIND,
|
|
28775
|
-
ROLLING_SCENE_TWIN_KIND,
|
|
28776
|
-
"fullFrameBoxed",
|
|
28777
|
-
"firstFrame",
|
|
28778
|
-
"lastFrame",
|
|
28779
|
-
"snapshot"
|
|
28780
|
-
] : [
|
|
28781
|
-
"fullFrameBoxed",
|
|
28782
|
-
"fullFrame",
|
|
28783
|
-
"crop"
|
|
28784
|
-
];
|
|
28785
|
-
return ownerKind === "track" ? [
|
|
28786
|
-
"thumbnail",
|
|
28787
|
-
"thumbnailSmall",
|
|
28788
|
-
"crop",
|
|
28789
|
-
"keyFrameSmall",
|
|
28790
|
-
"firstFrame"
|
|
28791
|
-
] : [
|
|
28792
|
-
"crop",
|
|
28793
|
-
"thumbnail",
|
|
28794
|
-
"fullFrameBoxed"
|
|
28795
|
-
];
|
|
29088
|
+
/** True when a rule ships no still at all. */
|
|
29089
|
+
function ruleShipsNoStill(rule) {
|
|
29090
|
+
return effectiveMediaPolicyForRule(rule).image === "none";
|
|
28796
29091
|
}
|
|
28797
29092
|
/**
|
|
28798
29093
|
* The media kinds whose stored bytes carry a BURNT-IN detection box.
|
|
28799
29094
|
*
|
|
28800
|
-
* This is the family boundary for {@link
|
|
29095
|
+
* This is the family boundary for {@link cleanSourcePreference} (D897; the retired
|
|
29096
|
+
* `framePreference` before it), and it exists as one
|
|
28801
29097
|
* exported constant because the alternative was tried and failed in production:
|
|
28802
29098
|
* the `boxed` ladder listed `snapshot` / `lastFrame` / `firstFrame` as *"the
|
|
28803
29099
|
* kinds that actually carry a burnt-in box today"* while, eleven lines below,
|
|
@@ -28813,7 +29109,7 @@ function attachmentKindPreference(policy, ownerKind) {
|
|
|
28813
29109
|
*
|
|
28814
29110
|
* ## Why `full` still ends with three of these kinds (D654)
|
|
28815
29111
|
*
|
|
28816
|
-
* `framePreference('full', 'track')` used to reach these kinds as soon as the
|
|
29112
|
+
* The retired `framePreference('full', 'track')` used to reach these kinds as soon as the
|
|
28817
29113
|
* key frame was out of band, so a rule asking for the clean scene was answered
|
|
28818
29114
|
* with an annotated one: of 38 `frame:'full'` deliveries sha256-matched back to
|
|
28819
29115
|
* stored media, **19 (50%) were `firstFrame` / `snapshot` / `lastFrame`**.
|
|
@@ -28876,90 +29172,36 @@ function isAnnotatedSceneKind(kind) {
|
|
|
28876
29172
|
return ANNOTATED_SCENE_KINDS.includes(kind);
|
|
28877
29173
|
}
|
|
28878
29174
|
/**
|
|
28879
|
-
* The
|
|
28880
|
-
*
|
|
28881
|
-
*
|
|
28882
|
-
* is
|
|
28883
|
-
*
|
|
28884
|
-
*
|
|
28885
|
-
*
|
|
28886
|
-
*
|
|
28887
|
-
*
|
|
28888
|
-
*
|
|
28889
|
-
*
|
|
28890
|
-
*
|
|
28891
|
-
*
|
|
28892
|
-
|
|
28893
|
-
|
|
28894
|
-
* that decides nothing is worse than no knob: the operator set it and believed
|
|
28895
|
-
* they had asked for something.
|
|
28896
|
-
*
|
|
28897
|
-
* The annotated frames were there the whole time under different names.
|
|
28898
|
-
* `firstFrame` / `snapshot` / `lastFrame` are written by `boxedTimelineFrame`
|
|
28899
|
-
* — the track's OWN box burned onto the native full frame — so `boxed` now asks
|
|
28900
|
-
* for them, newest-moment-first-per-family, before degrading to the clean
|
|
28901
|
-
* scene. No new capture path, no new kind, and the option answers its own
|
|
28902
|
-
* question again.
|
|
28903
|
-
*
|
|
28904
|
-
* The strictness is about FAMILY, and the family boundary is
|
|
28905
|
-
* {@link ANNOTATED_SCENE_KINDS} — ONE declaration, consumed by both ladders, so
|
|
28906
|
-
* `boxed` and `full` can never again hold different opinions about the same
|
|
28907
|
-
* kind. They did, and it shipped: see that constant.
|
|
28908
|
-
*/
|
|
28909
|
-
function framePreference(frame, ownerKind) {
|
|
28910
|
-
if (frame === "cropped") return ownerKind === "track" ? [
|
|
28911
|
-
"thumbnail",
|
|
28912
|
-
"thumbnailSmall",
|
|
28913
|
-
"crop"
|
|
28914
|
-
] : [
|
|
28915
|
-
"crop",
|
|
28916
|
-
"thumbnail",
|
|
28917
|
-
"thumbnailSmall"
|
|
28918
|
-
];
|
|
28919
|
-
if (frame === "boxed") return [
|
|
28920
|
-
...ANNOTATED_SCENE_KINDS,
|
|
28921
|
-
...CLEAN_SCENE_KINDS,
|
|
28922
|
-
SCENE_TWIN_KIND,
|
|
28923
|
-
ROLLING_SCENE_TWIN_KIND
|
|
28924
|
-
];
|
|
29175
|
+
* The SOURCE ladder of every notification picture (D897): the clean whole-scene
|
|
29176
|
+
* kinds of an owner, best first, then — LAST — the annotated tiles, for a
|
|
29177
|
+
* track that holds no clean frame at all (written before the D654 twin, or a
|
|
29178
|
+
* twin write that failed). The picture is rendered from what this picks, by
|
|
29179
|
+
* mode (`notification-image.ts`), so a rule no longer chooses a stored KIND:
|
|
29180
|
+
* `crop` cuts the subject out of the clean frame, `scene-boxes` draws on it.
|
|
29181
|
+
*
|
|
29182
|
+
* `keyFrame` leads because it is the native best-detection frame — the crop
|
|
29183
|
+
* keeps its resolution; the scene modes downscale it once at render. The twins
|
|
29184
|
+
* (`sceneFrame` at a boxed tile's instant, `lastSceneFrame` at the rolling
|
|
29185
|
+
* one) are what MOMENT finds near the trigger when the best shot is later.
|
|
29186
|
+
* A still-shelf photograph holds `keyFrameSmall` / `keyFrame` / `fullFrame`,
|
|
29187
|
+
* so a sound or a linked-camera still resolves through the same ladder.
|
|
29188
|
+
*/
|
|
29189
|
+
function cleanSourcePreference(ownerKind) {
|
|
28925
29190
|
return ownerKind === "track" ? [
|
|
28926
29191
|
"keyFrame",
|
|
28927
29192
|
"keyFrameSmall",
|
|
28928
29193
|
SCENE_TWIN_KIND,
|
|
28929
29194
|
ROLLING_SCENE_TWIN_KIND,
|
|
29195
|
+
"fullFrame",
|
|
28930
29196
|
"firstFrame",
|
|
28931
29197
|
"lastFrame",
|
|
28932
|
-
"snapshot"
|
|
29198
|
+
"snapshot",
|
|
29199
|
+
"fullFrameBoxed"
|
|
28933
29200
|
] : [
|
|
28934
29201
|
"fullFrame",
|
|
28935
29202
|
"keyFrame",
|
|
28936
|
-
"keyFrameSmall"
|
|
28937
|
-
|
|
28938
|
-
}
|
|
28939
|
-
/**
|
|
28940
|
-
* Derive the `best-matching` media signal from a matched rule's condition
|
|
28941
|
-
* summary ({@link NcEvaluation.matchedOn}). Identity takes priority over plate
|
|
28942
|
-
* (D-3 ordering: a face rule that ALSO plate-matched attaches the face crop).
|
|
28943
|
-
*/
|
|
28944
|
-
function matchSignal(matchedOn) {
|
|
28945
|
-
if (matchedOn === void 0) return null;
|
|
28946
|
-
if (matchedOn.includes("identities")) return "face";
|
|
28947
|
-
if (matchedOn.includes("plates")) return "plate";
|
|
28948
|
-
return null;
|
|
28949
|
-
}
|
|
28950
|
-
/**
|
|
28951
|
-
* Ordered media-kind preference for a `best-matching` attachment. The
|
|
28952
|
-
* signal-specific crop (`faceCrop` / `plateCrop`, both event-owned) leads;
|
|
28953
|
-
* then the plain `best` subject ladder, then the `keyFrame` clean-scene
|
|
28954
|
-
* ladder — so a missing specific crop degrades to best → keyFrame → none
|
|
28955
|
-
* (the dispatcher returns null when nothing resolves) without ever blocking
|
|
28956
|
-
* the send. A `null` signal is exactly the `best` ladder.
|
|
28957
|
-
*/
|
|
28958
|
-
function bestMatchingKindPreference(signal, ownerKind) {
|
|
28959
|
-
return [
|
|
28960
|
-
...signal === "face" ? ["faceCrop"] : signal === "plate" ? ["plateCrop"] : [],
|
|
28961
|
-
...attachmentKindPreference("best", ownerKind),
|
|
28962
|
-
...attachmentKindPreference("keyFrame", ownerKind)
|
|
29203
|
+
"keyFrameSmall",
|
|
29204
|
+
"fullFrameBoxed"
|
|
28963
29205
|
];
|
|
28964
29206
|
}
|
|
28965
29207
|
//#endregion
|
|
@@ -31354,7 +31596,8 @@ var NcGroupMemberFateSchema = require_clip_model_registry.union([require_clip_mo
|
|
|
31354
31596
|
"close-failed",
|
|
31355
31597
|
"alarm-entry-delay-started",
|
|
31356
31598
|
"alarm-entry-delay",
|
|
31357
|
-
"alarm-sensor-excluded"
|
|
31599
|
+
"alarm-sensor-excluded",
|
|
31600
|
+
"alarm-input-bypassed"
|
|
31358
31601
|
]), require_clip_model_registry.templateLiteral(["failed-condition:", require_clip_model_registry.string()])]);
|
|
31359
31602
|
/** Read cap, so a client cannot ask for the whole ring in one call. */
|
|
31360
31603
|
var MAX_QUERY_LIMIT = 200;
|
|
@@ -31666,6 +31909,8 @@ var NcPruneRecordSchema = require_clip_model_registry.object({
|
|
|
31666
31909
|
});
|
|
31667
31910
|
/** Rules × deleted subjects — small; a generous ceiling. */
|
|
31668
31911
|
var LOAD_LIMIT$1 = 1e4;
|
|
31912
|
+
/** Page size of the load's records query. */
|
|
31913
|
+
var LOAD_PAGE = 1e3;
|
|
31669
31914
|
function keyOf$1(row) {
|
|
31670
31915
|
return `${row.kind}:${row.ruleId}:${row.field}:${row.subject}`;
|
|
31671
31916
|
}
|
|
@@ -31691,18 +31936,23 @@ NC_DEVICE_PRUNES_SPEC.fromRecord, NC_DEVICE_PRUNES_SPEC.toValue, NC_DEVICE_PRUNE
|
|
|
31691
31936
|
var KEPT_OUTCOMES = new Set(["kept-disabled", "kept"]);
|
|
31692
31937
|
var NcPruneLedger = class {
|
|
31693
31938
|
ledger;
|
|
31694
|
-
|
|
31939
|
+
/**
|
|
31940
|
+
* The SAME handle the ledger writes through — the records kind wherever
|
|
31941
|
+
* `ctx.records` exists. The load reads it, never the legacy table: no boot
|
|
31942
|
+
* declares that table any more, so a direct read of it fails forever.
|
|
31943
|
+
*/
|
|
31944
|
+
kind;
|
|
31695
31945
|
logger;
|
|
31696
31946
|
loaded = false;
|
|
31697
31947
|
/** Bumped on every {@link record} write — a load ignores keys written after it began. */
|
|
31698
31948
|
writes = 0;
|
|
31699
31949
|
writtenAt = /* @__PURE__ */ new Map();
|
|
31700
31950
|
constructor(deps) {
|
|
31701
|
-
this.store = deps.store;
|
|
31702
31951
|
this.logger = deps.logger;
|
|
31952
|
+
this.kind = resolveDurableKind(NC_DEVICE_PRUNES_SPEC, ncDurableRecordKinds["device-prunes"], deps);
|
|
31703
31953
|
this.ledger = new DurableLedger({
|
|
31704
31954
|
spec: NC_DEVICE_PRUNES_SPEC,
|
|
31705
|
-
kind:
|
|
31955
|
+
kind: this.kind,
|
|
31706
31956
|
logger: deps.logger
|
|
31707
31957
|
});
|
|
31708
31958
|
}
|
|
@@ -31718,15 +31968,7 @@ var NcPruneLedger = class {
|
|
|
31718
31968
|
const startedAt = this.writes;
|
|
31719
31969
|
const writtenSince = (key) => (this.writtenAt.get(key) ?? 0) > startedAt;
|
|
31720
31970
|
try {
|
|
31721
|
-
const
|
|
31722
|
-
collection: NC_DEVICE_PRUNES_COLLECTION,
|
|
31723
|
-
filter: { limit: LOAD_LIMIT$1 }
|
|
31724
|
-
});
|
|
31725
|
-
const next = /* @__PURE__ */ new Map();
|
|
31726
|
-
for (const record of records) {
|
|
31727
|
-
const row = NC_DEVICE_PRUNES_SPEC.fromRecord(record.id, record.data);
|
|
31728
|
-
if (row !== null) next.set(keyOf$1(row), row);
|
|
31729
|
-
}
|
|
31971
|
+
const next = await this.readAll();
|
|
31730
31972
|
for (const key of heldBefore) if (!next.has(key) && !writtenSince(key)) this.ledger.evict(key);
|
|
31731
31973
|
for (const [key, row] of next) if (!writtenSince(key)) this.ledger.stage(row);
|
|
31732
31974
|
this.loaded = true;
|
|
@@ -31738,6 +31980,26 @@ var NcPruneLedger = class {
|
|
|
31738
31980
|
} });
|
|
31739
31981
|
}
|
|
31740
31982
|
}
|
|
31983
|
+
/** Every row the kind holds, paged. THROWS on a failed page — `load` is the guard. */
|
|
31984
|
+
async readAll() {
|
|
31985
|
+
const next = /* @__PURE__ */ new Map();
|
|
31986
|
+
let seen = 0;
|
|
31987
|
+
let cursor;
|
|
31988
|
+
while (seen < LOAD_LIMIT$1) {
|
|
31989
|
+
const page = await this.kind.query({
|
|
31990
|
+
limit: Math.min(LOAD_PAGE, LOAD_LIMIT$1 - seen),
|
|
31991
|
+
...cursor === void 0 ? {} : { cursor }
|
|
31992
|
+
});
|
|
31993
|
+
seen += page.records.length;
|
|
31994
|
+
for (const record of page.records) {
|
|
31995
|
+
const row = NC_DEVICE_PRUNES_SPEC.fromRecord(record.id, record.data);
|
|
31996
|
+
if (row !== null) next.set(keyOf$1(row), row);
|
|
31997
|
+
}
|
|
31998
|
+
if (page.nextCursor === null || page.records.length === 0) break;
|
|
31999
|
+
cursor = page.nextCursor;
|
|
32000
|
+
}
|
|
32001
|
+
return next;
|
|
32002
|
+
}
|
|
31741
32003
|
/** Has a load ever SUCCEEDED? The pruner acts on nothing until it has. */
|
|
31742
32004
|
isLoaded() {
|
|
31743
32005
|
return this.loaded;
|
|
@@ -32141,6 +32403,7 @@ var en_default = {
|
|
|
32141
32403
|
},
|
|
32142
32404
|
"button.alarm.disarm": "Disarm",
|
|
32143
32405
|
"button.alarm.armAnyway": "Arm anyway",
|
|
32406
|
+
"button.alarm.bypassInput": "Bypass until disarm",
|
|
32144
32407
|
"button.alarm.arm.home": "Arm home",
|
|
32145
32408
|
"button.alarm.arm.away": "Arm away",
|
|
32146
32409
|
"button.alarm.arm.night": "Arm night",
|
|
@@ -32527,6 +32790,7 @@ var it_default = {
|
|
|
32527
32790
|
},
|
|
32528
32791
|
"button.alarm.disarm": "Disarma",
|
|
32529
32792
|
"button.alarm.armAnyway": "Arma comunque",
|
|
32793
|
+
"button.alarm.bypassInput": "Escludi fino al disinserimento",
|
|
32530
32794
|
"button.alarm.arm.home": "Arma casa",
|
|
32531
32795
|
"button.alarm.arm.away": "Arma fuori",
|
|
32532
32796
|
"button.alarm.arm.night": "Arma notte",
|
|
@@ -33995,6 +34259,65 @@ function sameConfirm(a, b) {
|
|
|
33995
34259
|
return a?.enabled === true === (b?.enabled === true) && a?.expect?.op === b?.expect?.op && a?.expect?.count === b?.expect?.count;
|
|
33996
34260
|
}
|
|
33997
34261
|
//#endregion
|
|
34262
|
+
//#region src/notification-center/media-guard.ts
|
|
34263
|
+
/**
|
|
34264
|
+
* The hub's half of D897: a rule's picture is ONE mode (`media.image`) plus
|
|
34265
|
+
* `showTriggerZone`, and both are judged against the rule they are saved on.
|
|
34266
|
+
*
|
|
34267
|
+
* - `showTriggerZone` draws the zone that FIRED the rule. A rule whose trigger
|
|
34268
|
+
* is not a zone (no `zones` condition, no occupancy zone) has nothing to
|
|
34269
|
+
* draw, so the flag is refused rather than stored as a knob that does
|
|
34270
|
+
* nothing.
|
|
34271
|
+
* - `image` must be a mode the rule's kind can honour
|
|
34272
|
+
* (`imageModesForKind`): a sound owns no region of the frame, so it is never
|
|
34273
|
+
* offered `crop`.
|
|
34274
|
+
*
|
|
34275
|
+
* The predicate is `ncMediaPolicyError` (`@camstack/types`), read by the admin
|
|
34276
|
+
* editor too. It runs on both save routes — the cap provider
|
|
34277
|
+
* (`notificationRules.createRule/updateRule`) and the rule bridge
|
|
34278
|
+
* (`nc.createRule/updateRule`) — beside the other guards.
|
|
34279
|
+
*
|
|
34280
|
+
* Only media the CALLER sent is judged: a create's `media`, an update's
|
|
34281
|
+
* `media`, against the MERGED delivery and conditions. A stored rule is never
|
|
34282
|
+
* judged on load — `normalizeNcMediaPolicy` drops a flag it cannot draw there,
|
|
34283
|
+
* because a refusal at load would cost the rule its notifications.
|
|
34284
|
+
*
|
|
34285
|
+
* Not in Zod for the reason `rule-family-guard.ts` gives: a refine would also
|
|
34286
|
+
* run when the store LOADS a rule.
|
|
34287
|
+
*/
|
|
34288
|
+
var NcMediaPolicyRefusal = class extends Error {
|
|
34289
|
+
reason;
|
|
34290
|
+
constructor(reason) {
|
|
34291
|
+
super(`rule refused: ${reason}`);
|
|
34292
|
+
this.name = "NcMediaPolicyRefusal";
|
|
34293
|
+
this.reason = reason;
|
|
34294
|
+
}
|
|
34295
|
+
};
|
|
34296
|
+
function judge(subject, ctx) {
|
|
34297
|
+
if (subject.media === void 0) return;
|
|
34298
|
+
const reason = require_clip_model_registry.ncMediaPolicyError(subject.media, subject);
|
|
34299
|
+
if (reason === null) return;
|
|
34300
|
+
ctx.logger.warn("nc rule refused: media policy the rule cannot honour", { meta: {
|
|
34301
|
+
...ctx.ruleId !== void 0 ? { ruleId: ctx.ruleId } : {},
|
|
34302
|
+
by: ctx.by,
|
|
34303
|
+
reason
|
|
34304
|
+
} });
|
|
34305
|
+
throw new NcMediaPolicyRefusal(reason);
|
|
34306
|
+
}
|
|
34307
|
+
/** The create path of both routes. */
|
|
34308
|
+
function guardRuleMedia(rule, ctx) {
|
|
34309
|
+
judge(rule, ctx);
|
|
34310
|
+
}
|
|
34311
|
+
/** The update path of both routes: the patch's media on the MERGED rule. */
|
|
34312
|
+
function guardPatchMedia(existing, patch, ctx) {
|
|
34313
|
+
if (existing === null || patch.media === void 0) return;
|
|
34314
|
+
judge({
|
|
34315
|
+
delivery: patch.delivery ?? existing.delivery,
|
|
34316
|
+
conditions: patch.conditions ?? existing.conditions,
|
|
34317
|
+
media: patch.media
|
|
34318
|
+
}, ctx);
|
|
34319
|
+
}
|
|
34320
|
+
//#endregion
|
|
33998
34321
|
//#region src/notification-center/occupancy-watcher.ts
|
|
33999
34322
|
/** Sentinel key segments for the "no zone" (whole-frame) and "no class" scopes. */
|
|
34000
34323
|
var FRAME_SCOPE = "@frame";
|
|
@@ -36279,6 +36602,29 @@ var NcAlarmStatusSchema = require_clip_model_registry.object({
|
|
|
36279
36602
|
deviceId: require_clip_model_registry.number().int().nullable(),
|
|
36280
36603
|
live: require_clip_model_registry.NcAlarmConfigSchema.shape.live.unwrap().nullable()
|
|
36281
36604
|
});
|
|
36605
|
+
/** `nc.bypassAlarmInput` — one (rule, input) pair, until the next disarm. */
|
|
36606
|
+
var NcAlarmBypassInputRequestSchema = require_clip_model_registry.object({
|
|
36607
|
+
ruleId: require_clip_model_registry.string().min(1),
|
|
36608
|
+
/** The input: the sensor whose match pressed the panel, else the camera. */
|
|
36609
|
+
deviceId: require_clip_model_registry.number().int()
|
|
36610
|
+
});
|
|
36611
|
+
var NcAlarmBypassInputResultSchema = require_clip_model_registry.object({
|
|
36612
|
+
outcome: require_clip_model_registry._enum([
|
|
36613
|
+
"bypassed",
|
|
36614
|
+
"already-bypassed",
|
|
36615
|
+
"refused"
|
|
36616
|
+
]),
|
|
36617
|
+
/**
|
|
36618
|
+
* Why a `refused` did nothing: the panel is disarmed, the rule is not an
|
|
36619
|
+
* alarm rule with this input, or the per-arm bound is reached. Null otherwise.
|
|
36620
|
+
*/
|
|
36621
|
+
reason: require_clip_model_registry._enum([
|
|
36622
|
+
"disarmed",
|
|
36623
|
+
"stale",
|
|
36624
|
+
"full",
|
|
36625
|
+
"not-an-input"
|
|
36626
|
+
]).nullable()
|
|
36627
|
+
});
|
|
36282
36628
|
/**
|
|
36283
36629
|
* The action catalog — the tRPC contract Group B (the viewer client) consumes
|
|
36284
36630
|
* against `addonId: 'pipeline-analytics'`. Every entry that depends on the
|
|
@@ -36407,6 +36753,17 @@ var ncActions = require_clip_model_registry.defineCustomActions({
|
|
|
36407
36753
|
caller: "required"
|
|
36408
36754
|
}),
|
|
36409
36755
|
/**
|
|
36756
|
+
* "Bypass this input until disarm" from the app — the same panel path the
|
|
36757
|
+
* trigger notification's button takes, for the same principals who may
|
|
36758
|
+
* disarm here (admin). The live view (`nc.getAlarmStatus`) then lists it
|
|
36759
|
+
* under `bypassedInputs` until the next disarm.
|
|
36760
|
+
*/
|
|
36761
|
+
"nc.bypassAlarmInput": require_clip_model_registry.customAction(NcAlarmBypassInputRequestSchema, NcAlarmBypassInputResultSchema, {
|
|
36762
|
+
kind: "mutation",
|
|
36763
|
+
auth: "admin",
|
|
36764
|
+
caller: "required"
|
|
36765
|
+
}),
|
|
36766
|
+
/**
|
|
36410
36767
|
* What the deleted-device / deleted-target prune did to the rules
|
|
36411
36768
|
* (`device-prune-runner.ts`) — the source of the banner both UIs show on a
|
|
36412
36769
|
* rule it switched off ("Disabled on <date>: its last device (#id) was
|
|
@@ -37087,6 +37444,10 @@ function makeNcActionHandlers(deps) {
|
|
|
37087
37444
|
logger: deps.logger,
|
|
37088
37445
|
by: c.userId
|
|
37089
37446
|
});
|
|
37447
|
+
guardRuleMedia(parsed, {
|
|
37448
|
+
logger: deps.logger,
|
|
37449
|
+
by: c.userId
|
|
37450
|
+
});
|
|
37090
37451
|
guardRuleDevicesExclude(parsed, {
|
|
37091
37452
|
logger: deps.logger,
|
|
37092
37453
|
by: c.userId
|
|
@@ -37139,6 +37500,11 @@ function makeNcActionHandlers(deps) {
|
|
|
37139
37500
|
by: c.userId,
|
|
37140
37501
|
ruleId: input.ruleId
|
|
37141
37502
|
});
|
|
37503
|
+
guardPatchMedia(existing, patch, {
|
|
37504
|
+
logger: deps.logger,
|
|
37505
|
+
by: c.userId,
|
|
37506
|
+
ruleId: input.ruleId
|
|
37507
|
+
});
|
|
37142
37508
|
guardPatchDevicesExclude(existing, patch, {
|
|
37143
37509
|
logger: deps.logger,
|
|
37144
37510
|
by: c.userId,
|
|
@@ -37244,6 +37610,10 @@ function makeNcActionHandlers(deps) {
|
|
|
37244
37610
|
await requireAlarm("nc.disarmAlarm").disarm(c.userId);
|
|
37245
37611
|
return { success: true };
|
|
37246
37612
|
},
|
|
37613
|
+
"nc.bypassAlarmInput": async (input, caller) => {
|
|
37614
|
+
const c = requireAdmin(caller);
|
|
37615
|
+
return requireAlarm("nc.bypassAlarmInput").bypassInput(input, c.userId);
|
|
37616
|
+
},
|
|
37247
37617
|
"nc.listPrunes": async (_input, caller) => {
|
|
37248
37618
|
requireAdmin(caller);
|
|
37249
37619
|
const list = deps.listPrunes;
|
|
@@ -40251,7 +40621,7 @@ var GLYPH_ADVANCE = .56;
|
|
|
40251
40621
|
var LABEL_USABLE_FRACTION = .94;
|
|
40252
40622
|
/** `<`, `>`, `&`, `'`, `"` in a camera name or an operator caption would each
|
|
40253
40623
|
* turn the overlay into a document librsvg refuses. */
|
|
40254
|
-
function escapeXml$
|
|
40624
|
+
function escapeXml$3(s) {
|
|
40255
40625
|
return s.replace(/[<>&'"]/g, (ch) => {
|
|
40256
40626
|
switch (ch) {
|
|
40257
40627
|
case "<": return "<";
|
|
@@ -40419,7 +40789,7 @@ function tileLabelSvg(width, height, label) {
|
|
|
40419
40789
|
const baseline = height - Math.round((bandHeight - fitted.fontPx) / 2) - Math.round(fitted.fontPx * .18);
|
|
40420
40790
|
return Buffer.from(`<svg width="${width}" height="${height}" xmlns="http://www.w3.org/2000/svg">
|
|
40421
40791
|
<rect x="0" y="${height - bandHeight}" width="${width}" height="${bandHeight}" fill="black" fill-opacity="0.55"/>
|
|
40422
|
-
<text x="${padding}" y="${baseline}" font-size="${fitted.fontPx}" font-family="sans-serif" font-weight="bold" fill="white">${escapeXml$
|
|
40792
|
+
<text x="${padding}" y="${baseline}" font-size="${fitted.fontPx}" font-family="sans-serif" font-weight="bold" fill="white">${escapeXml$3(fitted.text)}</text>
|
|
40423
40793
|
</svg>`);
|
|
40424
40794
|
}
|
|
40425
40795
|
/** The mosaic's own title band. */
|
|
@@ -40428,7 +40798,7 @@ function titleSvg(width, height, caption) {
|
|
|
40428
40798
|
const baseline = Math.round(height / 2 + fitted.fontPx * .36);
|
|
40429
40799
|
return Buffer.from(`<svg width="${width}" height="${height}" xmlns="http://www.w3.org/2000/svg">
|
|
40430
40800
|
<rect x="0" y="0" width="${width}" height="${height}" fill="#111111"/>
|
|
40431
|
-
<text x="${Math.round(width / 2)}" y="${baseline}" font-size="${fitted.fontPx}" font-family="sans-serif" font-weight="bold" text-anchor="middle" fill="white">${escapeXml$
|
|
40801
|
+
<text x="${Math.round(width / 2)}" y="${baseline}" font-size="${fitted.fontPx}" font-family="sans-serif" font-weight="bold" text-anchor="middle" fill="white">${escapeXml$3(fitted.text)}</text>
|
|
40432
40802
|
</svg>`);
|
|
40433
40803
|
}
|
|
40434
40804
|
/**
|
|
@@ -40851,11 +41221,11 @@ function withTimeout$3(promise, ms, what) {
|
|
|
40851
41221
|
* {@link NC_GROUPED_MOSAIC_MAX_TILES}; the sentence still counts every member;
|
|
40852
41222
|
* - each tile is resolved through the rule's own ladder on a DERIVED entry
|
|
40853
41223
|
* ({@link groupedTileEntry}) that carries the member's own subject, zones and
|
|
40854
|
-
* `matchedOn`,
|
|
40855
|
-
* and never zone
|
|
41224
|
+
* `matchedOn`, rendered as `crop` (N full scenes would be the same picture N times)
|
|
41225
|
+
* and never with a zone drawn.
|
|
40856
41226
|
* - a member whose owner is a PHOTOGRAPH held in RAM (a sensor's linked-camera still or a
|
|
40857
41227
|
* still-shelf capture — an alarm incident's sensor member, D705) owns no subject crop:
|
|
40858
|
-
* its tile is the whole photograph (`
|
|
41228
|
+
* its tile is the whole photograph (`clean`), or it would never land;
|
|
40859
41229
|
* - a tile is about its member ALONE: the row's system event (an alarm's `alarmSource`)
|
|
40860
41230
|
* is dropped, or the member's object read would be scoped to the source's generation.
|
|
40861
41231
|
*
|
|
@@ -40896,16 +41266,18 @@ function ownsSubjectCrop(ownerId) {
|
|
|
40896
41266
|
/**
|
|
40897
41267
|
* A NEW entry whose media read is about ONE member. Never mutates `entry`.
|
|
40898
41268
|
*
|
|
40899
|
-
* Drops the primary's
|
|
40900
|
-
*
|
|
41269
|
+
* Drops the primary's zone overlay (no zone on a tile — `mediaShowTriggerZone`, and a
|
|
41270
|
+
* legacy row's `mediaZoneIds`), its `triggerZoneIds` and `matchedOn` (the member's own
|
|
41271
|
+
* replace them, or none), and renders the tile as `crop` (`clean` for a RAM photograph —
|
|
41272
|
+
* see the header) through the same image stage as every still (D897).
|
|
40901
41273
|
*/
|
|
40902
41274
|
function groupedTileEntry(entry, m) {
|
|
40903
|
-
const { mediaZoneIds: _zoneCrop, triggerZoneIds: _primaryZones, matchedOn: _primaryMatch, ...payload } = entry.payload;
|
|
41275
|
+
const { mediaZoneIds: _zoneCrop, mediaShowTriggerZone: _zoneOverlay, mediaFrame: _legacyFrame, triggerZoneIds: _primaryZones, matchedOn: _primaryMatch, ...payload } = entry.payload;
|
|
40904
41276
|
return {
|
|
40905
41277
|
...entry,
|
|
40906
41278
|
payload: {
|
|
40907
41279
|
...payload,
|
|
40908
|
-
|
|
41280
|
+
mediaImage: ownsSubjectCrop(m.trackId) ? "crop" : "clean",
|
|
40909
41281
|
subject: memberSubject(entry.payload.subject, m, entry.deviceId),
|
|
40910
41282
|
...m.triggerZoneIds !== void 0 ? { triggerZoneIds: m.triggerZoneIds } : {},
|
|
40911
41283
|
...m.matchedOn !== void 0 ? { matchedOn: m.matchedOn } : {}
|
|
@@ -41338,8 +41710,9 @@ function zoneOverlap(point, polygons) {
|
|
|
41338
41710
|
}
|
|
41339
41711
|
return best;
|
|
41340
41712
|
}
|
|
41341
|
-
/** The trail point closest to `timestamp`, or null when none is
|
|
41342
|
-
|
|
41713
|
+
/** The trail point closest to `timestamp`, or null when none is within `maxMs`
|
|
41714
|
+
* (default {@link TRAIL_MATCH_MAX_MS}). */
|
|
41715
|
+
function trailAt(trail, timestamp, maxMs = TRAIL_MATCH_MAX_MS) {
|
|
41343
41716
|
let best = null;
|
|
41344
41717
|
let bestDelta = Number.POSITIVE_INFINITY;
|
|
41345
41718
|
for (const point of trail) {
|
|
@@ -41349,7 +41722,7 @@ function trailAt(trail, timestamp) {
|
|
|
41349
41722
|
best = point;
|
|
41350
41723
|
}
|
|
41351
41724
|
}
|
|
41352
|
-
return best !== null && bestDelta <=
|
|
41725
|
+
return best !== null && bestDelta <= maxMs ? best : null;
|
|
41353
41726
|
}
|
|
41354
41727
|
/**
|
|
41355
41728
|
* The candidates that show the subject inside the triggering zone, in the order
|
|
@@ -41367,6 +41740,407 @@ function selectTriggerZoneCandidates(input) {
|
|
|
41367
41740
|
return strict === true ? [] : candidates;
|
|
41368
41741
|
}
|
|
41369
41742
|
//#endregion
|
|
41743
|
+
//#region src/notification-center/notification-image.ts
|
|
41744
|
+
/** How long a rendered picture is reused for the same record (N targets, retries). */
|
|
41745
|
+
var NC_IMAGE_MEMO_TTL_MS = 15 * 6e4;
|
|
41746
|
+
/** At most this many rendered pictures are held. */
|
|
41747
|
+
var NC_IMAGE_MEMO_MAX = 128;
|
|
41748
|
+
/** How far a trail point may sit from the source frame's instant and still be its box. */
|
|
41749
|
+
var NC_IMAGE_BOX_MATCH_MAX_MS = 750;
|
|
41750
|
+
/** A scene picture is downscaled to at most this width (a phone shows it). */
|
|
41751
|
+
var NC_IMAGE_SCENE_MAX_WIDTH = 1920;
|
|
41752
|
+
/** A crop is downscaled to at most this long edge. */
|
|
41753
|
+
var NC_IMAGE_CROP_MAX_EDGE = 1280;
|
|
41754
|
+
/** Padding around the subject box of a crop, as a fraction of the box's side. */
|
|
41755
|
+
var NC_IMAGE_CROP_PAD = .25;
|
|
41756
|
+
/** The subject's outline. */
|
|
41757
|
+
var NC_IMAGE_SUBJECT_COLOR = "#ffb300";
|
|
41758
|
+
/** Every other track's outline. */
|
|
41759
|
+
var NC_IMAGE_OTHER_COLOR = "#f2f2f2";
|
|
41760
|
+
/** The trigger zone's outline. */
|
|
41761
|
+
var NC_IMAGE_ZONE_COLOR = "#29b6f6";
|
|
41762
|
+
/** Two tracks whose boxes overlap this much at one instant are the same subject (a fused id). */
|
|
41763
|
+
var SAME_SUBJECT_IOU = .8;
|
|
41764
|
+
var JPEG_QUALITY = 85;
|
|
41765
|
+
/** The caption of a box: class (or label) and confidence — "dog 87%". Language-free. */
|
|
41766
|
+
function imageLabelText(name, confidence) {
|
|
41767
|
+
if (confidence === void 0 || !Number.isFinite(confidence)) return name;
|
|
41768
|
+
const pct = Math.round(Math.min(1, Math.max(0, confidence)) * 100);
|
|
41769
|
+
return `${name} ${String(pct)}%`;
|
|
41770
|
+
}
|
|
41771
|
+
/** Stroke widths for an output of this size: the subject's is the thick one. */
|
|
41772
|
+
function overlayStrokes(width, height) {
|
|
41773
|
+
const subject = Math.max(4, Math.round(Math.min(width, height) / 110));
|
|
41774
|
+
return {
|
|
41775
|
+
subject,
|
|
41776
|
+
other: Math.max(2, Math.round(subject / 2.5))
|
|
41777
|
+
};
|
|
41778
|
+
}
|
|
41779
|
+
function escapeXml$2(text) {
|
|
41780
|
+
return text.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
|
41781
|
+
}
|
|
41782
|
+
function clampPx(value, max) {
|
|
41783
|
+
return Math.max(0, Math.min(Math.round(value), max));
|
|
41784
|
+
}
|
|
41785
|
+
/** Scale a normalised box to an output of `width` × `height`, clamped inside it. */
|
|
41786
|
+
function toPixels(box, width, height) {
|
|
41787
|
+
const x = clampPx(box.x * width, width - 1);
|
|
41788
|
+
const y = clampPx(box.y * height, height - 1);
|
|
41789
|
+
return {
|
|
41790
|
+
x,
|
|
41791
|
+
y,
|
|
41792
|
+
w: Math.max(1, Math.min(Math.round(box.w * width), width - x)),
|
|
41793
|
+
h: Math.max(1, Math.min(Math.round(box.h * height), height - y))
|
|
41794
|
+
};
|
|
41795
|
+
}
|
|
41796
|
+
/**
|
|
41797
|
+
* The SVG drawn over a scene picture of `width` × `height`. The subject is
|
|
41798
|
+
* drawn LAST so a crowd never hides it. Captions sit on a dark plate above
|
|
41799
|
+
* the box (inside it when the box touches the top edge), white text.
|
|
41800
|
+
*/
|
|
41801
|
+
function notificationOverlaySvg(input) {
|
|
41802
|
+
const { width, height } = input;
|
|
41803
|
+
const strokes = overlayStrokes(width, height);
|
|
41804
|
+
const fontSize = Math.max(14, Math.round(height / 36));
|
|
41805
|
+
const parts = [];
|
|
41806
|
+
for (const zone of input.zones) {
|
|
41807
|
+
if (zone.length < 3) continue;
|
|
41808
|
+
const points = zone.map((p) => `${String(clampPx(p.x * width, width))},${String(clampPx(p.y * height, height))}`).join(" ");
|
|
41809
|
+
parts.push(`<polygon data-role="zone" points="${points}" fill="${NC_IMAGE_ZONE_COLOR}" fill-opacity="0.18" stroke="${NC_IMAGE_ZONE_COLOR}" stroke-width="${String(strokes.other)}" stroke-dasharray="${String(strokes.subject * 2)},${String(strokes.subject)}"/>`);
|
|
41810
|
+
}
|
|
41811
|
+
const ordered = [...input.boxes.filter((b) => b.role === "other"), ...input.boxes.filter((b) => b.role === "subject")];
|
|
41812
|
+
for (const box of ordered) {
|
|
41813
|
+
const subject = box.role === "subject";
|
|
41814
|
+
const color = subject ? NC_IMAGE_SUBJECT_COLOR : NC_IMAGE_OTHER_COLOR;
|
|
41815
|
+
const stroke = subject ? strokes.subject : strokes.other;
|
|
41816
|
+
parts.push(`<rect data-role="${box.role}" x="${String(box.x)}" y="${String(box.y)}" width="${String(box.w)}" height="${String(box.h)}" fill="none" stroke="${color}" stroke-width="${String(stroke)}"/>`);
|
|
41817
|
+
if (box.label.length === 0) continue;
|
|
41818
|
+
const plateH = Math.round(fontSize * 1.35);
|
|
41819
|
+
const plateW = Math.round(box.label.length * fontSize * .62 + fontSize * .8);
|
|
41820
|
+
const plateY = box.y - plateH >= 0 ? box.y - plateH : box.y;
|
|
41821
|
+
const plateX = Math.min(box.x, Math.max(0, width - plateW));
|
|
41822
|
+
parts.push(`<rect data-role="${box.role}-plate" x="${String(plateX)}" y="${String(plateY)}" width="${String(plateW)}" height="${String(plateH)}" fill="#000000" fill-opacity="0.72" stroke="${color}" stroke-width="${String(subject ? 2 : 1)}"/>`, `<text data-role="${box.role}-label" x="${String(plateX + Math.round(fontSize * .4))}" y="${String(plateY + Math.round(plateH * .75))}" font-family="DejaVu Sans, Helvetica, Arial, sans-serif" font-size="${String(fontSize)}" font-weight="${subject ? "700" : "400"}" fill="#ffffff">${escapeXml$2(box.label)}</text>`);
|
|
41823
|
+
}
|
|
41824
|
+
return `<svg xmlns="http://www.w3.org/2000/svg" width="${String(width)}" height="${String(height)}">${parts.join("")}</svg>`;
|
|
41825
|
+
}
|
|
41826
|
+
/** The pixel rectangle a `crop` cuts from a `width` × `height` frame, or null when too small. */
|
|
41827
|
+
function subjectCropRect(box, width, height) {
|
|
41828
|
+
const px = toPixels(box, width, height);
|
|
41829
|
+
const padX = Math.max(16, Math.round(px.w * NC_IMAGE_CROP_PAD));
|
|
41830
|
+
const padY = Math.max(16, Math.round(px.h * NC_IMAGE_CROP_PAD));
|
|
41831
|
+
const left = Math.max(0, px.x - padX);
|
|
41832
|
+
const top = Math.max(0, px.y - padY);
|
|
41833
|
+
const right = Math.min(width, px.x + px.w + padX);
|
|
41834
|
+
const bottom = Math.min(height, px.y + px.h + padY);
|
|
41835
|
+
if (right - left < 16 || bottom - top < 16) return null;
|
|
41836
|
+
return {
|
|
41837
|
+
left,
|
|
41838
|
+
top,
|
|
41839
|
+
width: right - left,
|
|
41840
|
+
height: bottom - top
|
|
41841
|
+
};
|
|
41842
|
+
}
|
|
41843
|
+
function iou$4(a, b) {
|
|
41844
|
+
const x1 = Math.max(a.x, b.x);
|
|
41845
|
+
const y1 = Math.max(a.y, b.y);
|
|
41846
|
+
const x2 = Math.min(a.x + a.w, b.x + b.w);
|
|
41847
|
+
const y2 = Math.min(a.y + a.h, b.y + b.h);
|
|
41848
|
+
const inter = Math.max(0, x2 - x1) * Math.max(0, y2 - y1);
|
|
41849
|
+
const union = a.w * a.h + b.w * b.h - inter;
|
|
41850
|
+
return union <= 0 ? 0 : inter / union;
|
|
41851
|
+
}
|
|
41852
|
+
/**
|
|
41853
|
+
* The tracks to draw thin: every track at the instant except the subject —
|
|
41854
|
+
* the same id, or a box that is the subject's (a fused id reads under its
|
|
41855
|
+
* survivor's name).
|
|
41856
|
+
*/
|
|
41857
|
+
function otherTracks(tracks, subjectTrackId, subjectBox) {
|
|
41858
|
+
return tracks.filter((t) => t.trackId !== subjectTrackId && (subjectBox === null || iou$4(t.bbox, subjectBox) < SAME_SUBJECT_IOU));
|
|
41859
|
+
}
|
|
41860
|
+
/**
|
|
41861
|
+
* What picture a ROW asks for. A row written since D897 carries `mediaImage`;
|
|
41862
|
+
* an older row (still in the outbox across the upgrade) is read through what
|
|
41863
|
+
* its legacy fields meant — the same mapping the rule migration applies.
|
|
41864
|
+
*/
|
|
41865
|
+
function payloadImageMode(payload) {
|
|
41866
|
+
if (payload.media === "none") return "none";
|
|
41867
|
+
if (payload.mediaImage !== void 0) return payload.mediaImage;
|
|
41868
|
+
if (payload.mediaZoneIds !== void 0 && payload.mediaZoneIds.length > 0) return "clean";
|
|
41869
|
+
if (payload.mediaFrame === "full") return "clean";
|
|
41870
|
+
if (payload.mediaFrame === "boxed") return "scene-boxes";
|
|
41871
|
+
if (payload.mediaFrame === "cropped") return "crop";
|
|
41872
|
+
if (payload.media === "keyFrame") return "clean";
|
|
41873
|
+
return "crop";
|
|
41874
|
+
}
|
|
41875
|
+
/** The zone ids a row's picture outlines: the zones that fired it, when it asked. */
|
|
41876
|
+
function payloadZoneOverlayIds(payload) {
|
|
41877
|
+
if (payload.mediaShowTriggerZone === true) return payload.triggerZoneIds ?? [];
|
|
41878
|
+
if (payload.mediaImage === void 0) return payload.mediaZoneIds ?? [];
|
|
41879
|
+
return [];
|
|
41880
|
+
}
|
|
41881
|
+
/**
|
|
41882
|
+
* Which camera the primary still SHOWS, or `composite` when it is a composed
|
|
41883
|
+
* mosaic of several. A sensor row of a linked set names its cameras primary
|
|
41884
|
+
* first; in `each` mode the primary's still is that first camera's alone.
|
|
41885
|
+
*/
|
|
41886
|
+
function primaryImageCamera(payload) {
|
|
41887
|
+
const linked = payload.subject.linkedCameraIds;
|
|
41888
|
+
if (linked === void 0 || linked.length === 0) return payload.subject.deviceId;
|
|
41889
|
+
if (linked.length > 1 && payload.subject.extraStills === void 0) return "composite";
|
|
41890
|
+
return linked[0] ?? payload.subject.deviceId;
|
|
41891
|
+
}
|
|
41892
|
+
/** True when the row's subject is a tracked subject (not a RAM photograph owner). */
|
|
41893
|
+
function hasTrackedSubject(trackId) {
|
|
41894
|
+
return trackId !== void 0 && !isLinkedStillId(trackId) && !isStillShelfId(trackId);
|
|
41895
|
+
}
|
|
41896
|
+
function toArrayBufferBytes(buf) {
|
|
41897
|
+
const out = new Uint8Array(buf.byteLength);
|
|
41898
|
+
out.set(buf);
|
|
41899
|
+
return out;
|
|
41900
|
+
}
|
|
41901
|
+
/**
|
|
41902
|
+
* Render one picture from a clean JPEG. A `crop` with no box, and every
|
|
41903
|
+
* `clean`, are the scene with nothing drawn. Throws on undecodable bytes; the
|
|
41904
|
+
* stage turns that into the source shipped as it is.
|
|
41905
|
+
*/
|
|
41906
|
+
async function renderNotificationImage(req) {
|
|
41907
|
+
const { default: sharp$18 } = await import("sharp");
|
|
41908
|
+
const meta = await sharp$18(Buffer.from(req.bytes)).metadata();
|
|
41909
|
+
const srcW = meta.width ?? 0;
|
|
41910
|
+
const srcH = meta.height ?? 0;
|
|
41911
|
+
if (srcW === 0 || srcH === 0) throw new Error("source frame has no dimensions");
|
|
41912
|
+
if (req.mode === "crop" && req.subject !== null) {
|
|
41913
|
+
const rect = subjectCropRect(req.subject, srcW, srcH);
|
|
41914
|
+
if (rect !== null) return {
|
|
41915
|
+
bytes: toArrayBufferBytes(await sharp$18(Buffer.from(req.bytes)).extract(rect).resize({
|
|
41916
|
+
width: NC_IMAGE_CROP_MAX_EDGE,
|
|
41917
|
+
height: NC_IMAGE_CROP_MAX_EDGE,
|
|
41918
|
+
fit: "inside",
|
|
41919
|
+
withoutEnlargement: true
|
|
41920
|
+
}).jpeg({ quality: JPEG_QUALITY }).toBuffer()),
|
|
41921
|
+
name: "crop.jpg",
|
|
41922
|
+
rendered: "crop"
|
|
41923
|
+
};
|
|
41924
|
+
}
|
|
41925
|
+
const scale = Math.min(1, NC_IMAGE_SCENE_MAX_WIDTH / srcW);
|
|
41926
|
+
const outW = Math.max(1, Math.round(srcW * scale));
|
|
41927
|
+
const outH = Math.max(1, Math.round(srcH * scale));
|
|
41928
|
+
const boxes = req.mode === "scene-boxes" ? [...req.others.map((o) => ({
|
|
41929
|
+
...toPixels(o, outW, outH),
|
|
41930
|
+
role: "other",
|
|
41931
|
+
label: o.label
|
|
41932
|
+
})), ...req.subject !== null ? [{
|
|
41933
|
+
...toPixels(req.subject, outW, outH),
|
|
41934
|
+
role: "subject",
|
|
41935
|
+
label: req.subject.label
|
|
41936
|
+
}] : []] : [];
|
|
41937
|
+
const zones = req.mode === "crop" ? [] : req.zones;
|
|
41938
|
+
let pipeline = sharp$18(Buffer.from(req.bytes)).resize({
|
|
41939
|
+
width: outW,
|
|
41940
|
+
height: outH,
|
|
41941
|
+
fit: "fill"
|
|
41942
|
+
});
|
|
41943
|
+
if (boxes.length > 0 || zones.length > 0) {
|
|
41944
|
+
const svg = notificationOverlaySvg({
|
|
41945
|
+
width: outW,
|
|
41946
|
+
height: outH,
|
|
41947
|
+
boxes,
|
|
41948
|
+
zones
|
|
41949
|
+
});
|
|
41950
|
+
pipeline = pipeline.composite([{
|
|
41951
|
+
input: Buffer.from(svg),
|
|
41952
|
+
top: 0,
|
|
41953
|
+
left: 0
|
|
41954
|
+
}]);
|
|
41955
|
+
}
|
|
41956
|
+
const out = await pipeline.jpeg({ quality: JPEG_QUALITY }).toBuffer();
|
|
41957
|
+
const rendered = req.mode === "scene-boxes" && boxes.length > 0 ? "scene-boxes" : "clean";
|
|
41958
|
+
return {
|
|
41959
|
+
bytes: toArrayBufferBytes(out),
|
|
41960
|
+
name: `${rendered}.jpg`,
|
|
41961
|
+
rendered
|
|
41962
|
+
};
|
|
41963
|
+
}
|
|
41964
|
+
var NcNotificationImageStage = class {
|
|
41965
|
+
deps;
|
|
41966
|
+
memo = /* @__PURE__ */ new Map();
|
|
41967
|
+
render;
|
|
41968
|
+
constructor(deps) {
|
|
41969
|
+
this.deps = deps;
|
|
41970
|
+
this.render = deps.render ?? renderNotificationImage;
|
|
41971
|
+
}
|
|
41972
|
+
/** Rendered pictures currently held (spec + diagnostics). */
|
|
41973
|
+
size() {
|
|
41974
|
+
return this.memo.size;
|
|
41975
|
+
}
|
|
41976
|
+
/**
|
|
41977
|
+
* The picture for this row from this source, rendered once per record +
|
|
41978
|
+
* subject + mode + zone flag + camera and reused for every target and retry.
|
|
41979
|
+
* Null when the row ships no still.
|
|
41980
|
+
*/
|
|
41981
|
+
async notificationImage(input) {
|
|
41982
|
+
const payload = input.entry.payload;
|
|
41983
|
+
const mode = payloadImageMode(payload);
|
|
41984
|
+
if (mode === "none") return null;
|
|
41985
|
+
const zoneIds = input.withSubject ? payloadZoneOverlayIds(payload) : [];
|
|
41986
|
+
const key = [
|
|
41987
|
+
input.entry.recordId,
|
|
41988
|
+
payload.subject.trackId ?? "",
|
|
41989
|
+
mode,
|
|
41990
|
+
zoneIds.length > 0 ? "z" : "-",
|
|
41991
|
+
String(input.camera)
|
|
41992
|
+
].join("|");
|
|
41993
|
+
const now = this.deps.now();
|
|
41994
|
+
const hit = this.memo.get(key);
|
|
41995
|
+
if (hit !== void 0 && now - hit.at <= NC_IMAGE_MEMO_TTL_MS) {
|
|
41996
|
+
this.memo.delete(key);
|
|
41997
|
+
this.memo.set(key, hit);
|
|
41998
|
+
return hit.value;
|
|
41999
|
+
}
|
|
42000
|
+
const value = this.compute(input, mode, zoneIds);
|
|
42001
|
+
this.remember(key, {
|
|
42002
|
+
at: now,
|
|
42003
|
+
value
|
|
42004
|
+
});
|
|
42005
|
+
value.then((image) => {
|
|
42006
|
+
if (image.reason === "render-failed") this.memo.delete(key);
|
|
42007
|
+
}, () => this.memo.delete(key));
|
|
42008
|
+
return value;
|
|
42009
|
+
}
|
|
42010
|
+
remember(key, entry) {
|
|
42011
|
+
const now = this.deps.now();
|
|
42012
|
+
for (const [k, v] of this.memo) if (now - v.at > NC_IMAGE_MEMO_TTL_MS) this.memo.delete(k);
|
|
42013
|
+
while (this.memo.size >= NC_IMAGE_MEMO_MAX) {
|
|
42014
|
+
const oldest = this.memo.keys().next();
|
|
42015
|
+
if (oldest.done === true) break;
|
|
42016
|
+
this.memo.delete(oldest.value);
|
|
42017
|
+
}
|
|
42018
|
+
this.memo.set(key, entry);
|
|
42019
|
+
}
|
|
42020
|
+
async compute(input, mode, zoneIds) {
|
|
42021
|
+
const { entry, source, camera } = input;
|
|
42022
|
+
const payload = entry.payload;
|
|
42023
|
+
if (source.annotated) return this.asStored(input, mode, "annotated-source");
|
|
42024
|
+
if (camera === "composite") return mode === "clean" ? this.asStored(input, mode) : this.asStored(input, mode, "composite-source");
|
|
42025
|
+
const subjectTrackId = input.withSubject ? payload.subject.trackId : void 0;
|
|
42026
|
+
const subjectBox = hasTrackedSubject(subjectTrackId) ? await this.subjectBoxAt(camera, subjectTrackId, source.timestamp) : null;
|
|
42027
|
+
const subject = subjectBox !== null ? {
|
|
42028
|
+
...subjectBox,
|
|
42029
|
+
label: imageLabelText(payload.subject.label ?? payload.subject.className, payload.subject.confidence)
|
|
42030
|
+
} : null;
|
|
42031
|
+
const others = mode === "scene-boxes" ? otherTracks(await this.tracksAt(camera, source.timestamp), subjectTrackId, subjectBox).map((t) => ({
|
|
42032
|
+
...t.bbox,
|
|
42033
|
+
label: imageLabelText(t.label ?? t.className, t.confidence)
|
|
42034
|
+
})) : [];
|
|
42035
|
+
const zones = mode === "crop" || zoneIds.length === 0 ? [] : await this.zonePolygons(camera, zoneIds);
|
|
42036
|
+
try {
|
|
42037
|
+
const image = await this.render({
|
|
42038
|
+
mode,
|
|
42039
|
+
bytes: source.bytes,
|
|
42040
|
+
subject,
|
|
42041
|
+
others,
|
|
42042
|
+
zones
|
|
42043
|
+
});
|
|
42044
|
+
const reason = image.rendered === mode ? void 0 : mode === "crop" ? "no-subject-box" : "no-tracks-at-instant";
|
|
42045
|
+
if (reason !== void 0) this.reportFallback(input, mode, reason);
|
|
42046
|
+
return reason !== void 0 ? {
|
|
42047
|
+
...image,
|
|
42048
|
+
reason
|
|
42049
|
+
} : image;
|
|
42050
|
+
} catch (err) {
|
|
42051
|
+
this.deps.logger.warn("notification picture render failed — shipping the source frame", {
|
|
42052
|
+
tags: { deviceId: camera },
|
|
42053
|
+
meta: {
|
|
42054
|
+
ruleId: entry.ruleId,
|
|
42055
|
+
recordId: entry.recordId,
|
|
42056
|
+
asked: mode,
|
|
42057
|
+
kind: source.kind,
|
|
42058
|
+
error: String(err)
|
|
42059
|
+
}
|
|
42060
|
+
});
|
|
42061
|
+
return {
|
|
42062
|
+
bytes: source.bytes,
|
|
42063
|
+
name: `${source.kind}.jpg`,
|
|
42064
|
+
rendered: "clean",
|
|
42065
|
+
reason: "render-failed"
|
|
42066
|
+
};
|
|
42067
|
+
}
|
|
42068
|
+
}
|
|
42069
|
+
asStored(input, mode, reason) {
|
|
42070
|
+
if (reason !== void 0) this.reportFallback(input, mode, reason);
|
|
42071
|
+
return {
|
|
42072
|
+
bytes: input.source.bytes,
|
|
42073
|
+
name: `${input.source.kind}.jpg`,
|
|
42074
|
+
rendered: input.source.annotated ? "scene-boxes" : "clean",
|
|
42075
|
+
...reason !== void 0 ? { reason } : {}
|
|
42076
|
+
};
|
|
42077
|
+
}
|
|
42078
|
+
/** ONE line per rendered record (the memo makes it so): what was asked, what ships, why. */
|
|
42079
|
+
reportFallback(input, asked, reason) {
|
|
42080
|
+
const camera = input.camera === "composite" ? input.entry.payload.subject.deviceId : input.camera;
|
|
42081
|
+
this.deps.logger.info("notification picture shipped without the mode the rule asked for", {
|
|
42082
|
+
tags: { deviceId: camera },
|
|
42083
|
+
meta: {
|
|
42084
|
+
ruleId: input.entry.ruleId,
|
|
42085
|
+
recordId: input.entry.recordId,
|
|
42086
|
+
...input.entry.payload.subject.trackId !== void 0 ? { trackId: input.entry.payload.subject.trackId } : {},
|
|
42087
|
+
asked,
|
|
42088
|
+
reason,
|
|
42089
|
+
kind: input.source.kind,
|
|
42090
|
+
offsetMs: input.source.timestamp - input.entry.payload.subject.timestamp
|
|
42091
|
+
}
|
|
42092
|
+
});
|
|
42093
|
+
}
|
|
42094
|
+
async subjectBoxAt(camera, trackId, timestamp) {
|
|
42095
|
+
const read = this.deps.getSubjectTrail;
|
|
42096
|
+
if (read === void 0) return null;
|
|
42097
|
+
try {
|
|
42098
|
+
return trailAt(await read(camera, trackId), timestamp, NC_IMAGE_BOX_MATCH_MAX_MS)?.bbox ?? null;
|
|
42099
|
+
} catch (err) {
|
|
42100
|
+
this.deps.logger.debug("subject trail read failed — no subject box on the picture", {
|
|
42101
|
+
tags: { deviceId: camera },
|
|
42102
|
+
meta: {
|
|
42103
|
+
trackId,
|
|
42104
|
+
error: String(err)
|
|
42105
|
+
}
|
|
42106
|
+
});
|
|
42107
|
+
return null;
|
|
42108
|
+
}
|
|
42109
|
+
}
|
|
42110
|
+
async tracksAt(camera, timestamp) {
|
|
42111
|
+
const read = this.deps.getTracksAt;
|
|
42112
|
+
if (read === void 0) return [];
|
|
42113
|
+
try {
|
|
42114
|
+
return await read(camera, timestamp, NC_IMAGE_BOX_MATCH_MAX_MS);
|
|
42115
|
+
} catch (err) {
|
|
42116
|
+
this.deps.logger.debug("tracks-at-instant read failed — no other boxes on the picture", {
|
|
42117
|
+
tags: { deviceId: camera },
|
|
42118
|
+
meta: {
|
|
42119
|
+
timestamp,
|
|
42120
|
+
error: String(err)
|
|
42121
|
+
}
|
|
42122
|
+
});
|
|
42123
|
+
return [];
|
|
42124
|
+
}
|
|
42125
|
+
}
|
|
42126
|
+
async zonePolygons(camera, zoneIds) {
|
|
42127
|
+
const read = this.deps.getZonePolygons;
|
|
42128
|
+
if (read === void 0) return [];
|
|
42129
|
+
try {
|
|
42130
|
+
return await read(camera, zoneIds);
|
|
42131
|
+
} catch (err) {
|
|
42132
|
+
this.deps.logger.debug("zone polygon read failed — the trigger zone is not drawn", {
|
|
42133
|
+
tags: { deviceId: camera },
|
|
42134
|
+
meta: {
|
|
42135
|
+
zoneIds: zoneIds.join(","),
|
|
42136
|
+
error: String(err)
|
|
42137
|
+
}
|
|
42138
|
+
});
|
|
42139
|
+
return [];
|
|
42140
|
+
}
|
|
42141
|
+
}
|
|
42142
|
+
};
|
|
42143
|
+
//#endregion
|
|
41370
42144
|
//#region src/notification-center/dispatcher.ts
|
|
41371
42145
|
/**
|
|
41372
42146
|
* NcDispatcher — turns a durable outbox entry into ONE canonical
|
|
@@ -41509,12 +42283,6 @@ var NC_STILL_WAIT_INTERVAL_MS = 500;
|
|
|
41509
42283
|
* is the instant; the ladder then runs among the frames that are about it.
|
|
41510
42284
|
*/
|
|
41511
42285
|
var NC_TRIGGER_STILL_MAX_AGE_MS = TRAIL_MATCH_MAX_MS;
|
|
41512
|
-
/** Bound on the once-per-record memo of the annotated-fallback line. */
|
|
41513
|
-
var NC_ANNOTATED_FALLBACK_MEMO_MAX = 5e3;
|
|
41514
|
-
function cleanSceneAsk(policy, frame) {
|
|
41515
|
-
if (frame !== void 0) return frame === "full" ? "full" : null;
|
|
41516
|
-
return policy === "keyFrame" ? "keyFrame" : null;
|
|
41517
|
-
}
|
|
41518
42286
|
/**
|
|
41519
42287
|
* Past this age the poll is pointless and only adds latency: the seconds in
|
|
41520
42288
|
* which this subject's media was being written are long gone, so a row that has
|
|
@@ -41548,10 +42316,8 @@ var NcDispatcher = class {
|
|
|
41548
42316
|
now;
|
|
41549
42317
|
sleep;
|
|
41550
42318
|
targetCacheTtlMs;
|
|
41551
|
-
/**
|
|
41552
|
-
|
|
41553
|
-
* {@link NC_ANNOTATED_FALLBACK_MEMO_MAX}. */
|
|
41554
|
-
reportedAnnotatedFallbacks = /* @__PURE__ */ new Set();
|
|
42319
|
+
/** The ONE render stage of every notification picture (D897). */
|
|
42320
|
+
imageStage;
|
|
41555
42321
|
texts;
|
|
41556
42322
|
/** One recorded cut per moment, and the recorder-busy breaker (D686). */
|
|
41557
42323
|
recordedCuts = new NcRecordedCutMemo();
|
|
@@ -41561,6 +42327,13 @@ var NcDispatcher = class {
|
|
|
41561
42327
|
this.sleep = deps.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
|
|
41562
42328
|
this.targetCacheTtlMs = deps.targetCacheTtlMs ?? DEFAULT_TARGET_CACHE_TTL_MS;
|
|
41563
42329
|
this.texts = deps.texts ?? new NcTextCatalog();
|
|
42330
|
+
this.imageStage = new NcNotificationImageStage({
|
|
42331
|
+
logger: deps.logger,
|
|
42332
|
+
now: this.now,
|
|
42333
|
+
...deps.getSubjectTrail !== void 0 ? { getSubjectTrail: deps.getSubjectTrail } : {},
|
|
42334
|
+
...deps.getTracksAt !== void 0 ? { getTracksAt: deps.getTracksAt } : {},
|
|
42335
|
+
...deps.getZonePolygons !== void 0 ? { getZonePolygons: deps.getZonePolygons } : {}
|
|
42336
|
+
});
|
|
41564
42337
|
}
|
|
41565
42338
|
/**
|
|
41566
42339
|
* Drop held recorded cuts past their TTL (re-review R4). Driven by the
|
|
@@ -42634,16 +43407,14 @@ var NcDispatcher = class {
|
|
|
42634
43407
|
* analyst to judge ({@link NcBuiltNotification.grouped}). */
|
|
42635
43408
|
async resolveAttachments(entry) {
|
|
42636
43409
|
const out = [];
|
|
42637
|
-
const zoneIdsWanted = entry.payload.mediaZoneIds;
|
|
42638
|
-
const stillOverride = zoneIdsWanted !== void 0 && zoneIdsWanted.length > 0 ? "keyFrame" : void 0;
|
|
42639
43410
|
const groupView = readOutboxGroup(entry.payload.group);
|
|
42640
43411
|
const groupMembers = groupedTileMembers(groupView);
|
|
42641
43412
|
const groupTiles = entry.payload.media === "none" ? null : groupMembers;
|
|
42642
43413
|
const memberTrackIds = (groupView?.members ?? []).map((m) => m.trackId);
|
|
42643
|
-
const firstLook = groupTiles !== null ? null : await this.resolveAttachment(entry,
|
|
43414
|
+
const firstLook = groupTiles !== null ? null : await this.resolveAttachment(entry, canStillGainMedia(entry) ? "trigger-only" : "any");
|
|
42644
43415
|
const footagePending = this.renderFootage(entry);
|
|
42645
43416
|
const mosaic = groupView !== void 0 && groupTiles !== null ? await this.groupedMosaic(entry, groupView, groupTiles) : null;
|
|
42646
|
-
const still = mosaic !== null ? mosaic.attachment : firstLook !== null || entry.payload.media === "none" ? firstLook : await this.waitForStill(entry
|
|
43417
|
+
const still = mosaic !== null ? mosaic.attachment : firstLook !== null || entry.payload.media === "none" ? firstLook : await this.waitForStill(entry);
|
|
42647
43418
|
const grouped = groupView !== void 0 && groupMembers !== null ? {
|
|
42648
43419
|
windowKey: groupView.key,
|
|
42649
43420
|
members: groupView.members.length,
|
|
@@ -42651,17 +43422,8 @@ var NcDispatcher = class {
|
|
|
42651
43422
|
} : null;
|
|
42652
43423
|
const footage = await footagePending;
|
|
42653
43424
|
const extras = await this.linkedExtraStills(entry);
|
|
42654
|
-
if (still !== null)
|
|
42655
|
-
|
|
42656
|
-
if (groupTiles === null && zoneIds !== void 0 && zoneIds.length > 0) {
|
|
42657
|
-
const cropped = await this.zoneCrop(entry.payload.subject.deviceId, zoneIds, still.bytes);
|
|
42658
|
-
out.push(cropped !== null ? {
|
|
42659
|
-
...still,
|
|
42660
|
-
bytes: cropped,
|
|
42661
|
-
name: "zone.jpg"
|
|
42662
|
-
} : still);
|
|
42663
|
-
} else out.push(still);
|
|
42664
|
-
} else if (entry.payload.media !== "none" && extras.length > 0) this.deps.logger.info("no still of the primary camera could be resolved — the other linked cameras' stills ship without it", {
|
|
43425
|
+
if (still !== null) out.push(still);
|
|
43426
|
+
else if (entry.payload.media !== "none" && extras.length > 0) this.deps.logger.info("no still of the primary camera could be resolved — the other linked cameras' stills ship without it", {
|
|
42665
43427
|
tags: { deviceId: entry.payload.subject.deviceId },
|
|
42666
43428
|
meta: {
|
|
42667
43429
|
recordKind: entry.recordKind,
|
|
@@ -42677,7 +43439,7 @@ var NcDispatcher = class {
|
|
|
42677
43439
|
recordKind: entry.recordKind,
|
|
42678
43440
|
recordId: entry.recordId,
|
|
42679
43441
|
policy: entry.payload.media,
|
|
42680
|
-
...entry.payload.
|
|
43442
|
+
...entry.payload.mediaImage !== void 0 ? { image: entry.payload.mediaImage } : {},
|
|
42681
43443
|
...memberTrackIds.length > 1 ? { memberTrackIds } : {},
|
|
42682
43444
|
owners: [...entry.payload.subject.eventId !== void 0 ? [`object:${entry.payload.subject.eventId}`] : [], ...entry.payload.subject.trackId !== void 0 ? [`track:${entry.payload.subject.trackId}`] : []]
|
|
42683
43445
|
}
|
|
@@ -42705,7 +43467,7 @@ var NcDispatcher = class {
|
|
|
42705
43467
|
for (const { ownerId, cameraId } of subject.extraStills ?? []) {
|
|
42706
43468
|
const file = await this.linkedStillFile(entry, ownerId, cameraId);
|
|
42707
43469
|
const bytes = file === void 0 ? null : decodeStillBytes(file.base64);
|
|
42708
|
-
if (bytes === null) {
|
|
43470
|
+
if (file === void 0 || bytes === null) {
|
|
42709
43471
|
this.deps.logger.info("linked camera picture missing at send — skipped", {
|
|
42710
43472
|
tags: { deviceId: cameraId },
|
|
42711
43473
|
meta: {
|
|
@@ -42716,9 +43478,21 @@ var NcDispatcher = class {
|
|
|
42716
43478
|
});
|
|
42717
43479
|
continue;
|
|
42718
43480
|
}
|
|
43481
|
+
const image = await this.imageStage.notificationImage({
|
|
43482
|
+
entry,
|
|
43483
|
+
source: {
|
|
43484
|
+
bytes,
|
|
43485
|
+
kind: file.kind,
|
|
43486
|
+
timestamp: file.timestamp,
|
|
43487
|
+
annotated: isAnnotatedSceneKind(file.kind)
|
|
43488
|
+
},
|
|
43489
|
+
camera: cameraId,
|
|
43490
|
+
withSubject: false
|
|
43491
|
+
});
|
|
43492
|
+
if (image === null) continue;
|
|
42719
43493
|
out.push({
|
|
42720
43494
|
mediaType: "image",
|
|
42721
|
-
bytes,
|
|
43495
|
+
bytes: image.bytes,
|
|
42722
43496
|
mime: "image/jpeg",
|
|
42723
43497
|
name: `camera-${String(cameraId)}.jpg`
|
|
42724
43498
|
});
|
|
@@ -43175,14 +43949,14 @@ var NcDispatcher = class {
|
|
|
43175
43949
|
* Bounded and then abandoned — a text-only notification about a person at the
|
|
43176
43950
|
* door beats a punctual one nobody received.
|
|
43177
43951
|
*/
|
|
43178
|
-
async waitForStill(entry
|
|
43952
|
+
async waitForStill(entry) {
|
|
43179
43953
|
const subject = entry.payload.subject;
|
|
43180
|
-
if (this.now() - subject.timestamp > NC_STILL_WAIT_MAX_AGE_MS) return this.resolveAttachment(entry,
|
|
43954
|
+
if (this.now() - subject.timestamp > NC_STILL_WAIT_MAX_AGE_MS) return this.resolveAttachment(entry, "any");
|
|
43181
43955
|
if (subject.eventId === void 0 && subject.trackId === void 0) return null;
|
|
43182
43956
|
const attempts = Math.floor(NC_STILL_WAIT_MS / NC_STILL_WAIT_INTERVAL_MS);
|
|
43183
43957
|
for (let i = 0; i < attempts; i += 1) {
|
|
43184
43958
|
await this.sleep(NC_STILL_WAIT_INTERVAL_MS);
|
|
43185
|
-
const still = await this.resolveAttachment(entry,
|
|
43959
|
+
const still = await this.resolveAttachment(entry, "trigger-only");
|
|
43186
43960
|
if (still !== null) {
|
|
43187
43961
|
this.deps.logger.info("the still landed during the bounded wait", {
|
|
43188
43962
|
tags: { deviceId: entry.payload.subject.deviceId },
|
|
@@ -43196,55 +43970,7 @@ var NcDispatcher = class {
|
|
|
43196
43970
|
return still;
|
|
43197
43971
|
}
|
|
43198
43972
|
}
|
|
43199
|
-
return this.resolveAttachment(entry,
|
|
43200
|
-
}
|
|
43201
|
-
/** Crop a JPEG to the padded bbox of the given zones (normalized polygons →
|
|
43202
|
-
* pixel rect via sharp metadata). Null on any failure — caller falls back
|
|
43203
|
-
* to the uncropped still. */
|
|
43204
|
-
async zoneCrop(deviceId, zoneIds, jpeg) {
|
|
43205
|
-
try {
|
|
43206
|
-
const points = (await this.deps.getZonePolygons?.(deviceId, zoneIds) ?? []).flat();
|
|
43207
|
-
if (points.length === 0) return null;
|
|
43208
|
-
const { default: sharp$18 } = await import("sharp");
|
|
43209
|
-
const img = sharp$18(Buffer.from(jpeg));
|
|
43210
|
-
const meta = await img.metadata();
|
|
43211
|
-
const W = meta.width ?? 0;
|
|
43212
|
-
const H = meta.height ?? 0;
|
|
43213
|
-
if (W === 0 || H === 0) return null;
|
|
43214
|
-
let minX = 1;
|
|
43215
|
-
let minY = 1;
|
|
43216
|
-
let maxX = 0;
|
|
43217
|
-
let maxY = 0;
|
|
43218
|
-
for (const p of points) {
|
|
43219
|
-
if (p.x < minX) minX = p.x;
|
|
43220
|
-
if (p.y < minY) minY = p.y;
|
|
43221
|
-
if (p.x > maxX) maxX = p.x;
|
|
43222
|
-
if (p.y > maxY) maxY = p.y;
|
|
43223
|
-
}
|
|
43224
|
-
if (maxX <= minX || maxY <= minY) return null;
|
|
43225
|
-
const padX = (maxX - minX) * .1;
|
|
43226
|
-
const padY = (maxY - minY) * .1;
|
|
43227
|
-
const left = Math.max(0, Math.floor((minX - padX) * W));
|
|
43228
|
-
const top = Math.max(0, Math.floor((minY - padY) * H));
|
|
43229
|
-
const width = Math.min(W - left, Math.ceil((maxX - minX + 2 * padX) * W));
|
|
43230
|
-
const height = Math.min(H - top, Math.ceil((maxY - minY + 2 * padY) * H));
|
|
43231
|
-
if (width < 16 || height < 16) return null;
|
|
43232
|
-
const outBuf = await img.extract({
|
|
43233
|
-
left,
|
|
43234
|
-
top,
|
|
43235
|
-
width,
|
|
43236
|
-
height
|
|
43237
|
-
}).jpeg({ quality: 82 }).toBuffer();
|
|
43238
|
-
const bytes = new Uint8Array(outBuf.byteLength);
|
|
43239
|
-
bytes.set(outBuf);
|
|
43240
|
-
return bytes;
|
|
43241
|
-
} catch (err) {
|
|
43242
|
-
this.deps.logger.debug("zone crop failed — attaching uncropped still", { meta: {
|
|
43243
|
-
deviceId,
|
|
43244
|
-
error: String(err)
|
|
43245
|
-
} });
|
|
43246
|
-
return null;
|
|
43247
|
-
}
|
|
43973
|
+
return this.resolveAttachment(entry, "any");
|
|
43248
43974
|
}
|
|
43249
43975
|
/**
|
|
43250
43976
|
* The grouped MOSAIC (D692 §6.2): one tile per member, at most
|
|
@@ -43376,7 +44102,7 @@ var NcDispatcher = class {
|
|
|
43376
44102
|
const tileEntry = entries[index];
|
|
43377
44103
|
const m = tiles[index];
|
|
43378
44104
|
if (tileEntry === void 0 || m === void 0) return;
|
|
43379
|
-
const still = await this.resolveAttachment(tileEntry,
|
|
44105
|
+
const still = await this.resolveAttachment(tileEntry, freshness);
|
|
43380
44106
|
if (still !== null) found.set(index, {
|
|
43381
44107
|
trackId: m.trackId,
|
|
43382
44108
|
className: m.className,
|
|
@@ -43508,53 +44234,32 @@ var NcDispatcher = class {
|
|
|
43508
44234
|
return ordered;
|
|
43509
44235
|
}
|
|
43510
44236
|
/**
|
|
43511
|
-
*
|
|
44237
|
+
* The row's still: the CLEAN source frame of the trigger moment, rendered by
|
|
44238
|
+
* the row's picture mode (D897, `notification-image.ts`).
|
|
43512
44239
|
*
|
|
43513
|
-
* The
|
|
43514
|
-
*
|
|
43515
|
-
*
|
|
43516
|
-
*
|
|
43517
|
-
*
|
|
43518
|
-
* answers "why does my full-frame notification have a box on it".
|
|
44240
|
+
* The source ladder is the same for every mode — `cleanSourcePreference`,
|
|
44241
|
+
* the track owner first (the scene frames live there), then the object
|
|
44242
|
+
* event's — and MOMENT then PLACE filter it exactly as before. What the
|
|
44243
|
+
* rule asked to SEE is decided once, by the render, never by which stored
|
|
44244
|
+
* kind happened to land.
|
|
43519
44245
|
*/
|
|
43520
|
-
|
|
43521
|
-
if (
|
|
43522
|
-
if (this.reportedAnnotatedFallbacks.has(entry.recordId)) return;
|
|
43523
|
-
if (this.reportedAnnotatedFallbacks.size >= NC_ANNOTATED_FALLBACK_MEMO_MAX) this.reportedAnnotatedFallbacks.clear();
|
|
43524
|
-
this.reportedAnnotatedFallbacks.add(entry.recordId);
|
|
43525
|
-
this.deps.logger.warn("clean scene asked — only an annotated frame is stored, shipping it", {
|
|
43526
|
-
tags: { deviceId: entry.payload.subject.deviceId },
|
|
43527
|
-
meta: {
|
|
43528
|
-
ruleId: entry.ruleId,
|
|
43529
|
-
recordId: entry.recordId,
|
|
43530
|
-
ownerId,
|
|
43531
|
-
asked,
|
|
43532
|
-
kind
|
|
43533
|
-
}
|
|
43534
|
-
});
|
|
43535
|
-
}
|
|
43536
|
-
async resolveAttachment(entry, policyOverride, freshness = "any") {
|
|
43537
|
-
const policy = policyOverride ?? entry.payload.media;
|
|
43538
|
-
if (policy === "none") return null;
|
|
43539
|
-
const frame = policyOverride === void 0 ? entry.payload.mediaFrame : void 0;
|
|
44246
|
+
async resolveAttachment(entry, freshness = "any") {
|
|
44247
|
+
if (entry.payload.media === "none") return null;
|
|
43540
44248
|
const subject = entry.payload.subject;
|
|
43541
|
-
const signal = policy === "best-matching" ? matchSignal(entry.payload.matchedOn) : null;
|
|
43542
44249
|
const owners = [];
|
|
43543
|
-
if (subject.eventId !== void 0) owners.push({
|
|
43544
|
-
kind: "object",
|
|
43545
|
-
id: subject.eventId
|
|
43546
|
-
});
|
|
43547
44250
|
if (subject.trackId !== void 0) owners.push({
|
|
43548
44251
|
kind: "track",
|
|
43549
44252
|
id: subject.trackId
|
|
43550
44253
|
});
|
|
43551
|
-
if (
|
|
44254
|
+
if (subject.eventId !== void 0) owners.push({
|
|
44255
|
+
kind: "object",
|
|
44256
|
+
id: subject.eventId
|
|
44257
|
+
});
|
|
43552
44258
|
for (const owner of owners) try {
|
|
43553
44259
|
const files = owner.kind === "object" ? await this.deps.getMediaForOwner(owner.kind, owner.id, eventMediaScopeOf(subject)) : await this.deps.getMediaForOwner(owner.kind, owner.id);
|
|
43554
44260
|
if (files.length === 0) continue;
|
|
43555
|
-
const preference = frame !== void 0 ? framePreference(frame, owner.kind) : policy === "best-matching" ? bestMatchingKindPreference(signal, owner.kind) : attachmentKindPreference(policy, owner.kind);
|
|
43556
44261
|
const ordered = [];
|
|
43557
|
-
for (const kind of
|
|
44262
|
+
for (const kind of cleanSourcePreference(owner.kind)) for (const file of files) if (file.kind === kind) ordered.push(file);
|
|
43558
44263
|
const inBand = this.narrowToTriggerInstant(entry, ordered, freshness);
|
|
43559
44264
|
if (inBand.length === 0) continue;
|
|
43560
44265
|
const shortlist = await this.narrowToTriggerZone(entry, inBand, freshness);
|
|
@@ -43562,12 +44267,23 @@ var NcDispatcher = class {
|
|
|
43562
44267
|
for (const file of shortlist) {
|
|
43563
44268
|
const bytes = decodeStillBytes(file.base64);
|
|
43564
44269
|
if (bytes === null) continue;
|
|
43565
|
-
|
|
44270
|
+
const image = await this.imageStage.notificationImage({
|
|
44271
|
+
entry,
|
|
44272
|
+
source: {
|
|
44273
|
+
bytes,
|
|
44274
|
+
kind: file.kind,
|
|
44275
|
+
timestamp: file.timestamp,
|
|
44276
|
+
annotated: isAnnotatedSceneKind(file.kind)
|
|
44277
|
+
},
|
|
44278
|
+
camera: primaryImageCamera(entry.payload),
|
|
44279
|
+
withSubject: true
|
|
44280
|
+
});
|
|
44281
|
+
if (image === null) return null;
|
|
43566
44282
|
return {
|
|
43567
44283
|
mediaType: "image",
|
|
43568
|
-
bytes,
|
|
44284
|
+
bytes: image.bytes,
|
|
43569
44285
|
mime: "image/jpeg",
|
|
43570
|
-
name:
|
|
44286
|
+
name: image.name
|
|
43571
44287
|
};
|
|
43572
44288
|
}
|
|
43573
44289
|
} catch (err) {
|
|
@@ -48371,7 +49087,20 @@ function bodyToRule(id, raw, onProjection) {
|
|
|
48371
49087
|
});
|
|
48372
49088
|
if (!parsed.success) return null;
|
|
48373
49089
|
if (projection.kind === "projected") onProjection?.(id, projection);
|
|
48374
|
-
return parsed.data;
|
|
49090
|
+
return withNormalizedMedia(parsed.data);
|
|
49091
|
+
}
|
|
49092
|
+
/**
|
|
49093
|
+
* D897: the rule with its media policy in the stored shape — `image` set, the
|
|
49094
|
+
* legacy `attach` / `frame` / `zoneCrop` folded into it. Applied on LOAD (a read
|
|
49095
|
+
* projection: a rule nobody saved since keeps its row as written) and on every
|
|
49096
|
+
* WRITE, so the first save of a rule converges its row and an editor that still
|
|
49097
|
+
* sends the legacy triple is folded on the way in.
|
|
49098
|
+
*/
|
|
49099
|
+
function withNormalizedMedia(rule) {
|
|
49100
|
+
return {
|
|
49101
|
+
...rule,
|
|
49102
|
+
media: require_clip_model_registry.normalizeNcMediaPolicy(rule.media, rule)
|
|
49103
|
+
};
|
|
48375
49104
|
}
|
|
48376
49105
|
function isLegacyDeps$2(deps) {
|
|
48377
49106
|
return "store" in deps;
|
|
@@ -48648,14 +49377,14 @@ var NcRuleStore = class NcRuleStore {
|
|
|
48648
49377
|
/** Create a new rule. `createdBy` is the SERVER-injected caller userId. */
|
|
48649
49378
|
async create(input, createdBy) {
|
|
48650
49379
|
const now = this.now();
|
|
48651
|
-
const rule = {
|
|
49380
|
+
const rule = withNormalizedMedia({
|
|
48652
49381
|
...input,
|
|
48653
49382
|
id: this.newId(),
|
|
48654
49383
|
createdBy,
|
|
48655
49384
|
createdAt: now,
|
|
48656
49385
|
updatedAt: now,
|
|
48657
49386
|
disabledTargetIds: []
|
|
48658
|
-
};
|
|
49387
|
+
});
|
|
48659
49388
|
await this.write(rule);
|
|
48660
49389
|
return rule;
|
|
48661
49390
|
}
|
|
@@ -48693,7 +49422,7 @@ var NcRuleStore = class NcRuleStore {
|
|
|
48693
49422
|
createdAt: existing.createdAt,
|
|
48694
49423
|
updatedAt: this.now()
|
|
48695
49424
|
};
|
|
48696
|
-
const updated = require_clip_model_registry.NcRuleSchema.parse(candidate);
|
|
49425
|
+
const updated = withNormalizedMedia(require_clip_model_registry.NcRuleSchema.parse(candidate));
|
|
48697
49426
|
await this.write(updated);
|
|
48698
49427
|
return updated;
|
|
48699
49428
|
}
|
|
@@ -55254,19 +55983,20 @@ var NC_ALARM_ANNOUNCE_RULE_ID = "nc-alarm-announce";
|
|
|
55254
55983
|
* every row a rule addresses AND by the alarm's own announcement row when it is
|
|
55255
55984
|
* the COMBINED siren (the triggering rule's pictures on the alarm's words).
|
|
55256
55985
|
*
|
|
55257
|
-
* `effectiveMediaPolicyForRule`
|
|
55258
|
-
*
|
|
55259
|
-
*
|
|
55260
|
-
*
|
|
55261
|
-
*
|
|
55986
|
+
* `effectiveMediaPolicyForRule` resolves the picture MODE (D897) — a sensor
|
|
55987
|
+
* rule's included: it goes through the same selector and render stage as a
|
|
55988
|
+
* detection rule. `triggerZoneIds` are the zones of the rule that owns the
|
|
55989
|
+
* media (the SOURCE rule of a combined alarm row), and a zone overlay is
|
|
55990
|
+
* frozen only when the rule asked for one AND fired on a zone.
|
|
55262
55991
|
*/
|
|
55263
55992
|
function mediaPolicyFields(mediaRule, cropZoneIds) {
|
|
55264
55993
|
const media = effectiveMediaPolicyForRule(mediaRule);
|
|
55994
|
+
const image = media.image ?? "crop";
|
|
55265
55995
|
return {
|
|
55266
|
-
media:
|
|
55267
|
-
|
|
55996
|
+
media: image === "none" ? "none" : "best",
|
|
55997
|
+
mediaImage: image,
|
|
55998
|
+
...media.showTriggerZone === true && cropZoneIds !== void 0 && cropZoneIds.length > 0 ? { mediaShowTriggerZone: true } : {},
|
|
55268
55999
|
...cropZoneIds !== void 0 ? { triggerZoneIds: [...cropZoneIds] } : {},
|
|
55269
|
-
...media.frame !== void 0 ? { mediaFrame: media.frame } : {},
|
|
55270
56000
|
...media.profile !== void 0 ? { mediaProfile: media.profile } : {},
|
|
55271
56001
|
...media.gif === true ? { mediaGif: true } : {},
|
|
55272
56002
|
...media.clip === true ? { mediaClip: true } : {},
|
|
@@ -55306,6 +56036,36 @@ function alarmButtonKind(kind) {
|
|
|
55306
56036
|
default: return null;
|
|
55307
56037
|
}
|
|
55308
56038
|
}
|
|
56039
|
+
/**
|
|
56040
|
+
* Is a bypassed (rule, input) pair still an input of that rule? The rule must
|
|
56041
|
+
* exist, be enabled, still press the panel, and — when it names devices —
|
|
56042
|
+
* still name the input or its camera, and not exclude the input. A false
|
|
56043
|
+
* "no" re-arms the input (protection restored), which is the safe direction.
|
|
56044
|
+
*/
|
|
56045
|
+
function alarmRuleStillHasInput(rule, entry) {
|
|
56046
|
+
if (rule === null || !rule.enabled || !require_clip_model_registry.ruleAutoTriggersAlarm(rule)) return false;
|
|
56047
|
+
if ((rule.conditions.devicesExclude ?? []).includes(entry.deviceId)) return false;
|
|
56048
|
+
const scope = rule.conditions.devices;
|
|
56049
|
+
if (scope === void 0 || scope.length === 0) return true;
|
|
56050
|
+
return scope.includes(entry.deviceId) || scope.includes(entry.cameraId);
|
|
56051
|
+
}
|
|
56052
|
+
/**
|
|
56053
|
+
* The (rule, input) pair an `alarm-triggered` / `alarm-pending` row is about,
|
|
56054
|
+
* from the source it FROZE (`alarmSource`). Undefined on a manual trigger.
|
|
56055
|
+
*/
|
|
56056
|
+
function rowBypassableInput(systemEvent) {
|
|
56057
|
+
const kind = systemEvent?.kind;
|
|
56058
|
+
if (kind !== "alarm-triggered" && kind !== "alarm-pending") return void 0;
|
|
56059
|
+
const source = systemEvent?.alarmSource;
|
|
56060
|
+
if (source === void 0) return void 0;
|
|
56061
|
+
return {
|
|
56062
|
+
ruleId: source.ruleId,
|
|
56063
|
+
ruleName: source.ruleName,
|
|
56064
|
+
inputDeviceId: intrusionDeviceOf(source),
|
|
56065
|
+
cameraId: source.deviceId,
|
|
56066
|
+
notedAt: source.timestamp
|
|
56067
|
+
};
|
|
56068
|
+
}
|
|
55309
56069
|
/** The refusal id an `alarm-arm-refused` row froze (`incomingFromAlarmTransition`). */
|
|
55310
56070
|
function rowRefusalId(systemEvent) {
|
|
55311
56071
|
const value = systemEvent?.data["refusalId"];
|
|
@@ -55321,6 +56081,12 @@ var ARM_ANYWAY_REJECTION_COPY = {
|
|
|
55321
56081
|
used: "Already used",
|
|
55322
56082
|
"unknown-refusal": "This refusal is no longer current — arm again"
|
|
55323
56083
|
};
|
|
56084
|
+
/** What a pressed Bypass that did nothing tells the person holding the phone. */
|
|
56085
|
+
var BYPASS_INPUT_REFUSAL_COPY = {
|
|
56086
|
+
disarmed: "The alarm is disarmed — nothing to bypass",
|
|
56087
|
+
stale: "The alarm was re-armed since this notification — nothing was bypassed",
|
|
56088
|
+
full: "Too many inputs are bypassed already — disarm to reset them"
|
|
56089
|
+
};
|
|
55324
56090
|
/**
|
|
55325
56091
|
* What the sustained-state alerts run on when nothing wired the settings in.
|
|
55326
56092
|
*
|
|
@@ -55706,9 +56472,6 @@ function renderAudioScope(devices, excluded) {
|
|
|
55706
56472
|
if (devices.length === 0) return excluded.length === 0 ? "@all" : `@all-except:${excluded.join("+")}`;
|
|
55707
56473
|
return devices.filter((id) => !excluded.includes(id)).join("+");
|
|
55708
56474
|
}
|
|
55709
|
-
/** {@link NotificationCenter.reportedSensorMediaIgnored} cap, same shape as
|
|
55710
|
-
* the dispatcher's own annotated-fallback memo. */
|
|
55711
|
-
var NC_SENSOR_MEDIA_IGNORED_MEMO_MAX = 5e3;
|
|
55712
56475
|
/**
|
|
55713
56476
|
* One KEPT member as the payload freezes it (§6.1): its latest name, its own first-matched
|
|
55714
56477
|
* event identity (D694 — the event's STORED timestamp, never a clock), its qualifying
|
|
@@ -55769,9 +56532,6 @@ var NotificationCenter = class {
|
|
|
55769
56532
|
dispatcher;
|
|
55770
56533
|
deps;
|
|
55771
56534
|
now;
|
|
55772
|
-
/** Rule ids whose stored (non-default) media policy has already been
|
|
55773
|
-
* reported as ignored at evaluation — ONE line per rule, not per fire. */
|
|
55774
|
-
reportedSensorMediaIgnored = /* @__PURE__ */ new Set();
|
|
55775
56535
|
/** Samples the device-compat guard's "accepted unchecked" WARN per rule. */
|
|
55776
56536
|
deviceCompatWarnSampler = new NcDeviceCompatWarnSampler();
|
|
55777
56537
|
/**
|
|
@@ -56141,6 +56901,7 @@ var NotificationCenter = class {
|
|
|
56141
56901
|
targetId: input.targetId
|
|
56142
56902
|
}
|
|
56143
56903
|
});
|
|
56904
|
+
const bypassable = rowBypassableInput(input.systemEvent);
|
|
56144
56905
|
const alarmButtons = alarmKind === null ? [] : buildAlarmButtons({
|
|
56145
56906
|
kind: alarmKind,
|
|
56146
56907
|
panel: panel === null ? null : {
|
|
@@ -56148,10 +56909,12 @@ var NotificationCenter = class {
|
|
|
56148
56909
|
availableModes: panel.availableModes()
|
|
56149
56910
|
},
|
|
56150
56911
|
...refusalId !== void 0 ? { refusalId } : {},
|
|
56912
|
+
...bypassable !== void 0 ? { bypassable } : {},
|
|
56151
56913
|
labels: {
|
|
56152
56914
|
disarm: this.texts.text({ key: "button.alarm.disarm" }),
|
|
56153
56915
|
arm: (mode) => this.texts.text({ key: `button.alarm.arm.${mode}` }),
|
|
56154
|
-
armAnyway: this.texts.text({ key: "button.alarm.armAnyway" })
|
|
56916
|
+
armAnyway: this.texts.text({ key: "button.alarm.armAnyway" }),
|
|
56917
|
+
bypassInput: this.texts.text({ key: "button.alarm.bypassInput" })
|
|
56155
56918
|
},
|
|
56156
56919
|
mintUrl: (button) => {
|
|
56157
56920
|
const id = (0, node_crypto.randomUUID)();
|
|
@@ -56188,6 +56951,16 @@ var NotificationCenter = class {
|
|
|
56188
56951
|
action: "disarm",
|
|
56189
56952
|
expiresAt
|
|
56190
56953
|
};
|
|
56954
|
+
case "bypass-input": return {
|
|
56955
|
+
...common,
|
|
56956
|
+
action: "bypass-input",
|
|
56957
|
+
ruleId: button.ruleId,
|
|
56958
|
+
ruleName: button.ruleName,
|
|
56959
|
+
inputDeviceId: button.inputDeviceId,
|
|
56960
|
+
cameraId: button.cameraId,
|
|
56961
|
+
notedAt: button.notedAt,
|
|
56962
|
+
expiresAt
|
|
56963
|
+
};
|
|
56191
56964
|
}
|
|
56192
56965
|
}
|
|
56193
56966
|
});
|
|
@@ -56375,6 +57148,7 @@ var NotificationCenter = class {
|
|
|
56375
57148
|
if (panel === null) throw new Error("this node has no alarm panel");
|
|
56376
57149
|
if (panel.deviceId !== grant.panelDeviceId) throw new Error("this button was minted for a different alarm panel");
|
|
56377
57150
|
if (grant.action === "arm-anyway") return this.applyGrantedArmAnyway(panel, grant);
|
|
57151
|
+
if (grant.action === "bypass-input") return this.applyGrantedBypassInput(panel, grant);
|
|
56378
57152
|
if (this.deps.actuate === void 0) throw new Error("no actuator is wired on this node");
|
|
56379
57153
|
await this.actuateWithAttribution({
|
|
56380
57154
|
deviceId: panel.deviceId,
|
|
@@ -56411,6 +57185,32 @@ var NotificationCenter = class {
|
|
|
56411
57185
|
return;
|
|
56412
57186
|
}
|
|
56413
57187
|
}
|
|
57188
|
+
/**
|
|
57189
|
+
* "Bypass this input until disarm", from a trigger notification — the (rule,
|
|
57190
|
+
* input) pair the grant froze, on the panel, in-process. The token is the
|
|
57191
|
+
* whole of the authority (D47), exactly as for the Disarm beside it; the
|
|
57192
|
+
* panel refuses a disarmed alarm and an arm session that is already over.
|
|
57193
|
+
*/
|
|
57194
|
+
async applyGrantedBypassInput(panel, grant) {
|
|
57195
|
+
const outcome = panel.bypassInput({
|
|
57196
|
+
ruleId: grant.ruleId,
|
|
57197
|
+
ruleName: grant.ruleName,
|
|
57198
|
+
deviceId: grant.inputDeviceId,
|
|
57199
|
+
cameraId: grant.cameraId,
|
|
57200
|
+
notedAt: grant.notedAt
|
|
57201
|
+
});
|
|
57202
|
+
if (outcome.outcome === "refused") throw new NcActionRefusedError(BYPASS_INPUT_REFUSAL_COPY[outcome.reason]);
|
|
57203
|
+
this.logger.info("alarm input bypassed from a notification button", {
|
|
57204
|
+
tags: { deviceId: grant.inputDeviceId },
|
|
57205
|
+
meta: {
|
|
57206
|
+
panelDeviceId: panel.deviceId,
|
|
57207
|
+
ruleId: grant.ruleId,
|
|
57208
|
+
rule: grant.ruleName,
|
|
57209
|
+
outcome: outcome.outcome,
|
|
57210
|
+
targetId: grant.targetId ?? null
|
|
57211
|
+
}
|
|
57212
|
+
});
|
|
57213
|
+
}
|
|
56414
57214
|
/** The registry the action plane claims against. */
|
|
56415
57215
|
get actionTokenRegistry() {
|
|
56416
57216
|
return this.actionTokens;
|
|
@@ -57818,7 +58618,7 @@ var NotificationCenter = class {
|
|
|
57818
58618
|
const shown = this.unmutedCameras(cameraIds);
|
|
57819
58619
|
const involved = [sensorId, ...rowShown];
|
|
57820
58620
|
if (!this.rules.listEnabled("device-event").some((rule) => {
|
|
57821
|
-
if (
|
|
58621
|
+
if (ruleShipsNoStill(rule)) return false;
|
|
57822
58622
|
if (!deviceEventRuleMayFireOnKind(rule, sensorKind, sensorRaised)) return false;
|
|
57823
58623
|
const excluded = rule.conditions.devicesExclude;
|
|
57824
58624
|
if (excluded !== void 0 && deviceScopeExcludes(excluded, {
|
|
@@ -57962,7 +58762,7 @@ var NotificationCenter = class {
|
|
|
57962
58762
|
const shelf = this.stills;
|
|
57963
58763
|
if (shelf === null || !this.evaluationActive) return void 0;
|
|
57964
58764
|
for (const rule of this.rules.listEnabled("immediate")) {
|
|
57965
|
-
if (rule
|
|
58765
|
+
if (ruleShipsNoStill(rule)) continue;
|
|
57966
58766
|
if (!(mode === "condition" ? rule.conditions.audio !== void 0 : rule.conditions.audio === void 0 && referencesAudioClass(rule.conditions.classes))) continue;
|
|
57967
58767
|
const devices = rule.conditions.devices;
|
|
57968
58768
|
if (devices !== void 0 && devices.length > 0 && !devices.includes(deviceId)) continue;
|
|
@@ -58134,7 +58934,7 @@ var NotificationCenter = class {
|
|
|
58134
58934
|
ruleWantsOccupancyStill(edge) {
|
|
58135
58935
|
const subject = occupancySubjectFromEdge(edge);
|
|
58136
58936
|
for (const rule of this.rules.listEnabled("device-event")) {
|
|
58137
|
-
if (
|
|
58937
|
+
if (ruleShipsNoStill(rule)) continue;
|
|
58138
58938
|
const occ = rule.conditions.occupancy;
|
|
58139
58939
|
if (occ === void 0 || !matchesOccupancy(occ, subject)) continue;
|
|
58140
58940
|
const devices = rule.conditions.devices;
|
|
@@ -58430,6 +59230,10 @@ var NotificationCenter = class {
|
|
|
58430
59230
|
logger: this.logger,
|
|
58431
59231
|
by: caller.userId
|
|
58432
59232
|
});
|
|
59233
|
+
guardRuleMedia(rule, {
|
|
59234
|
+
logger: this.logger,
|
|
59235
|
+
by: caller.userId
|
|
59236
|
+
});
|
|
58433
59237
|
guardRuleDevicesExclude(rule, {
|
|
58434
59238
|
logger: this.logger,
|
|
58435
59239
|
by: caller.userId
|
|
@@ -58477,6 +59281,11 @@ var NotificationCenter = class {
|
|
|
58477
59281
|
by: caller.userId,
|
|
58478
59282
|
ruleId
|
|
58479
59283
|
});
|
|
59284
|
+
guardPatchMedia(existing, patch, {
|
|
59285
|
+
logger: this.logger,
|
|
59286
|
+
by: caller.userId,
|
|
59287
|
+
ruleId
|
|
59288
|
+
});
|
|
58480
59289
|
guardPatchDevicesExclude(existing, patch, {
|
|
58481
59290
|
logger: this.logger,
|
|
58482
59291
|
by: caller.userId,
|
|
@@ -58873,6 +59682,49 @@ var NotificationCenter = class {
|
|
|
58873
59682
|
meta: { userId }
|
|
58874
59683
|
});
|
|
58875
59684
|
},
|
|
59685
|
+
bypassInput: async ({ ruleId, deviceId }, userId) => {
|
|
59686
|
+
const panel = requirePanel();
|
|
59687
|
+
const rule = this.rules.get(ruleId) ?? null;
|
|
59688
|
+
if (rule === null || !alarmRuleStillHasInput(rule, {
|
|
59689
|
+
deviceId,
|
|
59690
|
+
cameraId: deviceId
|
|
59691
|
+
})) {
|
|
59692
|
+
this.logger.info("alarm input bypass refused — not an input of an alarm rule", {
|
|
59693
|
+
tags: { deviceId },
|
|
59694
|
+
meta: {
|
|
59695
|
+
userId,
|
|
59696
|
+
ruleId,
|
|
59697
|
+
panelDeviceId: panel.deviceId
|
|
59698
|
+
}
|
|
59699
|
+
});
|
|
59700
|
+
return {
|
|
59701
|
+
outcome: "refused",
|
|
59702
|
+
reason: "not-an-input"
|
|
59703
|
+
};
|
|
59704
|
+
}
|
|
59705
|
+
const outcome = panel.bypassInput({
|
|
59706
|
+
ruleId,
|
|
59707
|
+
ruleName: rule.name,
|
|
59708
|
+
deviceId,
|
|
59709
|
+
cameraId: deviceId
|
|
59710
|
+
});
|
|
59711
|
+
this.logger.info("alarm input bypass requested through the bridge", {
|
|
59712
|
+
tags: { deviceId },
|
|
59713
|
+
meta: {
|
|
59714
|
+
userId,
|
|
59715
|
+
ruleId,
|
|
59716
|
+
rule: rule.name,
|
|
59717
|
+
outcome: outcome.outcome
|
|
59718
|
+
}
|
|
59719
|
+
});
|
|
59720
|
+
return outcome.outcome === "refused" ? {
|
|
59721
|
+
outcome: "refused",
|
|
59722
|
+
reason: outcome.reason
|
|
59723
|
+
} : {
|
|
59724
|
+
outcome: outcome.outcome,
|
|
59725
|
+
reason: null
|
|
59726
|
+
};
|
|
59727
|
+
},
|
|
58876
59728
|
getConfig: () => this.alarmConfig(),
|
|
58877
59729
|
getSensors: (input) => this.alarmSensors(input),
|
|
58878
59730
|
setConfig: (patch) => this.setAlarmConfig(patch)
|
|
@@ -58949,6 +59801,8 @@ var NotificationCenter = class {
|
|
|
58949
59801
|
openings,
|
|
58950
59802
|
nonBlocking: panel.settings().nonBlocking,
|
|
58951
59803
|
exclusions: panel.exclusionPhases(),
|
|
59804
|
+
bypasses: panel.inputBypasses(),
|
|
59805
|
+
ruleName: (ruleId) => this.rules.get(ruleId)?.name,
|
|
58952
59806
|
name: this.deviceNameFromDirectory()
|
|
58953
59807
|
});
|
|
58954
59808
|
}
|
|
@@ -59265,7 +60119,7 @@ var NotificationCenter = class {
|
|
|
59265
60119
|
const subject = incoming.subject;
|
|
59266
60120
|
const alarmSource = subject.systemEvent?.alarmSource;
|
|
59267
60121
|
const sourceRule = alarmSource !== void 0 ? this.rules.get(alarmSource.ruleId) : null;
|
|
59268
|
-
const media = sourceRule !== null ? mediaPolicyFields(sourceRule,
|
|
60122
|
+
const media = sourceRule !== null ? mediaPolicyFields(sourceRule, ruleCropZoneIds(sourceRule)) : { media: "none" };
|
|
59269
60123
|
const mediaDeviceId = alarmSource?.deviceId ?? panel.deviceId;
|
|
59270
60124
|
const entries = targets.map((targetId) => ({
|
|
59271
60125
|
ruleId: NC_ALARM_ANNOUNCE_RULE_ID,
|
|
@@ -60105,7 +60959,7 @@ var NotificationCenter = class {
|
|
|
60105
60959
|
buttons: unresolvable.join(", ")
|
|
60106
60960
|
}
|
|
60107
60961
|
});
|
|
60108
|
-
if (!noOwnerReported && kind === "device-event" && subject.trackId === void 0 && subject.linkedStills === void 0 &&
|
|
60962
|
+
if (!noOwnerReported && kind === "device-event" && subject.trackId === void 0 && subject.linkedStills === void 0 && !ruleShipsNoStill(rule)) {
|
|
60109
60963
|
noOwnerReported = true;
|
|
60110
60964
|
const linked = subject.linkedCameraIds;
|
|
60111
60965
|
const meta = {
|
|
@@ -60138,7 +60992,7 @@ var NotificationCenter = class {
|
|
|
60138
60992
|
meta
|
|
60139
60993
|
});
|
|
60140
60994
|
}
|
|
60141
|
-
if (!noOwnerReported && (kind === "audio-window" || kind === "audio-event") && subject.trackId === void 0 && rule
|
|
60995
|
+
if (!noOwnerReported && (kind === "audio-window" || kind === "audio-event") && subject.trackId === void 0 && !ruleShipsNoStill(rule)) {
|
|
60142
60996
|
noOwnerReported = true;
|
|
60143
60997
|
this.logger.warn("audio match has no still owner — this notification ships text-only", {
|
|
60144
60998
|
tags: { deviceId: subject.deviceId },
|
|
@@ -60226,8 +61080,17 @@ var NotificationCenter = class {
|
|
|
60226
61080
|
* Materialise legacy inline `onTrigger` sequences into the shared store and
|
|
60227
61081
|
* rewrite each rule's actions to bindings + `buttons.sequenceId`. Runs on RAW
|
|
60228
61082
|
* rows before {@link NcRuleStore.load} so Zod never skips them.
|
|
61083
|
+
*
|
|
61084
|
+
* A rewrite of the LEGACY rules table only. A records-backed centre has no
|
|
61085
|
+
* such table to read (no boot declares it; the adoption mapper read it once,
|
|
61086
|
+
* after every earlier boot had already run this rewrite on it), so it skips —
|
|
61087
|
+
* reading it anyway failed on every boot ("is not declared").
|
|
60229
61088
|
*/
|
|
60230
61089
|
async migrateLegacyRuleActions() {
|
|
61090
|
+
if (this.deps.records !== void 0) {
|
|
61091
|
+
this.logger.debug("legacy actions migration not run — rules live on records", { meta: { collection: NC_RULES_COLLECTION } });
|
|
61092
|
+
return;
|
|
61093
|
+
}
|
|
60231
61094
|
let rows;
|
|
60232
61095
|
try {
|
|
60233
61096
|
rows = await this.deps.store.query.query({
|
|
@@ -60505,24 +61368,6 @@ var NotificationCenter = class {
|
|
|
60505
61368
|
}
|
|
60506
61369
|
});
|
|
60507
61370
|
}
|
|
60508
|
-
/**
|
|
60509
|
-
* ONE line per rule id, the first time a SENSOR rule's stored media policy
|
|
60510
|
-
* is about to be overridden by {@link effectiveMediaPolicyForRule}'s
|
|
60511
|
-
* one-snapshot default (operator ruling, 2026-10-01). Never refuses the
|
|
60512
|
-
* rule — it still loads and still fires — this only says the stored choice
|
|
60513
|
-
* is not the one being used.
|
|
60514
|
-
*/
|
|
60515
|
-
warnSensorMediaIgnoredOnce(rule) {
|
|
60516
|
-
if (mediaPolicyMatchesSensorDefault(rule.media)) return;
|
|
60517
|
-
if (this.reportedSensorMediaIgnored.has(rule.id)) return;
|
|
60518
|
-
if (this.reportedSensorMediaIgnored.size >= NC_SENSOR_MEDIA_IGNORED_MEMO_MAX) this.reportedSensorMediaIgnored.clear();
|
|
60519
|
-
this.reportedSensorMediaIgnored.add(rule.id);
|
|
60520
|
-
this.logger.info("sensor rule carries a media choice — ignored at evaluation, one snapshot is always attached", { meta: {
|
|
60521
|
-
ruleId: rule.id,
|
|
60522
|
-
rule: rule.name,
|
|
60523
|
-
storedMedia: rule.media
|
|
60524
|
-
} });
|
|
60525
|
-
}
|
|
60526
61371
|
buildEntries(rule, subject, kind, matchedOn, userTargets = [], origin = "pipeline", cooldown, extras) {
|
|
60527
61372
|
const hasEventMedia = kind === "object-event" || kind === "package-event";
|
|
60528
61373
|
const isTrackScoped = kind === "object-event" || kind === "track-end";
|
|
@@ -60530,11 +61375,10 @@ var NotificationCenter = class {
|
|
|
60530
61375
|
const alarmSource = subject.systemEvent?.alarmSource;
|
|
60531
61376
|
const mediaRule = alarmSource !== void 0 ? this.rules.get(alarmSource.ruleId) ?? rule : rule;
|
|
60532
61377
|
const noMedia = isSystemEvent && alarmSource === void 0;
|
|
60533
|
-
if (require_clip_model_registry.ruleKindOf(mediaRule) === "sensor") this.warnSensorMediaIgnoredOnce(mediaRule);
|
|
60534
61378
|
const direct = new Set(rule.targets.map((t) => t.targetId));
|
|
60535
61379
|
const renderedZones = notificationZones(rule, subject);
|
|
60536
61380
|
const confirm = frozenConfirmPolicy(rule, isSystemEvent);
|
|
60537
|
-
const cropZoneIds = ruleCropZoneIds(
|
|
61381
|
+
const cropZoneIds = ruleCropZoneIds(mediaRule);
|
|
60538
61382
|
const allTargets = [...rule.targets, ...userTargets.filter((t) => !direct.has(t.targetId))];
|
|
60539
61383
|
const linkedOwners = !noMedia && subject.linkedStills !== void 0 ? linkedMediaOwners(subject.linkedStills, linkedCameraMode(mediaRule.media)) : void 0;
|
|
60540
61384
|
const groupV2 = extras?.groupV2 ?? (subject.systemEvent !== void 0 && !noMedia ? alarmIncidentGroup(subject.systemEvent, subject.timestamp) : void 0);
|
|
@@ -60896,6 +61740,11 @@ var NotificationCenter = class {
|
|
|
60896
61740
|
* read cannot answer for, or that reads unknown, changes nothing (D49).
|
|
60897
61741
|
* Never throws into the tick.
|
|
60898
61742
|
*/
|
|
61743
|
+
pruneAlarmInputBypasses() {
|
|
61744
|
+
const panel = this.alarmPanel;
|
|
61745
|
+
if (panel === null || panel.inputBypasses().length === 0) return;
|
|
61746
|
+
panel.pruneInputBypasses((entry) => alarmRuleStillHasInput(this.rules.get(entry.ruleId) ?? null, entry));
|
|
61747
|
+
}
|
|
60899
61748
|
async observeAlarmOpenings() {
|
|
60900
61749
|
const panel = this.alarmPanel;
|
|
60901
61750
|
const read = this.deps.readDeviceOpenings;
|
|
@@ -61180,6 +62029,7 @@ var NotificationCenter = class {
|
|
|
61180
62029
|
await this.zoneOwners.refresh();
|
|
61181
62030
|
this.sceneStates.refresh();
|
|
61182
62031
|
await this.observeAlarmOpenings();
|
|
62032
|
+
this.pruneAlarmInputBypasses();
|
|
61183
62033
|
await this.occupancyStore.pruneExcept(this.activeOccupancyKeys());
|
|
61184
62034
|
if (!this.pruneLedger.isLoaded()) await this.pruneLedger.load();
|
|
61185
62035
|
await this.pruner.reconcileDevices();
|
|
@@ -98789,6 +99639,14 @@ function planeRunsStationary(source) {
|
|
|
98789
99639
|
//#endregion
|
|
98790
99640
|
//#region src/pipeline-analytics/pipeline/suppressed-births.ts
|
|
98791
99641
|
/**
|
|
99642
|
+
* How many rejected ids a device remembers AFTER the tracker stopped carrying
|
|
99643
|
+
* them. An id's `leaving` / zone-exit events fire for a track that is absent
|
|
99644
|
+
* from the frame by definition — the frame it is lost or a few frames later —
|
|
99645
|
+
* so the event path must still recognise it once `retain` dropped it. Track ids
|
|
99646
|
+
* are never reused; the bound only caps memory on a phantom-heavy camera.
|
|
99647
|
+
*/
|
|
99648
|
+
var RETIRED_REJECTIONS_PER_DEVICE = 256;
|
|
99649
|
+
/**
|
|
98792
99650
|
* Memory of births the confirmation gate rejected, per device.
|
|
98793
99651
|
*
|
|
98794
99652
|
* The gate runs AFTER the TrackStore upsert, and it has to: the upsert's
|
|
@@ -98810,6 +99668,8 @@ function planeRunsStationary(source) {
|
|
|
98810
99668
|
*/
|
|
98811
99669
|
var SuppressedBirthRegistry = class {
|
|
98812
99670
|
byDevice = /* @__PURE__ */ new Map();
|
|
99671
|
+
/** Rejected ids the tracker no longer carries, oldest first (bounded). */
|
|
99672
|
+
retired = /* @__PURE__ */ new Map();
|
|
98813
99673
|
/** Rejected ids whose per-frame skip has already been reported once. */
|
|
98814
99674
|
skipReported = /* @__PURE__ */ new Map();
|
|
98815
99675
|
/** Record that this birth was rejected; later frames must not re-upsert it. */
|
|
@@ -98825,6 +99685,14 @@ var SuppressedBirthRegistry = class {
|
|
|
98825
99685
|
return this.byDevice.get(deviceKey)?.has(trackId) === true;
|
|
98826
99686
|
}
|
|
98827
99687
|
/**
|
|
99688
|
+
* True when an event naming this id must be dropped: the birth was rejected,
|
|
99689
|
+
* whether the tracker still carries the id or already let it go. A rejected
|
|
99690
|
+
* birth is a track that never existed — it has no `left`, no zone exit.
|
|
99691
|
+
*/
|
|
99692
|
+
suppressesEvents(deviceKey, trackId) {
|
|
99693
|
+
return this.isRejected(deviceKey, trackId) || this.retired.get(deviceKey)?.has(trackId) === true;
|
|
99694
|
+
}
|
|
99695
|
+
/**
|
|
98828
99696
|
* True the FIRST time a rejected id's frame work is skipped (so the host logs
|
|
98829
99697
|
* one line per id, not one per frame); false afterwards.
|
|
98830
99698
|
*/
|
|
@@ -98842,7 +99710,9 @@ var SuppressedBirthRegistry = class {
|
|
|
98842
99710
|
* Forget every rejected id the tracker is no longer carrying.
|
|
98843
99711
|
*
|
|
98844
99712
|
* Called once per frame with the ids present THIS frame. An id absent from
|
|
98845
|
-
* that set can never be upserted again, so
|
|
99713
|
+
* that set can never be upserted again, so it leaves the live set — but it
|
|
99714
|
+
* moves to the bounded retired memory, because its `leaving` / zone-exit
|
|
99715
|
+
* events fire exactly when it is absent (`suppressesEvents`).
|
|
98846
99716
|
*/
|
|
98847
99717
|
retain(deviceKey, currentTrackIds) {
|
|
98848
99718
|
const reported = this.skipReported.get(deviceKey);
|
|
@@ -98852,9 +99722,26 @@ var SuppressedBirthRegistry = class {
|
|
|
98852
99722
|
}
|
|
98853
99723
|
const ids = this.byDevice.get(deviceKey);
|
|
98854
99724
|
if (!ids) return;
|
|
98855
|
-
for (const id of ids)
|
|
99725
|
+
for (const id of ids) {
|
|
99726
|
+
if (currentTrackIds.has(id)) continue;
|
|
99727
|
+
ids.delete(id);
|
|
99728
|
+
this.retire(deviceKey, id);
|
|
99729
|
+
}
|
|
98856
99730
|
if (ids.size === 0) this.byDevice.delete(deviceKey);
|
|
98857
99731
|
}
|
|
99732
|
+
retire(deviceKey, trackId) {
|
|
99733
|
+
let ids = this.retired.get(deviceKey);
|
|
99734
|
+
if (!ids) {
|
|
99735
|
+
ids = /* @__PURE__ */ new Set();
|
|
99736
|
+
this.retired.set(deviceKey, ids);
|
|
99737
|
+
}
|
|
99738
|
+
ids.add(trackId);
|
|
99739
|
+
while (ids.size > RETIRED_REJECTIONS_PER_DEVICE) {
|
|
99740
|
+
const oldest = ids.values().next();
|
|
99741
|
+
if (oldest.done === true) break;
|
|
99742
|
+
ids.delete(oldest.value);
|
|
99743
|
+
}
|
|
99744
|
+
}
|
|
98858
99745
|
/** Rejected ids currently held for a device — diagnostics and tests. */
|
|
98859
99746
|
size(deviceKey) {
|
|
98860
99747
|
return this.byDevice.get(deviceKey)?.size ?? 0;
|
|
@@ -98863,6 +99750,7 @@ var SuppressedBirthRegistry = class {
|
|
|
98863
99750
|
clearDevice(deviceKey) {
|
|
98864
99751
|
this.byDevice.delete(deviceKey);
|
|
98865
99752
|
this.skipReported.delete(deviceKey);
|
|
99753
|
+
this.retired.delete(deviceKey);
|
|
98866
99754
|
}
|
|
98867
99755
|
};
|
|
98868
99756
|
//#endregion
|
|
@@ -107133,6 +108021,10 @@ Object.freeze({
|
|
|
107133
108021
|
"resolution": "auto",
|
|
107134
108022
|
"maxPoints": 1e3
|
|
107135
108023
|
},
|
|
108024
|
+
"queryMany": {
|
|
108025
|
+
"resolution": "auto",
|
|
108026
|
+
"maxPoints": 1e3
|
|
108027
|
+
},
|
|
107136
108028
|
"queryNode": {
|
|
107137
108029
|
"resolution": "auto",
|
|
107138
108030
|
"maxPoints": 1e3
|
|
@@ -107191,7 +108083,7 @@ Object.freeze({
|
|
|
107191
108083
|
"createRule": { "rule": { "__nested__": {
|
|
107192
108084
|
"enabled": true,
|
|
107193
108085
|
"conditions": {},
|
|
107194
|
-
"media": {
|
|
108086
|
+
"media": {},
|
|
107195
108087
|
"throttle": {
|
|
107196
108088
|
"cooldownSec": 60,
|
|
107197
108089
|
"scope": "rule-device"
|
|
@@ -107202,7 +108094,7 @@ Object.freeze({
|
|
|
107202
108094
|
"previewStateSummary": { "rule": { "__nested__": {
|
|
107203
108095
|
"enabled": true,
|
|
107204
108096
|
"conditions": {},
|
|
107205
|
-
"media": {
|
|
108097
|
+
"media": {},
|
|
107206
108098
|
"throttle": {
|
|
107207
108099
|
"cooldownSec": 60,
|
|
107208
108100
|
"scope": "rule-device"
|
|
@@ -107213,7 +108105,7 @@ Object.freeze({
|
|
|
107213
108105
|
"rule": { "__nested__": {
|
|
107214
108106
|
"enabled": true,
|
|
107215
108107
|
"conditions": {},
|
|
107216
|
-
"media": {
|
|
108108
|
+
"media": {},
|
|
107217
108109
|
"throttle": {
|
|
107218
108110
|
"cooldownSec": 60,
|
|
107219
108111
|
"scope": "rule-device"
|
|
@@ -107406,7 +108298,6 @@ Object.freeze({
|
|
|
107406
108298
|
],
|
|
107407
108299
|
"load-contribution": ["list"],
|
|
107408
108300
|
"log-channels": ["list"],
|
|
107409
|
-
"log-destination": ["query"],
|
|
107410
108301
|
"login-method": ["getLoginMethods"],
|
|
107411
108302
|
"mqtt-broker": ["listBrokers"],
|
|
107412
108303
|
"network-access": ["listEndpoints"],
|
|
@@ -107428,12 +108319,19 @@ Object.freeze({
|
|
|
107428
108319
|
"user-passkeys": ["listPasskeys"],
|
|
107429
108320
|
"videoclips": ["listClips", "listSources"]
|
|
107430
108321
|
});
|
|
107431
|
-
Object.freeze({
|
|
107432
|
-
"
|
|
107433
|
-
|
|
107434
|
-
|
|
107435
|
-
|
|
107436
|
-
]
|
|
108322
|
+
Object.freeze({
|
|
108323
|
+
"log-destination": [
|
|
108324
|
+
"cancelRead",
|
|
108325
|
+
"describeRead",
|
|
108326
|
+
"read"
|
|
108327
|
+
],
|
|
108328
|
+
"videoclips": [
|
|
108329
|
+
"dialClipStream",
|
|
108330
|
+
"getPlaybackOptions",
|
|
108331
|
+
"offerClipBytes",
|
|
108332
|
+
"readClipBytes"
|
|
108333
|
+
]
|
|
108334
|
+
});
|
|
107437
108335
|
/** cap name -> the numeric state field whose strict advance IS the cap's one-shot event. */
|
|
107438
108336
|
var CAP_ADVANCE_FIELDS = Object.freeze({ "doorbell": "lastPressedAt" });
|
|
107439
108337
|
Object.freeze({
|
|
@@ -117387,6 +118285,15 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_clip_m
|
|
|
117387
118285
|
}
|
|
117388
118286
|
}));
|
|
117389
118287
|
},
|
|
118288
|
+
getTracksAt: async (deviceId, timestamp, toleranceMs) => {
|
|
118289
|
+
const live = stores.trackStore.getActive(deviceId);
|
|
118290
|
+
const persisted = await stores.trackStore.queryRecent({
|
|
118291
|
+
deviceIds: [deviceId],
|
|
118292
|
+
since: timestamp - toleranceMs,
|
|
118293
|
+
limit: NC_TRACKS_AT_INSTANT_LIMIT
|
|
118294
|
+
});
|
|
118295
|
+
return tracksAtInstant([...live, ...persisted.tracks], timestamp, toleranceMs, this.lastFrameDimsByDevice.get(deviceId));
|
|
118296
|
+
},
|
|
117390
118297
|
getDeviceName,
|
|
117391
118298
|
getDeviceLocation,
|
|
117392
118299
|
buildActions: (input) => this.notificationCenter?.mintButtons(input) ?? Promise.resolve([])
|
|
@@ -119752,12 +120659,13 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_clip_m
|
|
|
119752
120659
|
zones: computeStationaryEntryZones(v, liveZones, trk.zoneMembershipMinOverlap),
|
|
119753
120660
|
standing: true
|
|
119754
120661
|
}));
|
|
120662
|
+
const occupancyTracked = result.tracked.filter((t) => !this.suppressedBirths.isRejected(key, t.trackId));
|
|
119755
120663
|
this.zoneAnalytics?.recordFrame({
|
|
119756
120664
|
deviceId,
|
|
119757
120665
|
timestamp: result.timestamp,
|
|
119758
120666
|
frameWidth: result.frameWidth,
|
|
119759
120667
|
frameHeight: result.frameHeight,
|
|
119760
|
-
tracked: stationaryAsTracked.length > 0 ? [...
|
|
120668
|
+
tracked: stationaryAsTracked.length > 0 ? [...occupancyTracked, ...stationaryAsTracked] : occupancyTracked,
|
|
119761
120669
|
zones: liveZones,
|
|
119762
120670
|
...stationaryViews.length > 0 ? { stationaryObjects: stationaryViews } : {}
|
|
119763
120671
|
});
|
|
@@ -120114,12 +121022,14 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_clip_m
|
|
|
120114
121022
|
}
|
|
120115
121023
|
});
|
|
120116
121024
|
}
|
|
120117
|
-
|
|
121025
|
+
const keptObjectEvents = this.dropSuppressedBirthEvents(deviceId, key, result.objectEvents);
|
|
121026
|
+
const keptAppearanceEvents = this.dropSuppressedBirthEvents(deviceId, key, result.appearanceEvents);
|
|
121027
|
+
if (rejectedThisFrame.size > 0 || keptObjectEvents !== result.objectEvents || keptAppearanceEvents !== result.appearanceEvents) result = {
|
|
120118
121028
|
...result,
|
|
120119
121029
|
tracked: result.tracked.filter((t) => !rejectedThisFrame.has(t.trackId)),
|
|
120120
121030
|
rawTrackedDetections: result.rawTrackedDetections.filter((rt) => !rejectedThisFrame.has(rt.trackId)),
|
|
120121
|
-
objectEvents:
|
|
120122
|
-
appearanceEvents:
|
|
121031
|
+
objectEvents: keptObjectEvents,
|
|
121032
|
+
appearanceEvents: keptAppearanceEvents
|
|
120123
121033
|
};
|
|
120124
121034
|
if (currentTrackIds.size > 0) this.lastTrackActivityMs.set(deviceId, Date.now());
|
|
120125
121035
|
const stationarySettings = this.stationarySettingsFromCache(deviceId);
|
|
@@ -120579,6 +121489,35 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_clip_m
|
|
|
120579
121489
|
* invented id would put `event:NaN` in a media key. A branch that accepts
|
|
120580
121490
|
* work and produces nothing must log it.
|
|
120581
121491
|
*/
|
|
121492
|
+
/**
|
|
121493
|
+
* The events of `drafts` whose track's birth the confirmation gate rejected,
|
|
121494
|
+
* removed — the SAME array when none is, so a caller can tell. A rejected
|
|
121495
|
+
* birth is a track that never existed: it has no event row, no media and no
|
|
121496
|
+
* notification, on any frame. Each drop is logged (bounded by event rate):
|
|
121497
|
+
* an accepted event that silently vanishes reads as "never happened" (D391).
|
|
121498
|
+
*/
|
|
121499
|
+
dropSuppressedBirthEvents(deviceId, key, drafts) {
|
|
121500
|
+
const isSuppressed = (e) => e.trackId !== void 0 && this.suppressedBirths.suppressesEvents(key, e.trackId);
|
|
121501
|
+
if (!drafts.some(isSuppressed)) return drafts;
|
|
121502
|
+
const kept = [];
|
|
121503
|
+
for (const e of drafts) {
|
|
121504
|
+
if (!isSuppressed(e)) {
|
|
121505
|
+
kept.push(e);
|
|
121506
|
+
continue;
|
|
121507
|
+
}
|
|
121508
|
+
this.ctx.logger.info("object event dropped — the track birth was suppressed", {
|
|
121509
|
+
tags: { deviceId },
|
|
121510
|
+
meta: {
|
|
121511
|
+
trackId: e.trackId,
|
|
121512
|
+
className: e.className,
|
|
121513
|
+
state: e.state,
|
|
121514
|
+
zones: e.zones,
|
|
121515
|
+
reason: "birth-suppressed"
|
|
121516
|
+
}
|
|
121517
|
+
});
|
|
121518
|
+
}
|
|
121519
|
+
return kept;
|
|
121520
|
+
}
|
|
120582
121521
|
async persistObjectEvents(deviceId, drafts) {
|
|
120583
121522
|
if (drafts.length === 0) return [];
|
|
120584
121523
|
const store = this.eventStore;
|