@camstack/addon-post-analysis 1.2.305 → 1.2.307
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-t2ey3w0B.js → clip-model-registry-C67UM_q_.js} +128 -6
- package/dist/{clip-model-registry-EA6x7Vd5.mjs → clip-model-registry-C9Aw4qkh.mjs} +117 -7
- package/dist/embedding-encoder/index.js +1 -1
- package/dist/embedding-encoder/index.mjs +1 -1
- package/dist/pipeline-analytics/_stub.js +1 -1
- package/dist/pipeline-analytics/{_virtual_mf-localSharedImportMap___mfe_internal__addon_pipeline_analytics_widgets-yOiRjNnR.mjs → _virtual_mf-localSharedImportMap___mfe_internal__addon_pipeline_analytics_widgets-QE0Juzwb.mjs} +1 -1
- package/dist/pipeline-analytics/_virtual_mf___mfe_internal__addon_pipeline_analytics_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-MwQBD526.mjs +26 -0
- package/dist/pipeline-analytics/{hostInit-6Km5THPt.mjs → hostInit-DzNrV8y8.mjs} +1 -1
- package/dist/pipeline-analytics/index.js +563 -36
- package/dist/pipeline-analytics/index.mjs +563 -36
- 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-CieM-gpB.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-C67UM_q_.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");
|
|
@@ -10652,21 +10652,49 @@ function matchesPlate(label, values, maxDistance) {
|
|
|
10652
10652
|
return false;
|
|
10653
10653
|
}
|
|
10654
10654
|
/**
|
|
10655
|
+
* Subject kinds whose `classNames` is a real, operator-meaningful detection
|
|
10656
|
+
* class — the ones the `classes` condition can actually scope a rule to.
|
|
10657
|
+
* `device-event` and `system-event` carry NO class live (`classNames: []`,
|
|
10658
|
+
* always — see `subjectFromSensorEvent`); their OUTBOX `className` is a
|
|
10659
|
+
* display placeholder instead (the sensor kind, or the literal `'event'`,
|
|
10660
|
+
* `className: subject.classNames[0] ?? subject.sensorKind ?? 'event'`), which
|
|
10661
|
+
* `cooldownSubjectFromOutbox` would otherwise reconstruct as if it were a real
|
|
10662
|
+
* class. Gating here, not on the reconstructed value, keeps the live path and
|
|
10663
|
+
* the boot reseed addressing the SAME key regardless of what a display label
|
|
10664
|
+
* happens to say (2026-10-01, caught by `system-event-cooldown-direction.spec.ts`
|
|
10665
|
+
* when the default below flipped).
|
|
10666
|
+
*/
|
|
10667
|
+
var CLASS_BEARING_SUBJECT_KINDS = new Set([
|
|
10668
|
+
"object-event",
|
|
10669
|
+
"track-end",
|
|
10670
|
+
"package-event",
|
|
10671
|
+
"occupancy-event",
|
|
10672
|
+
"audio-event",
|
|
10673
|
+
"audio-window"
|
|
10674
|
+
]);
|
|
10675
|
+
/**
|
|
10655
10676
|
* Should this subject's class be part of the cooldown key?
|
|
10656
10677
|
*
|
|
10657
10678
|
* AUDIO is unconditional and predates the setting: a rule opted into several
|
|
10658
10679
|
* audio classes (dog + scream) must not have a safety-relevant scream
|
|
10659
10680
|
* swallowed by an unrelated bark's 60 s window.
|
|
10660
10681
|
*
|
|
10661
|
-
*
|
|
10662
|
-
*
|
|
10663
|
-
*
|
|
10664
|
-
*
|
|
10665
|
-
*
|
|
10682
|
+
* A `device-event` or `system-event` subject never reads a class — see
|
|
10683
|
+
* {@link CLASS_BEARING_SUBJECT_KINDS} — so the setting has nothing to key on
|
|
10684
|
+
* regardless of `granularity`.
|
|
10685
|
+
*
|
|
10686
|
+
* Every class-bearing subject gets the same treatment unless the rule
|
|
10687
|
+
* explicitly asks for `shared` — cat→dog fires at once, cat→cat still waits.
|
|
10688
|
+
* That is the operator's default since 2026-10-01 (D36's dated note): an
|
|
10689
|
+
* UNSET rule now reads exactly like an explicit `per-class` one, new rules and
|
|
10690
|
+
* existing rules alike. Only a `granularity: 'shared'` actually stored
|
|
10691
|
+
* reproduces the old class-agnostic key byte for byte — the one case where an
|
|
10692
|
+
* operator asked for one window across every class on purpose.
|
|
10666
10693
|
*/
|
|
10667
10694
|
function keysPerClass(rule, subject) {
|
|
10668
10695
|
if (subject.kind === "audio-event" || subject.kind === "audio-window") return true;
|
|
10669
|
-
|
|
10696
|
+
if (!CLASS_BEARING_SUBJECT_KINDS.has(subject.kind)) return false;
|
|
10697
|
+
return rule.throttle.granularity !== "shared";
|
|
10670
10698
|
}
|
|
10671
10699
|
/**
|
|
10672
10700
|
* The cooldown scope of ONE system event — subject AND direction.
|
|
@@ -10751,6 +10779,42 @@ function isCoolingDown(rule, lastFiredAt, now) {
|
|
|
10751
10779
|
return now - lastFiredAt < rule.throttle.cooldownSec * 1e3;
|
|
10752
10780
|
}
|
|
10753
10781
|
/**
|
|
10782
|
+
* The media policy EVERY sensor rule attaches: one whole-scene photograph,
|
|
10783
|
+
* never a selector. `attach: 'keyFrame'` is the existing "clean full scene
|
|
10784
|
+
* frame" ladder (`attachmentKindPreference`) — the closest existing concept
|
|
10785
|
+
* to a plain snapshot, as opposed to `'best'`/`'best-matching'`, which chase a
|
|
10786
|
+
* SUBJECT crop a sensor subject (a doorbell press, a contact change) has
|
|
10787
|
+
* none of to chase. No zone crop, no gif, no clip, no profile — a sensor
|
|
10788
|
+
* subject has no zone condition to crop to and no reason to animate.
|
|
10789
|
+
*/
|
|
10790
|
+
var NC_SENSOR_MEDIA_POLICY = { attach: "keyFrame" };
|
|
10791
|
+
/**
|
|
10792
|
+
* The media policy actually used to build a delivery, for a rule of this
|
|
10793
|
+
* KIND. A sensor rule's STORED `media` is never read at evaluation — the
|
|
10794
|
+
* editor offers no media control for one any more, and `media.attach` /
|
|
10795
|
+
* `.gif` / `.clip` / `.profile` on an existing sensor rule (authored before
|
|
10796
|
+
* this ruling, from the viewer or the API) are IGNORED here, never refused at
|
|
10797
|
+
* load. See {@link mediaPolicyMatchesSensorDefault} for the sampled-log gate.
|
|
10798
|
+
*/
|
|
10799
|
+
function effectiveMediaPolicy(rule, kind) {
|
|
10800
|
+
return kind === "sensor" ? NC_SENSOR_MEDIA_POLICY : rule.media;
|
|
10801
|
+
}
|
|
10802
|
+
/**
|
|
10803
|
+
* True when a stored media policy already reads as the sensor default — the
|
|
10804
|
+
* gate the boot/evaluation path uses to decide whether an existing sensor
|
|
10805
|
+
* rule's stored choice is being silently overridden (and so is worth a
|
|
10806
|
+
* sampled log line) or already matches and there is nothing to say.
|
|
10807
|
+
*/
|
|
10808
|
+
function mediaPolicyMatchesSensorDefault(media) {
|
|
10809
|
+
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;
|
|
10810
|
+
}
|
|
10811
|
+
/** `effectiveMediaPolicy`, resolving the kind itself — the call-site shortcut
|
|
10812
|
+
* every production caller uses; `effectiveMediaPolicy` stays the unit under
|
|
10813
|
+
* test because `ruleKindOf` needs no fixture of its own there. */
|
|
10814
|
+
function effectiveMediaPolicyForRule(rule) {
|
|
10815
|
+
return effectiveMediaPolicy(rule, require_clip_model_registry.ruleKindOf(rule));
|
|
10816
|
+
}
|
|
10817
|
+
/**
|
|
10754
10818
|
* Ordered media-kind preference for one attachment, per policy and owner.
|
|
10755
10819
|
* `best` favours the tight subject crop; `keyFrame` the clean scene frame
|
|
10756
10820
|
* (small variant first — the degrade engine enforces per-backend byte
|
|
@@ -14332,11 +14396,11 @@ function logRefusal$3(check, ctx) {
|
|
|
14332
14396
|
try {
|
|
14333
14397
|
check();
|
|
14334
14398
|
} catch (err) {
|
|
14335
|
-
if (err instanceof NcDevicesExcludeRefusal) warnRefusal$
|
|
14399
|
+
if (err instanceof NcDevicesExcludeRefusal) warnRefusal$3(ctx.logger, err, ctx);
|
|
14336
14400
|
throw err;
|
|
14337
14401
|
}
|
|
14338
14402
|
}
|
|
14339
|
-
function warnRefusal$
|
|
14403
|
+
function warnRefusal$3(logger, err, ctx) {
|
|
14340
14404
|
logger.warn("nc rule refused: devicesExclude on a system rule", { meta: {
|
|
14341
14405
|
...ctx.ruleId !== void 0 ? { ruleId: ctx.ruleId } : {},
|
|
14342
14406
|
by: ctx.by,
|
|
@@ -14359,6 +14423,184 @@ function guardPatchDevicesExclude(existing, patch, ctx) {
|
|
|
14359
14423
|
logRefusal$3(() => assertDevicesExcludeDelivery(merged), ctx);
|
|
14360
14424
|
}
|
|
14361
14425
|
//#endregion
|
|
14426
|
+
//#region src/notification-center/device-compat-guard.ts
|
|
14427
|
+
/**
|
|
14428
|
+
* A NEW or UPDATED rule's `conditions.devices` (the "Only these" INCLUDE list)
|
|
14429
|
+
* names only devices that can actually produce this rule's KIND of subject —
|
|
14430
|
+
* operator ruling, 2026-10-01: "the DEVICE picker of a rule must offer only
|
|
14431
|
+
* devices compatible with the rule type … the server should also refuse a
|
|
14432
|
+
* save whose deviceIds include an incompatible device".
|
|
14433
|
+
*
|
|
14434
|
+
* THE authority, same arrangement as `device-scope-guard.ts` beside it: the
|
|
14435
|
+
* cap-provider route (`notificationRules.createRule/updateRule`, index.ts)
|
|
14436
|
+
* and the rule bridge (`nc.createRule/updateRule`, nc-actions.ts) both call
|
|
14437
|
+
* `guardRuleDeviceCompat` / `guardPatchDeviceCompat`. Which devices a kind
|
|
14438
|
+
* may scope is `ruleDeviceCompatCapQueries` (`@camstack/types`) — the SAME
|
|
14439
|
+
* table the admin's Devices tab narrows by, so a save can never refuse a
|
|
14440
|
+
* device its own picker just offered. A `sensor` rule's set includes every
|
|
14441
|
+
* camera: a camera-scoped sensor rule is legitimate (D630 §6).
|
|
14442
|
+
*
|
|
14443
|
+
* ASYNC, unlike its sibling guards: it has to ask `deviceManager` which
|
|
14444
|
+
* devices carry the caps. `listDeviceIdsByCaps` is therefore OPTIONAL on the
|
|
14445
|
+
* context — absent on an older wiring, if the cross-addon call throws, or if
|
|
14446
|
+
* it answers EMPTY (the registry not yet warm at boot: `getAllBindings`
|
|
14447
|
+
* answers `[]` with only a warning, which is not a measurement — D315/D393),
|
|
14448
|
+
* the guard FAILS OPEN (never refuses) rather than blocking every save on a
|
|
14449
|
+
* read it cannot make (D49). It is never SILENT about it: an unjudged scoped
|
|
14450
|
+
* save logs a sampled WARN naming the rule and the reason, so a dropped wiring
|
|
14451
|
+
* is visible in production rather than a guard that quietly stopped existing.
|
|
14452
|
+
*
|
|
14453
|
+
* ON UPDATE only the ids the patch ADDS are judged. A device already stored on
|
|
14454
|
+
* the rule is never re-judged: the answer is not stable (switching detection
|
|
14455
|
+
* off on a camera removes its `detection-pipeline` binding, D62; an agent
|
|
14456
|
+
* camera's native caps are missing during the handshake), and re-judging
|
|
14457
|
+
* would make every rule naming that camera un-editable until the operator
|
|
14458
|
+
* removed it — losing a scope a later switch-on would not give back.
|
|
14459
|
+
*
|
|
14460
|
+
* NEVER called at LOAD. `NcRuleSchema.parse` and the store's own read path are
|
|
14461
|
+
* untouched — a rule saved before this ruling, or saved while the capability
|
|
14462
|
+
* read was unavailable, keeps loading and keeps firing exactly as it does
|
|
14463
|
+
* today; only a NEW save attempt is judged.
|
|
14464
|
+
*
|
|
14465
|
+
* `devicesExclude` is NOT checked: this guard judges what the rule actually
|
|
14466
|
+
* MATCHES ON (the include scope), not what it widens away from, and an
|
|
14467
|
+
* operator excluding an incompatible device from an "all except" rule is not
|
|
14468
|
+
* the mistake this exists to catch.
|
|
14469
|
+
*/
|
|
14470
|
+
/** How long one rule's "accepted unchecked" WARN stays quiet after it fired. */
|
|
14471
|
+
var UNCHECKED_WARN_WINDOW_MS = 10 * 6e4;
|
|
14472
|
+
/** Bound on remembered rule keys — cleared whole when exceeded. */
|
|
14473
|
+
var UNCHECKED_WARN_MEMO_MAX = 256;
|
|
14474
|
+
/**
|
|
14475
|
+
* Samples the "accepted unchecked" WARN per rule: an editor that autosaves, or
|
|
14476
|
+
* a script, must not turn one unreachable read into a log flood, but the first
|
|
14477
|
+
* save of every rule in a window is always reported.
|
|
14478
|
+
*/
|
|
14479
|
+
var NcDeviceCompatWarnSampler = class {
|
|
14480
|
+
lastWarnedAt = /* @__PURE__ */ new Map();
|
|
14481
|
+
shouldWarn(key, now) {
|
|
14482
|
+
const last = this.lastWarnedAt.get(key);
|
|
14483
|
+
if (last !== void 0 && now - last < UNCHECKED_WARN_WINDOW_MS) return false;
|
|
14484
|
+
if (this.lastWarnedAt.size >= UNCHECKED_WARN_MEMO_MAX) this.lastWarnedAt.clear();
|
|
14485
|
+
this.lastWarnedAt.set(key, now);
|
|
14486
|
+
return true;
|
|
14487
|
+
}
|
|
14488
|
+
};
|
|
14489
|
+
var NcDeviceCompatRefusal = class extends Error {
|
|
14490
|
+
kind;
|
|
14491
|
+
incompatibleIds;
|
|
14492
|
+
constructor(kind, incompatibleIds) {
|
|
14493
|
+
super(`rule refused: device(s) ${incompatibleIds.join(", ")} cannot produce a '${kind}' rule's subject`);
|
|
14494
|
+
this.name = "NcDeviceCompatRefusal";
|
|
14495
|
+
this.kind = kind;
|
|
14496
|
+
this.incompatibleIds = incompatibleIds;
|
|
14497
|
+
}
|
|
14498
|
+
};
|
|
14499
|
+
/** The union of every compat query's answer, or why there is none. */
|
|
14500
|
+
async function readCompatibleIds(queries, listDeviceIdsByCaps) {
|
|
14501
|
+
try {
|
|
14502
|
+
const answers = await Promise.all(queries.map((capNames) => listDeviceIdsByCaps(capNames)));
|
|
14503
|
+
return {
|
|
14504
|
+
ok: true,
|
|
14505
|
+
ids: new Set(answers.flat())
|
|
14506
|
+
};
|
|
14507
|
+
} catch (err) {
|
|
14508
|
+
return {
|
|
14509
|
+
ok: false,
|
|
14510
|
+
error: String(err)
|
|
14511
|
+
};
|
|
14512
|
+
}
|
|
14513
|
+
}
|
|
14514
|
+
/**
|
|
14515
|
+
* The check. `judged` is the ids to judge — the whole include list on a
|
|
14516
|
+
* create, only the ADDED ids on an update (see the file header).
|
|
14517
|
+
*
|
|
14518
|
+
* Resolves `not-judged` when the kind has no capability filter
|
|
14519
|
+
* (`ruleDeviceCompatCapQueries` is `null`) or there is nothing to judge —
|
|
14520
|
+
* "every device" is never refused here. Resolves `unchecked` (fail open) when
|
|
14521
|
+
* the read is not wired, throws, or answers nothing at all. Throws
|
|
14522
|
+
* {@link NcDeviceCompatRefusal} naming every incompatible id, never only the
|
|
14523
|
+
* first — an operator fixing the list needs the whole picture in one pass.
|
|
14524
|
+
*/
|
|
14525
|
+
async function assertRuleDeviceCompat(rule, listDeviceIdsByCaps, judged = rule.conditions.devices ?? []) {
|
|
14526
|
+
const kind = require_clip_model_registry.ruleKindOf(rule);
|
|
14527
|
+
const queries = require_clip_model_registry.ruleDeviceCompatCapQueries(kind);
|
|
14528
|
+
if (queries === null || judged.length === 0) return { outcome: "not-judged" };
|
|
14529
|
+
if (listDeviceIdsByCaps === void 0) return {
|
|
14530
|
+
outcome: "unchecked",
|
|
14531
|
+
reason: "unwired",
|
|
14532
|
+
ids: judged
|
|
14533
|
+
};
|
|
14534
|
+
const read = await readCompatibleIds(queries, listDeviceIdsByCaps);
|
|
14535
|
+
if (!read.ok) return {
|
|
14536
|
+
outcome: "unchecked",
|
|
14537
|
+
reason: "read-failed",
|
|
14538
|
+
ids: judged,
|
|
14539
|
+
error: read.error
|
|
14540
|
+
};
|
|
14541
|
+
if (read.ids.size === 0) return {
|
|
14542
|
+
outcome: "unchecked",
|
|
14543
|
+
reason: "answered-empty",
|
|
14544
|
+
ids: judged
|
|
14545
|
+
};
|
|
14546
|
+
const incompatible = judged.filter((id) => !read.ids.has(id));
|
|
14547
|
+
if (incompatible.length > 0) throw new NcDeviceCompatRefusal(kind, incompatible);
|
|
14548
|
+
return { outcome: "compatible" };
|
|
14549
|
+
}
|
|
14550
|
+
/**
|
|
14551
|
+
* Run the check and report what it could not do: a refusal is dropped work, so
|
|
14552
|
+
* it is logged before it is rethrown; an unchecked acceptance is a guard that
|
|
14553
|
+
* did not run, so it is logged (sampled) too.
|
|
14554
|
+
*/
|
|
14555
|
+
async function runAndReport(rule, judged, ctx) {
|
|
14556
|
+
let verdict;
|
|
14557
|
+
try {
|
|
14558
|
+
verdict = await assertRuleDeviceCompat(rule, ctx.listDeviceIdsByCaps, judged);
|
|
14559
|
+
} catch (err) {
|
|
14560
|
+
if (err instanceof NcDeviceCompatRefusal) warnRefusal$2(ctx.logger, err, ctx);
|
|
14561
|
+
throw err;
|
|
14562
|
+
}
|
|
14563
|
+
if (verdict.outcome === "unchecked") warnUnchecked(require_clip_model_registry.ruleKindOf(rule), verdict, ctx);
|
|
14564
|
+
}
|
|
14565
|
+
function warnRefusal$2(logger, err, ctx) {
|
|
14566
|
+
logger.warn("nc rule refused: device(s) incompatible with the rule kind", { meta: {
|
|
14567
|
+
...ctx.ruleId !== void 0 ? { ruleId: ctx.ruleId } : {},
|
|
14568
|
+
by: ctx.by,
|
|
14569
|
+
kind: err.kind,
|
|
14570
|
+
incompatibleIds: [...err.incompatibleIds]
|
|
14571
|
+
} });
|
|
14572
|
+
}
|
|
14573
|
+
function warnUnchecked(kind, verdict, ctx) {
|
|
14574
|
+
const now = (ctx.now ?? Date.now)();
|
|
14575
|
+
const key = `${ctx.ruleId ?? `new:${ctx.by}`}:${verdict.reason}`;
|
|
14576
|
+
if (ctx.sampler !== void 0 && !ctx.sampler.shouldWarn(key, now)) return;
|
|
14577
|
+
ctx.logger.warn("nc rule saved WITHOUT the device-compatibility check — accepted unchecked", { meta: {
|
|
14578
|
+
...ctx.ruleId !== void 0 ? { ruleId: ctx.ruleId } : {},
|
|
14579
|
+
by: ctx.by,
|
|
14580
|
+
kind,
|
|
14581
|
+
reason: verdict.reason,
|
|
14582
|
+
uncheckedIds: [...verdict.ids],
|
|
14583
|
+
...verdict.error !== void 0 ? { error: verdict.error } : {}
|
|
14584
|
+
} });
|
|
14585
|
+
}
|
|
14586
|
+
/** The create path: the whole include list is judged. */
|
|
14587
|
+
async function guardRuleDeviceCompat(rule, ctx) {
|
|
14588
|
+
await runAndReport(rule, rule.conditions.devices ?? [], ctx);
|
|
14589
|
+
}
|
|
14590
|
+
/**
|
|
14591
|
+
* The update path: the MERGED rule decides the kind, but only the ids the
|
|
14592
|
+
* patch ADDS to `conditions.devices` are judged — a stored id is never
|
|
14593
|
+
* re-judged (see the file header). A patch that adds nothing never pays for a
|
|
14594
|
+
* capability read.
|
|
14595
|
+
*/
|
|
14596
|
+
async function guardPatchDeviceCompat(existing, patch, ctx) {
|
|
14597
|
+
if (existing === null) return;
|
|
14598
|
+
const merged = mergedFamilySubject(existing, patch);
|
|
14599
|
+
if (merged === null) return;
|
|
14600
|
+
const stored = new Set(existing.conditions.devices ?? []);
|
|
14601
|
+
await runAndReport(merged, (merged.conditions.devices ?? []).filter((id) => !stored.has(id)), ctx);
|
|
14602
|
+
}
|
|
14603
|
+
//#endregion
|
|
14362
14604
|
//#region src/notification-center/rule-summary-guard.ts
|
|
14363
14605
|
/**
|
|
14364
14606
|
* The hub's half of D647: `systemEventDelivery: { mode: 'summary' }` belongs to
|
|
@@ -17218,6 +17460,8 @@ function requireAdmin(caller) {
|
|
|
17218
17460
|
return c;
|
|
17219
17461
|
}
|
|
17220
17462
|
function makeNcActionHandlers(deps) {
|
|
17463
|
+
/** One sampler per handler set: an unchecked save warns once per rule per window. */
|
|
17464
|
+
const deviceCompatWarnSampler = new NcDeviceCompatWarnSampler();
|
|
17221
17465
|
/** The alarm half, or a refusal by name — never a made-up "disarmed". */
|
|
17222
17466
|
const requireAlarm = (action) => {
|
|
17223
17467
|
if (deps.alarm === void 0) throw new Error(`${action}: the alarm is not wired on this node`);
|
|
@@ -17375,6 +17619,12 @@ function makeNcActionHandlers(deps) {
|
|
|
17375
17619
|
logger: deps.logger,
|
|
17376
17620
|
by: c.userId
|
|
17377
17621
|
});
|
|
17622
|
+
await guardRuleDeviceCompat(parsed, {
|
|
17623
|
+
logger: deps.logger,
|
|
17624
|
+
by: c.userId,
|
|
17625
|
+
listDeviceIdsByCaps: deps.listDeviceIdsByCaps,
|
|
17626
|
+
sampler: deviceCompatWarnSampler
|
|
17627
|
+
});
|
|
17378
17628
|
guardRuleSystemEventDelivery(parsed, {
|
|
17379
17629
|
logger: deps.logger,
|
|
17380
17630
|
by: c.userId
|
|
@@ -17417,6 +17667,13 @@ function makeNcActionHandlers(deps) {
|
|
|
17417
17667
|
by: c.userId,
|
|
17418
17668
|
ruleId: input.ruleId
|
|
17419
17669
|
});
|
|
17670
|
+
await guardPatchDeviceCompat(existing, patch, {
|
|
17671
|
+
logger: deps.logger,
|
|
17672
|
+
by: c.userId,
|
|
17673
|
+
ruleId: input.ruleId,
|
|
17674
|
+
listDeviceIdsByCaps: deps.listDeviceIdsByCaps,
|
|
17675
|
+
sampler: deviceCompatWarnSampler
|
|
17676
|
+
});
|
|
17420
17677
|
guardPatchSystemEventDelivery(existing, patch, clear, {
|
|
17421
17678
|
logger: deps.logger,
|
|
17422
17679
|
by: c.userId,
|
|
@@ -17918,9 +18175,9 @@ function makeNcActionHandlers(deps) {
|
|
|
17918
18175
|
* reproduce.
|
|
17919
18176
|
*/
|
|
17920
18177
|
var NC_CLIP_DISPATCH_MARGIN_SEC = 10;
|
|
17921
|
-
/** Does this
|
|
17922
|
-
function wantsFootage(
|
|
17923
|
-
return
|
|
18178
|
+
/** Does this media policy ever cut footage from the broker's clip ring? */
|
|
18179
|
+
function wantsFootage(media) {
|
|
18180
|
+
return media.gif === true || media.clip === true;
|
|
17924
18181
|
}
|
|
17925
18182
|
/** The ask ONE rule makes, or `null` when it needs no retained footage. */
|
|
17926
18183
|
function askSecondsFor(rule) {
|
|
@@ -17929,9 +18186,10 @@ function askSecondsFor(rule) {
|
|
|
17929
18186
|
seconds: require_clip_model_registry.NC_CLIP_RETENTION_MAX_SEC,
|
|
17930
18187
|
reason: "occupancy"
|
|
17931
18188
|
};
|
|
17932
|
-
|
|
17933
|
-
|
|
17934
|
-
const
|
|
18189
|
+
const media = effectiveMediaPolicyForRule(rule);
|
|
18190
|
+
if (!wantsFootage(media)) return null;
|
|
18191
|
+
const pre = media.clipPreRollSec ?? require_clip_model_registry.NC_CLIP_PRE_ROLL_DEFAULT_SEC;
|
|
18192
|
+
const post = media.clipPostRollSec ?? require_clip_model_registry.NC_CLIP_POST_ROLL_DEFAULT_SEC;
|
|
17935
18193
|
return {
|
|
17936
18194
|
seconds: Math.min(pre + post + NC_CLIP_DISPATCH_MARGIN_SEC, require_clip_model_registry.NC_CLIP_RETENTION_MAX_SEC),
|
|
17937
18195
|
reason: "window"
|
|
@@ -17950,12 +18208,13 @@ function deriveClipRetentionAsks(rules) {
|
|
|
17950
18208
|
for (const rule of rules) {
|
|
17951
18209
|
const ask = askSecondsFor(rule);
|
|
17952
18210
|
if (ask === null) continue;
|
|
18211
|
+
const profile = effectiveMediaPolicyForRule(rule).profile;
|
|
17953
18212
|
const base = {
|
|
17954
18213
|
seconds: ask.seconds,
|
|
17955
18214
|
reason: ask.reason,
|
|
17956
18215
|
ruleId: rule.id,
|
|
17957
18216
|
ruleName: rule.name,
|
|
17958
|
-
...
|
|
18217
|
+
...profile !== void 0 ? { profile } : {}
|
|
17959
18218
|
};
|
|
17960
18219
|
const devices = rule.conditions.devices;
|
|
17961
18220
|
if (devices === void 0 || devices.length === 0) {
|
|
@@ -19812,6 +20071,114 @@ function ncGroupSubjectText(texts, input) {
|
|
|
19812
20071
|
return texts.join([...names, ...others]);
|
|
19813
20072
|
}
|
|
19814
20073
|
//#endregion
|
|
20074
|
+
//#region src/notification-center/outbox-key.ts
|
|
20075
|
+
/**
|
|
20076
|
+
* The record kinds whose `recordId` is a per-table SQLite rowid (D474), and so
|
|
20077
|
+
* may be reused after a delete. `device-event` is NOT one: sensor events are
|
|
20078
|
+
* TEXT-keyed with a producer-minted id (`sensor-event-store.ts`).
|
|
20079
|
+
*/
|
|
20080
|
+
var ROWID_RECORD_KINDS = new Set([
|
|
20081
|
+
"object-event",
|
|
20082
|
+
"package-event",
|
|
20083
|
+
"audio-event"
|
|
20084
|
+
]);
|
|
20085
|
+
/** Is this kind's record id a reusable rowid? See {@link ROWID_RECORD_KINDS}. */
|
|
20086
|
+
function isRowidRecordKind(kind) {
|
|
20087
|
+
return ROWID_RECORD_KINDS.has(kind);
|
|
20088
|
+
}
|
|
20089
|
+
/** The record identity an outbox key input speaks for. */
|
|
20090
|
+
function outboxRecordIdentity(input) {
|
|
20091
|
+
return {
|
|
20092
|
+
recordId: input.recordId,
|
|
20093
|
+
deviceId: input.deviceId,
|
|
20094
|
+
recordAt: input.payload.subject.timestamp
|
|
20095
|
+
};
|
|
20096
|
+
}
|
|
20097
|
+
/**
|
|
20098
|
+
* The record GENERATION ref — `<recordId>@<deviceId>:<recordAt>`. Exported for
|
|
20099
|
+
* the specs and for a caller that must name a rowid record outside the outbox
|
|
20100
|
+
* (none today); a key is always built through {@link outboxEntryId}.
|
|
20101
|
+
*/
|
|
20102
|
+
function recordGenerationRef(record) {
|
|
20103
|
+
return `${record.recordId}@${String(record.deviceId)}:${String(record.recordAt)}`;
|
|
20104
|
+
}
|
|
20105
|
+
/**
|
|
20106
|
+
* The middle segment of the dedup id:
|
|
20107
|
+
*
|
|
20108
|
+
* 1. an explicit `dedupRef` (upgrade / burst revision);
|
|
20109
|
+
* 2. the track id — `maxPerTrack: 1` (a track id is random, never reused);
|
|
20110
|
+
* 3. for a rowid record, its generation ref;
|
|
20111
|
+
* 4. the record id as is (synthetic ids already carry device + instant).
|
|
20112
|
+
*/
|
|
20113
|
+
function outboxDedupRef(input) {
|
|
20114
|
+
if (input.dedupRef !== void 0) return input.dedupRef;
|
|
20115
|
+
if (input.trackId !== void 0) return input.trackId;
|
|
20116
|
+
if (isRowidRecordKind(input.recordKind)) return recordGenerationRef(outboxRecordIdentity(input));
|
|
20117
|
+
return input.recordId;
|
|
20118
|
+
}
|
|
20119
|
+
/** The outbox row id — the durable per-track / per-record fire guarantee. */
|
|
20120
|
+
function outboxEntryId(input) {
|
|
20121
|
+
return `${input.ruleId}:${outboxDedupRef(input)}:${input.targetId}`;
|
|
20122
|
+
}
|
|
20123
|
+
/**
|
|
20124
|
+
* The id this input had BEFORE D694 — but only when it differs from today's,
|
|
20125
|
+
* i.e. for a rowid record keyed on its record id. `null` otherwise: there is no
|
|
20126
|
+
* older spelling to look for.
|
|
20127
|
+
*/
|
|
20128
|
+
function legacyOutboxEntryId(input) {
|
|
20129
|
+
if (input.dedupRef !== void 0 || input.trackId !== void 0) return null;
|
|
20130
|
+
if (!isRowidRecordKind(input.recordKind)) return null;
|
|
20131
|
+
return `${input.ruleId}:${input.recordId}:${input.targetId}`;
|
|
20132
|
+
}
|
|
20133
|
+
/**
|
|
20134
|
+
* A STORED row's id as it would be built today, when the row was written under
|
|
20135
|
+
* the pre-D694 spelling; `null` when the stored id is already current (or was
|
|
20136
|
+
* built from an explicit dedup ref, which a row does not record and which never
|
|
20137
|
+
* involved the record id).
|
|
20138
|
+
*/
|
|
20139
|
+
function translatedLegacyEntryId(storedId, row) {
|
|
20140
|
+
const legacy = legacyOutboxEntryId(row);
|
|
20141
|
+
if (legacy === null || legacy !== storedId) return null;
|
|
20142
|
+
return outboxEntryId(row);
|
|
20143
|
+
}
|
|
20144
|
+
/**
|
|
20145
|
+
* Does a STORED row's payload really carry its record timestamp?
|
|
20146
|
+
*
|
|
20147
|
+
* The type says it always does, but a stored payload is checked only as an
|
|
20148
|
+
* object on the way back from the store (`rowToEntry`), so a malformed or very
|
|
20149
|
+
* old row can lack `subject`. Every read of {@link outboxRecordIdentity} on a
|
|
20150
|
+
* STORED row is preceded by this check. Takes the payload as `unknown` on
|
|
20151
|
+
* purpose: the static type cannot answer the question.
|
|
20152
|
+
*/
|
|
20153
|
+
function hasRecordTimestamp(payload) {
|
|
20154
|
+
if (typeof payload !== "object" || payload === null || !("subject" in payload)) return false;
|
|
20155
|
+
const subject = payload.subject;
|
|
20156
|
+
return typeof subject === "object" && subject !== null && "timestamp" in subject && typeof subject.timestamp === "number";
|
|
20157
|
+
}
|
|
20158
|
+
/** Do two rows speak for the same RECORD? Id, camera and record timestamp. */
|
|
20159
|
+
function isSameOutboxRecord(a, b) {
|
|
20160
|
+
return a.recordId === b.recordId && a.deviceId === b.deviceId && a.recordAt === b.recordAt;
|
|
20161
|
+
}
|
|
20162
|
+
/**
|
|
20163
|
+
* The scope of the event a payload's `eventId` names.
|
|
20164
|
+
*
|
|
20165
|
+
* Usually the row's own subject. A COMBINED alarm row is the exception: its
|
|
20166
|
+
* subject is the alarm transition (its own instant), while its `eventId` is the
|
|
20167
|
+
* triggering rule's event — so the generation is the alarm SOURCE's camera and
|
|
20168
|
+
* timestamp (`NcRuleFireRef`), never the transition's.
|
|
20169
|
+
*/
|
|
20170
|
+
function eventMediaScopeOf(subject) {
|
|
20171
|
+
const source = subject.systemEvent?.alarmSource;
|
|
20172
|
+
if (source !== void 0) return {
|
|
20173
|
+
deviceId: source.deviceId,
|
|
20174
|
+
recordAt: source.timestamp
|
|
20175
|
+
};
|
|
20176
|
+
return {
|
|
20177
|
+
deviceId: subject.deviceId,
|
|
20178
|
+
recordAt: subject.timestamp
|
|
20179
|
+
};
|
|
20180
|
+
}
|
|
20181
|
+
//#endregion
|
|
19815
20182
|
//#region src/notification-center/footage-source.ts
|
|
19816
20183
|
/**
|
|
19817
20184
|
* Decide where this window must be cut from. Pure and total.
|
|
@@ -21777,7 +22144,7 @@ var NcDispatcher = class {
|
|
|
21777
22144
|
});
|
|
21778
22145
|
if (policy === "keyFrame" || frame === "full" || frame === "boxed") owners.reverse();
|
|
21779
22146
|
for (const owner of owners) try {
|
|
21780
|
-
const files = await this.deps.getMediaForOwner(owner.kind, owner.id);
|
|
22147
|
+
const files = owner.kind === "object" ? await this.deps.getMediaForOwner(owner.kind, owner.id, eventMediaScopeOf(subject)) : await this.deps.getMediaForOwner(owner.kind, owner.id);
|
|
21781
22148
|
if (files.length === 0) continue;
|
|
21782
22149
|
const preference = frame !== void 0 ? framePreference(frame, owner.kind) : policy === "best-matching" ? bestMatchingKindPreference(signal, owner.kind) : attachmentKindPreference(policy, owner.kind);
|
|
21783
22150
|
const ordered = [];
|
|
@@ -24062,11 +24429,6 @@ var DEFAULT_MAX_ATTEMPTS = 8;
|
|
|
24062
24429
|
var DEFAULT_DRAIN_BATCH = 20;
|
|
24063
24430
|
/** Dedup ids remembered at boot — bounded to recent rows (dedup horizon). */
|
|
24064
24431
|
var DEDUP_HYDRATE_WINDOW_MS = 168 * 36e5;
|
|
24065
|
-
/** Stable dedup id — the durable per-track/per-record fire guarantee. */
|
|
24066
|
-
function outboxEntryId(input) {
|
|
24067
|
-
const dedupRef = input.dedupRef ?? input.trackId ?? input.recordId;
|
|
24068
|
-
return `${input.ruleId}:${dedupRef}:${input.targetId}`;
|
|
24069
|
-
}
|
|
24070
24432
|
var NcOutbox = class {
|
|
24071
24433
|
pending = /* @__PURE__ */ new Map();
|
|
24072
24434
|
/** Every id ever enqueued within the dedup horizon (incl. sent/dead). */
|
|
@@ -24143,20 +24505,27 @@ var NcOutbox = class {
|
|
|
24143
24505
|
}
|
|
24144
24506
|
});
|
|
24145
24507
|
let superseded = 0;
|
|
24508
|
+
let translated = 0;
|
|
24146
24509
|
for (const row of rows) {
|
|
24147
|
-
|
|
24510
|
+
const entry = rowToEntry$1(row.id, row.data);
|
|
24511
|
+
const current = entry === null ? null : this.translateLegacy(row.id, entry);
|
|
24512
|
+
if (this.knownIds.has(row.id) || current !== null && this.knownIds.has(current)) {
|
|
24148
24513
|
superseded += 1;
|
|
24149
24514
|
continue;
|
|
24150
24515
|
}
|
|
24151
24516
|
this.knownIds.add(row.id);
|
|
24152
|
-
|
|
24517
|
+
if (current !== null) {
|
|
24518
|
+
this.knownIds.add(current);
|
|
24519
|
+
translated += 1;
|
|
24520
|
+
}
|
|
24153
24521
|
if (entry !== null && entry.status === "pending") this.pending.set(entry.id, entry);
|
|
24154
24522
|
}
|
|
24155
24523
|
this.horizonLoaded = true;
|
|
24156
24524
|
this.logger.info("notification outbox loaded", { meta: {
|
|
24157
24525
|
pending: this.pending.size,
|
|
24158
24526
|
known: this.knownIds.size,
|
|
24159
|
-
superseded
|
|
24527
|
+
superseded,
|
|
24528
|
+
translated
|
|
24160
24529
|
} });
|
|
24161
24530
|
} catch (err) {
|
|
24162
24531
|
this.logger.warn("notification outbox load failed", { meta: { error: String(err) } });
|
|
@@ -24180,7 +24549,7 @@ var NcOutbox = class {
|
|
|
24180
24549
|
for (const input of inputs) {
|
|
24181
24550
|
const id = outboxEntryId(input);
|
|
24182
24551
|
if (this.knownIds.has(id)) continue;
|
|
24183
|
-
if (!this.horizonLoaded && await this.
|
|
24552
|
+
if (!this.horizonLoaded && await this.alreadyStored(id, input)) {
|
|
24184
24553
|
this.knownIds.add(id);
|
|
24185
24554
|
this.logger.info("outbox enqueue deduped against the store — the horizon is still cold", {
|
|
24186
24555
|
tags: { deviceId: input.deviceId },
|
|
@@ -24397,7 +24766,7 @@ var NcOutbox = class {
|
|
|
24397
24766
|
*/
|
|
24398
24767
|
async supersede(input) {
|
|
24399
24768
|
const wanted = new Set(input.targetIds);
|
|
24400
|
-
const victims = [...this.pending.values()].filter((e) => e.ruleId === input.ruleId && e.
|
|
24769
|
+
const victims = [...this.pending.values()].filter((e) => e.ruleId === input.ruleId && wanted.has(e.targetId) && hasRecordTimestamp(e.payload) && isSameOutboxRecord(outboxRecordIdentity(e), input));
|
|
24401
24770
|
let dropped = 0;
|
|
24402
24771
|
for (const entry of victims) try {
|
|
24403
24772
|
await this.store.delete.mutate({
|
|
@@ -24513,6 +24882,81 @@ var NcOutbox = class {
|
|
|
24513
24882
|
await this.mutate(retry);
|
|
24514
24883
|
}
|
|
24515
24884
|
/**
|
|
24885
|
+
* Cold-horizon dedup: is this intent already a row in the store?
|
|
24886
|
+
*
|
|
24887
|
+
* Today's id first. Then, for a rowid record, the pre-D694 spelling — which
|
|
24888
|
+
* is accepted ONLY when the row found speaks for the SAME record (camera and
|
|
24889
|
+
* record timestamp too). A legacy row of a different record that reused the
|
|
24890
|
+
* rowid is exactly the collision D694 removes, and must not swallow this one.
|
|
24891
|
+
*/
|
|
24892
|
+
async alreadyStored(id, input) {
|
|
24893
|
+
if (await this.rowExists(id)) return true;
|
|
24894
|
+
const legacy = legacyOutboxEntryId(input);
|
|
24895
|
+
if (legacy === null) return false;
|
|
24896
|
+
const row = await this.readRow(legacy);
|
|
24897
|
+
if (row === null) return false;
|
|
24898
|
+
if (!hasRecordTimestamp(row.payload)) {
|
|
24899
|
+
this.logger.warn("outbox: a pre-D694 row carries no record timestamp — not used for dedup", {
|
|
24900
|
+
tags: { deviceId: row.deviceId },
|
|
24901
|
+
meta: {
|
|
24902
|
+
id,
|
|
24903
|
+
legacyId: legacy,
|
|
24904
|
+
ruleId: input.ruleId
|
|
24905
|
+
}
|
|
24906
|
+
});
|
|
24907
|
+
return false;
|
|
24908
|
+
}
|
|
24909
|
+
if (isSameOutboxRecord(outboxRecordIdentity(row), outboxRecordIdentity(input))) return true;
|
|
24910
|
+
this.logger.info("outbox: a pre-D694 row shares this rowid but is a different record — not a duplicate", {
|
|
24911
|
+
tags: { deviceId: input.deviceId },
|
|
24912
|
+
meta: {
|
|
24913
|
+
id,
|
|
24914
|
+
legacyId: legacy,
|
|
24915
|
+
legacyDeviceId: row.deviceId,
|
|
24916
|
+
ruleId: input.ruleId
|
|
24917
|
+
}
|
|
24918
|
+
});
|
|
24919
|
+
return false;
|
|
24920
|
+
}
|
|
24921
|
+
/**
|
|
24922
|
+
* The id a stored pre-D694 row would have today, or `null`.
|
|
24923
|
+
*
|
|
24924
|
+
* Guarded per row: a stored payload is validated only as an object
|
|
24925
|
+
* (`rowToEntry`), and a row with no `subject.timestamp` would otherwise throw
|
|
24926
|
+
* inside {@link load} and leave the WHOLE dedup horizon cold. Such a row is
|
|
24927
|
+
* still loaded under its stored id; it just gets no translated twin.
|
|
24928
|
+
*/
|
|
24929
|
+
translateLegacy(storedId, entry) {
|
|
24930
|
+
if (legacyOutboxEntryId(entry) !== storedId) return null;
|
|
24931
|
+
if (!hasRecordTimestamp(entry.payload)) {
|
|
24932
|
+
this.logger.warn("outbox: a pre-D694 row carries no record timestamp — loaded without translation", {
|
|
24933
|
+
tags: { deviceId: entry.deviceId },
|
|
24934
|
+
meta: {
|
|
24935
|
+
id: storedId,
|
|
24936
|
+
ruleId: entry.ruleId
|
|
24937
|
+
}
|
|
24938
|
+
});
|
|
24939
|
+
return null;
|
|
24940
|
+
}
|
|
24941
|
+
return translatedLegacyEntryId(storedId, entry);
|
|
24942
|
+
}
|
|
24943
|
+
/** One stored row by id, or `null` (absent, unreadable, or a failed read). */
|
|
24944
|
+
async readRow(id) {
|
|
24945
|
+
try {
|
|
24946
|
+
const row = await this.store.get.query({
|
|
24947
|
+
collection: NC_OUTBOX_COLLECTION,
|
|
24948
|
+
key: id
|
|
24949
|
+
});
|
|
24950
|
+
return isStoredRow(row) ? rowToEntry$1(id, row) : null;
|
|
24951
|
+
} catch (err) {
|
|
24952
|
+
this.logger.debug("outbox cold-horizon dedup read failed", { meta: {
|
|
24953
|
+
id,
|
|
24954
|
+
error: String(err)
|
|
24955
|
+
} });
|
|
24956
|
+
return null;
|
|
24957
|
+
}
|
|
24958
|
+
}
|
|
24959
|
+
/**
|
|
24516
24960
|
* Does a row already exist under this dedup id?
|
|
24517
24961
|
*
|
|
24518
24962
|
* Asked ONLY while {@link horizonLoaded} is false. {@link persist} writes
|
|
@@ -24606,6 +25050,10 @@ var OUTBOX_RECORD_KINDS = new Set([
|
|
|
24606
25050
|
function isOutboxRecordKind(x) {
|
|
24607
25051
|
return typeof x === "string" && OUTBOX_RECORD_KINDS.has(x);
|
|
24608
25052
|
}
|
|
25053
|
+
/** `settingsStore.get` answers `unknown` — a guard, never a cast. */
|
|
25054
|
+
function isStoredRow(x) {
|
|
25055
|
+
return typeof x === "object" && x !== null && !Array.isArray(x);
|
|
25056
|
+
}
|
|
24609
25057
|
/** A stored row back to an entry — a guard, never a cast. Exported for the
|
|
24610
25058
|
* specs that read the store a centre wrote to. */
|
|
24611
25059
|
function rowToEntry$1(id, data) {
|
|
@@ -31569,9 +32017,15 @@ var NC_ALARM_ANNOUNCE_RULE_ID = "nc-alarm-announce";
|
|
|
31569
32017
|
* A rule's media policy, as the fields a row freezes — ONE derivation, used by
|
|
31570
32018
|
* every row a rule addresses AND by the alarm's own announcement row when it is
|
|
31571
32019
|
* the COMBINED siren (the triggering rule's pictures on the alarm's words).
|
|
32020
|
+
*
|
|
32021
|
+
* `effectiveMediaPolicyForRule` is what actually resolves it: a SENSOR rule's
|
|
32022
|
+
* stored `media` is ignored here, in favour of the one-snapshot default
|
|
32023
|
+
* (operator ruling, 2026-10-01) — the rule still LOADS with whatever it has
|
|
32024
|
+
* stored, and `buildEntries` is where the sampled log fires when that stored
|
|
32025
|
+
* choice is about to be overridden.
|
|
31572
32026
|
*/
|
|
31573
32027
|
function mediaPolicyFields(mediaRule, cropZoneIds) {
|
|
31574
|
-
const media = mediaRule
|
|
32028
|
+
const media = effectiveMediaPolicyForRule(mediaRule);
|
|
31575
32029
|
return {
|
|
31576
32030
|
media: media.attach,
|
|
31577
32031
|
...media.zoneCrop === true && cropZoneIds !== void 0 ? { mediaZoneIds: [...cropZoneIds] } : {},
|
|
@@ -31865,13 +32319,14 @@ function cooldownSubjectKind(kind) {
|
|
|
31865
32319
|
* the same builder the live path does, so the two can never address different
|
|
31866
32320
|
* windows. Only the four fields the key is a function of are reconstructed —
|
|
31867
32321
|
* the class (for `per-class` and audio), the subject kind (which decides
|
|
31868
|
-
* whether
|
|
32322
|
+
* whether the class is keyed at all — audio always, occupancy and detections
|
|
32323
|
+
* unless `shared`), the system event (subject +
|
|
31869
32324
|
* direction) and the device.
|
|
31870
32325
|
*/
|
|
31871
32326
|
function cooldownSubjectFromOutbox(entry) {
|
|
31872
32327
|
const subject = entry.payload.subject;
|
|
31873
32328
|
return {
|
|
31874
|
-
kind: cooldownSubjectKind(entry.recordKind),
|
|
32329
|
+
kind: entry.recordKind === "device-event" && subject.occupancy !== void 0 ? "occupancy-event" : cooldownSubjectKind(entry.recordKind),
|
|
31875
32330
|
recordId: entry.recordId,
|
|
31876
32331
|
deviceId: entry.deviceId,
|
|
31877
32332
|
timestamp: subject.timestamp,
|
|
@@ -31950,6 +32405,9 @@ function renderAudioScope(devices, excluded) {
|
|
|
31950
32405
|
if (devices.length === 0) return excluded.length === 0 ? "@all" : `@all-except:${excluded.join("+")}`;
|
|
31951
32406
|
return devices.filter((id) => !excluded.includes(id)).join("+");
|
|
31952
32407
|
}
|
|
32408
|
+
/** {@link NotificationCenter.reportedSensorMediaIgnored} cap, same shape as
|
|
32409
|
+
* the dispatcher's own annotated-fallback memo. */
|
|
32410
|
+
var NC_SENSOR_MEDIA_IGNORED_MEMO_MAX = 5e3;
|
|
31953
32411
|
var NotificationCenter = class NotificationCenter {
|
|
31954
32412
|
logger;
|
|
31955
32413
|
rules;
|
|
@@ -31957,6 +32415,11 @@ var NotificationCenter = class NotificationCenter {
|
|
|
31957
32415
|
dispatcher;
|
|
31958
32416
|
deps;
|
|
31959
32417
|
now;
|
|
32418
|
+
/** Rule ids whose stored (non-default) media policy has already been
|
|
32419
|
+
* reported as ignored at evaluation — ONE line per rule, not per fire. */
|
|
32420
|
+
reportedSensorMediaIgnored = /* @__PURE__ */ new Set();
|
|
32421
|
+
/** Samples the device-compat guard's "accepted unchecked" WARN per rule. */
|
|
32422
|
+
deviceCompatWarnSampler = new NcDeviceCompatWarnSampler();
|
|
31960
32423
|
/**
|
|
31961
32424
|
* Every word this centre can print, in the hub's language.
|
|
31962
32425
|
*
|
|
@@ -32957,7 +33420,7 @@ var NotificationCenter = class NotificationCenter {
|
|
|
32957
33420
|
...deps.dispatcher,
|
|
32958
33421
|
logger: this.logger.child("dispatch"),
|
|
32959
33422
|
texts: this.texts,
|
|
32960
|
-
getMediaForOwner: async (ownerKind, ownerId) => {
|
|
33423
|
+
getMediaForOwner: async (ownerKind, ownerId, eventScope) => {
|
|
32961
33424
|
const held = isSyntheticId(ownerId) ? this.syntheticMedia.get(ownerId) : void 0;
|
|
32962
33425
|
if (held !== void 0) return held.files;
|
|
32963
33426
|
const shelved = isStillShelfId(ownerId) ? this.stills?.get(ownerId) : void 0;
|
|
@@ -32966,7 +33429,7 @@ var NotificationCenter = class NotificationCenter {
|
|
|
32966
33429
|
if (linked !== void 0) return linked;
|
|
32967
33430
|
const departed = ownerKind === "track" && this.departureFallbacks.has(ownerId);
|
|
32968
33431
|
const owner = departed ? await this.resolveDepartedTrack(ownerId) : ownerId;
|
|
32969
|
-
const files = await deps.dispatcher.getMediaForOwner(ownerKind, owner);
|
|
33432
|
+
const files = await deps.dispatcher.getMediaForOwner(ownerKind, owner, eventScope);
|
|
32970
33433
|
if (files.length > 0 || !departed) return files;
|
|
32971
33434
|
return this.departureFallbackMedia(ownerId) ?? files;
|
|
32972
33435
|
},
|
|
@@ -33737,7 +34200,7 @@ var NotificationCenter = class NotificationCenter {
|
|
|
33737
34200
|
const shown = this.unmutedCameras(cameraIds);
|
|
33738
34201
|
const involved = [sensorId, ...rowShown];
|
|
33739
34202
|
if (!this.rules.listEnabled("device-event").some((rule) => {
|
|
33740
|
-
if (rule.
|
|
34203
|
+
if (effectiveMediaPolicyForRule(rule).attach === "none") return false;
|
|
33741
34204
|
if (!deviceEventRuleMayFireOnKind(rule, sensorKind, sensorRaised)) return false;
|
|
33742
34205
|
const excluded = rule.conditions.devicesExclude;
|
|
33743
34206
|
if (excluded !== void 0 && deviceScopeExcludes(excluded, {
|
|
@@ -34034,7 +34497,7 @@ var NotificationCenter = class NotificationCenter {
|
|
|
34034
34497
|
ruleWantsOccupancyStill(edge) {
|
|
34035
34498
|
const subject = occupancySubjectFromEdge(edge);
|
|
34036
34499
|
for (const rule of this.rules.listEnabled("device-event")) {
|
|
34037
|
-
if (rule.
|
|
34500
|
+
if (effectiveMediaPolicyForRule(rule).attach === "none") continue;
|
|
34038
34501
|
const occ = rule.conditions.occupancy;
|
|
34039
34502
|
if (occ === void 0 || !matchesOccupancy(occ, subject)) continue;
|
|
34040
34503
|
const devices = rule.conditions.devices;
|
|
@@ -34301,6 +34764,12 @@ var NotificationCenter = class NotificationCenter {
|
|
|
34301
34764
|
logger: this.logger,
|
|
34302
34765
|
by: caller.userId
|
|
34303
34766
|
});
|
|
34767
|
+
await guardRuleDeviceCompat(rule, {
|
|
34768
|
+
logger: this.logger,
|
|
34769
|
+
by: caller.userId,
|
|
34770
|
+
listDeviceIdsByCaps: this.deps.listDeviceIdsByCaps,
|
|
34771
|
+
sampler: this.deviceCompatWarnSampler
|
|
34772
|
+
});
|
|
34304
34773
|
guardRuleSystemEventDelivery(rule, {
|
|
34305
34774
|
logger: this.logger,
|
|
34306
34775
|
by: caller.userId
|
|
@@ -34338,6 +34807,13 @@ var NotificationCenter = class NotificationCenter {
|
|
|
34338
34807
|
by: caller.userId,
|
|
34339
34808
|
ruleId
|
|
34340
34809
|
});
|
|
34810
|
+
await guardPatchDeviceCompat(existing, patch, {
|
|
34811
|
+
logger: this.logger,
|
|
34812
|
+
by: caller.userId,
|
|
34813
|
+
ruleId,
|
|
34814
|
+
listDeviceIdsByCaps: this.deps.listDeviceIdsByCaps,
|
|
34815
|
+
sampler: this.deviceCompatWarnSampler
|
|
34816
|
+
});
|
|
34341
34817
|
guardPatchSystemEventDelivery(existing, patch, clear ?? [], {
|
|
34342
34818
|
logger: this.logger,
|
|
34343
34819
|
by: caller.userId,
|
|
@@ -35100,6 +35576,8 @@ var NotificationCenter = class NotificationCenter {
|
|
|
35100
35576
|
const dropped = await this.outbox.supersede({
|
|
35101
35577
|
ruleId: source.ruleId,
|
|
35102
35578
|
recordId: source.recordId,
|
|
35579
|
+
deviceId: source.deviceId,
|
|
35580
|
+
recordAt: source.timestamp,
|
|
35103
35581
|
targetIds: [...combined]
|
|
35104
35582
|
});
|
|
35105
35583
|
this.logger.info("alarm notification combined with the rule that triggered it", {
|
|
@@ -35385,7 +35863,7 @@ var NotificationCenter = class NotificationCenter {
|
|
|
35385
35863
|
buttons: unresolvable.join(", ")
|
|
35386
35864
|
}
|
|
35387
35865
|
});
|
|
35388
|
-
if (!noOwnerReported && kind === "device-event" && subject.trackId === void 0 && subject.linkedStills === void 0 && rule.
|
|
35866
|
+
if (!noOwnerReported && kind === "device-event" && subject.trackId === void 0 && subject.linkedStills === void 0 && effectiveMediaPolicyForRule(rule).attach !== "none") {
|
|
35389
35867
|
noOwnerReported = true;
|
|
35390
35868
|
const linked = subject.linkedCameraIds;
|
|
35391
35869
|
const meta = {
|
|
@@ -35763,6 +36241,24 @@ var NotificationCenter = class NotificationCenter {
|
|
|
35763
36241
|
}
|
|
35764
36242
|
});
|
|
35765
36243
|
}
|
|
36244
|
+
/**
|
|
36245
|
+
* ONE line per rule id, the first time a SENSOR rule's stored media policy
|
|
36246
|
+
* is about to be overridden by {@link effectiveMediaPolicyForRule}'s
|
|
36247
|
+
* one-snapshot default (operator ruling, 2026-10-01). Never refuses the
|
|
36248
|
+
* rule — it still loads and still fires — this only says the stored choice
|
|
36249
|
+
* is not the one being used.
|
|
36250
|
+
*/
|
|
36251
|
+
warnSensorMediaIgnoredOnce(rule) {
|
|
36252
|
+
if (mediaPolicyMatchesSensorDefault(rule.media)) return;
|
|
36253
|
+
if (this.reportedSensorMediaIgnored.has(rule.id)) return;
|
|
36254
|
+
if (this.reportedSensorMediaIgnored.size >= NC_SENSOR_MEDIA_IGNORED_MEMO_MAX) this.reportedSensorMediaIgnored.clear();
|
|
36255
|
+
this.reportedSensorMediaIgnored.add(rule.id);
|
|
36256
|
+
this.logger.info("sensor rule carries a media choice — ignored at evaluation, one snapshot is always attached", { meta: {
|
|
36257
|
+
ruleId: rule.id,
|
|
36258
|
+
rule: rule.name,
|
|
36259
|
+
storedMedia: rule.media
|
|
36260
|
+
} });
|
|
36261
|
+
}
|
|
35766
36262
|
buildEntries(rule, subject, kind, matchedOn, userTargets = [], origin = "pipeline", cooldown, upgrade, group) {
|
|
35767
36263
|
const hasEventMedia = kind === "object-event" || kind === "package-event";
|
|
35768
36264
|
const isTrackScoped = kind === "object-event" || kind === "track-end";
|
|
@@ -35770,6 +36266,7 @@ var NotificationCenter = class NotificationCenter {
|
|
|
35770
36266
|
const alarmSource = subject.systemEvent?.alarmSource;
|
|
35771
36267
|
const mediaRule = alarmSource !== void 0 ? this.rules.get(alarmSource.ruleId) ?? rule : rule;
|
|
35772
36268
|
const noMedia = isSystemEvent && alarmSource === void 0;
|
|
36269
|
+
if (require_clip_model_registry.ruleKindOf(mediaRule) === "sensor") this.warnSensorMediaIgnoredOnce(mediaRule);
|
|
35773
36270
|
const direct = new Set(rule.targets.map((t) => t.targetId));
|
|
35774
36271
|
const renderedZones = notificationZones(rule, subject);
|
|
35775
36272
|
const confirm = frozenConfirmPolicy(rule, isSystemEvent);
|
|
@@ -95822,6 +96319,30 @@ async function encodeClipQuery(encode, text, modelId, warn) {
|
|
|
95822
96319
|
}
|
|
95823
96320
|
}
|
|
95824
96321
|
//#endregion
|
|
96322
|
+
//#region src/pipeline-analytics/nc-owner-media-reader.ts
|
|
96323
|
+
/** Keep only the rows written for THIS generation of the event id. */
|
|
96324
|
+
function ofGeneration(files, scope) {
|
|
96325
|
+
return files.filter((f) => f.timestamp === scope.recordAt);
|
|
96326
|
+
}
|
|
96327
|
+
function makeNcOwnerMediaReader(deps) {
|
|
96328
|
+
return async (ownerKind, ownerId, eventScope) => {
|
|
96329
|
+
if (ownerKind === "track") return deps.media.listByOwner("track", await deps.resolveTrackOwnerId(ownerId));
|
|
96330
|
+
if (eventScope === void 0) return deps.media.listByOwner("object", ownerId);
|
|
96331
|
+
const onCamera = await deps.media.listByOwner("object", ownerId, eventScope.deviceId);
|
|
96332
|
+
const kept = ofGeneration(onCamera, eventScope);
|
|
96333
|
+
if (kept.length < onCamera.length) deps.logger.warn("event media of another record on a reused event id — not used", {
|
|
96334
|
+
tags: { deviceId: eventScope.deviceId },
|
|
96335
|
+
meta: {
|
|
96336
|
+
ownerId,
|
|
96337
|
+
recordAt: eventScope.recordAt,
|
|
96338
|
+
refused: onCamera.length - kept.length,
|
|
96339
|
+
kept: kept.length
|
|
96340
|
+
}
|
|
96341
|
+
});
|
|
96342
|
+
return kept;
|
|
96343
|
+
};
|
|
96344
|
+
}
|
|
96345
|
+
//#endregion
|
|
95825
96346
|
//#region src/pipeline-analytics/store/settings-raw-mirror.ts
|
|
95826
96347
|
/** How long a mirrored entry is served before a refresh is started behind the
|
|
95827
96348
|
* next reader. See the module doc for why this matches the per-kind caches'
|
|
@@ -98994,6 +99515,7 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_clip_m
|
|
|
98994
99515
|
listCallerTargetIds: async (userId) => {
|
|
98995
99516
|
return (await api.notificationOutput.listTargets.query({})).filter((t) => t.config["ownerUserId"] === userId).map((t) => t.id);
|
|
98996
99517
|
},
|
|
99518
|
+
listDeviceIdsByCaps: (capNames) => api.deviceManager.listDeviceIdsByCaps.query({ capNames: [...capNames] }),
|
|
98997
99519
|
dropRuleMosaics: async (ruleId) => {
|
|
98998
99520
|
const mediaStore = this.mediaStore;
|
|
98999
99521
|
if (mediaStore === null) return;
|
|
@@ -99910,6 +100432,7 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_clip_m
|
|
|
99910
100432
|
addonId: device.addonId
|
|
99911
100433
|
};
|
|
99912
100434
|
},
|
|
100435
|
+
listDeviceIdsByCaps: (capNames) => api.deviceManager.listDeviceIdsByCaps.query({ capNames: [...capNames] }),
|
|
99913
100436
|
isDeviceDeleted: async (deviceId) => await api.deviceManager.getDevice.query({ deviceId }) === null,
|
|
99914
100437
|
readNodeDirectory: () => api.nodes.directory.query(),
|
|
99915
100438
|
getSnapshot: (deviceId) => api.snapshot.getSnapshot.query({ deviceId }),
|
|
@@ -100144,7 +100667,11 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_clip_m
|
|
|
100144
100667
|
}
|
|
100145
100668
|
});
|
|
100146
100669
|
},
|
|
100147
|
-
getMediaForOwner:
|
|
100670
|
+
getMediaForOwner: makeNcOwnerMediaReader({
|
|
100671
|
+
media: stores.mediaStore,
|
|
100672
|
+
resolveTrackOwnerId: (trackId) => resolveMediaOwnerId("track", trackId, (id) => this.trackAliasStore?.resolve(id) ?? Promise.resolve(null), (failure) => this.aliasResolveFailureLogger?.(failure), "getMediaForOwner"),
|
|
100673
|
+
logger: this.ctx.logger.child("nc-media")
|
|
100674
|
+
}),
|
|
100148
100675
|
getSubjectTrail: async (deviceId, trackId) => {
|
|
100149
100676
|
const dims = this.lastFrameDimsByDevice.get(deviceId);
|
|
100150
100677
|
if (dims === void 0 || dims.w <= 0 || dims.h <= 0) return [];
|