@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
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { $ as NC_AUDIO_CONFIRM_WINDOW_SEC_DEFAULT, $n as
|
|
1
|
+
import { $ as NC_AUDIO_CONFIRM_WINDOW_SEC_DEFAULT, $n as ruleDeviceCompatCapQueries, $t as TimelapseRuleInputSchema, A as EMBEDDING_REQUIREMENTS_FAILED, An as isDetectionMacroClass, Ar as ZodObject, At as NcTemplateVarDescriptorSchema, B as ListTrackRelationsInputSchema, Bn as memoizePinnedFfmpeg, Br as record, Bt as SCENE_DEFAULT_ANCHOR_THRESHOLD, C as DEFAULT_EVENT_COLOR, Cn as encodeVectorBase64, Cr as customAction, Ct as NcSnoozeInputSchema, D as DeclaredDevices, Dn as gifDeriveSpeed, Dr as nodePin, Dt as NcTaxonomySchema, E as DETECTION_PIPELINE_CAP_NAME, En as failureContributionCapability, Er as isDeviceScopedCap, Et as NcSystemEventKindSchema, F as EXPORT_DENSE_MAX_RANGES, Fn as isSystemDelivery, Fr as literal, Ft as RECORDING_SOURCE_CAMSTACK_ADDON, G as MAX_BIRTH_LATENCY_PROXY_MS, Gn as plateGalleryCapability, Gr as unknown, Gt as SCENE_DIVERGED, H as LockStateSchema, Hn as occupancyScope, Hr as string, Ht as SCENE_DEFAULT_OBSERVATION_SPACING_SEC, I as FIRST_LEVEL_MACRO_CLASSES, In as isSystemEventSummaryRule, Ir as looseObject, It as RebuildObjectEmbeddingsRefusalSchema, J as MOTION_CLOSE_AFTER_MS, Jn as readTimelapseGeneratedAt, Jt as SetTrackPurityInputSchema, K as MAX_TRACK_PURITIES, Kn as readDeviceStateFrom, Kr as EventCategory, Kt as SCENE_RESET_RECAPTURES, L as FULL_IMAGE_BBOX, Ln as isVectorIndexDimConflict, Lr as number, Lt as RetrainStatusSchema, M as EVENT_DENSITY_BATCH_MAX, Mn as isKnownTemplateVar, Mr as array, Mt as OpsLogEntrySchema, N as EVENT_KIND_BY_CAP, Nn as isMediaOwnerType, Nr as boolean, O as DetectionSourceSchema, On as gifFromMp4Args, Ot as NcTemplatePreviewInputSchema, P as EVENT_OWNER_TYPES, Pn as isScheduleActive, Pr as discriminatedUnion, Pt as RECORDING_EXPORT_MAX_READ_BYTES, Q as NC_AUDIO_CONFIRM_HITS_DEFAULT, Qn as ruleBelongsToAlarm$1, Qt as TRACK_RELATIONS_DEFAULT_LIMIT, R as FailureCounters, Rn as kebabToCamel, Rr as object, Rt as SCENE_CONFIRM_DEFAULT_MAX_IMAGE_PX, S as CoverStateSchema, Sr as createEvent, St as NcScheduleSchema, T as DETECTION_MACRO_CLASSES, Tn as faceGalleryCapability, Tr as hydrateSchema, Tt as NcSnoozeSuppressedSchema, U as MAX_ARCHIVED_DEBUG_NOTES, Ur as tuple, Ut as SCENE_DEFAULT_QUIET_SECONDS, V as ListTrackRelationsResultSchema, Vn as notificationRulesCapability, Vr as strictObject, Vt as SCENE_DEFAULT_CHECK_INTERVAL_SEC, W as MAX_BIRTH_DECISION_RECORDS, Wn as pipelineAnalyticsCapability, Wr as union, Wt as SCENE_DEFAULT_UNCOVERED_POLICY, X as MotionTriggeredBySchema, Xn as resolveLocationMode, Xt as SetTrackRelationInputSchema, Y as MediaFileKindEnum, Yn as resolveClipSearchMinScore, Yt as SetTrackPurityResultSchema, Z as NC_ALARM_SYSTEM_EVENT_KINDS, Zt as TIMELAPSE_DENSE_FLOOR_SEC, _ as BaseDevice, _n as describeSystemEventFamilyProblem, _r as isSourceCap, _t as NcRuleInputSchema, a as CLIP_EMBEDDING_STEP_ID, an as TrackSideSchema, ar as subKindsOf, at as NC_CONSUMABLE_SYSTEM_EVENT_KINDS, b as ClearTrackRelationInputSchema, bn as embeddingRefusalOf, br as BaseAddon, bt as NcRuleTargetSchema, c as resolveClusterModelPin, cn as alarmPanelCapability, cr as systemEventFilterApplies, ct as NC_SNOOZE_MAX_MINUTES, d as APPEARANCE_RESOLVES_AMBIGUITY_DEFAULT, dn as audioModeOf, dr as templateVarsFor, dt as NC_TAXONOMY, en as TimelapseRulePatchSchema, er as ruleKindOf, et as NC_CLIP_POST_ROLL_DEFAULT_SEC, f as ARCHIVED_DEBUG_NOTES_DEFAULT_LIMIT, fn as buildEventKindDescriptor, fr as vectorDimFromBase64, ft as NC_TEMPLATE_VARS, g as BIRTH_DECISION_DEFAULT_LIMIT, gn as describeSystemEventDeliveryProblem, gr as evaluateSensorEdge, gt as NcConditionsSchema, h as ArchivedDebugNoteSchema, hn as deriveRecordingMode, hr as canonicalHash, ht as NcConditionDescriptorSchema, in as TrackRelationSchema, ir as storageOccupancyCapability, it as NC_CONSUMABLE_LOW_PERCENT_DEFAULT, j as EMBEDDING_REQUIREMENTS_UNAVAILABLE, jn as isEventOwnerType, jr as _enum, jt as OPS_LOG_DEFAULT_LIMIT, k as EMBEDDING_MODEL_FAILED, kn as isAudioClassName, kr as sleep$1, kt as NcTemplatePreviewSchema, l as APPEARANCE_BIN_COUNT, ln as assertTimelapseCadences, lr as systemEventSummaryScheduleOf, lt as NC_SYSTEM_EVENT_KIND_GROUP_SPECS, m as AlarmArmModeSchema, mn as cosineSimilarity$1, mr as DEFAULT_FIRST_SIGHTING_FRESHNESS_MS, mt as NcAlarmSettingsPatchSchema, nn as TrackPuritySchema, nr as sensorRaisedOf, nt as NC_CLIP_RETENTION_MAX_SEC, o as FACE_EMBEDDING_STEP_ID, on as TrackSourceSchema, or as systemEventDeliveryProblems, ot as NC_DEFAULT_SNOOZE_MINUTES, p as AUDIO_MACRO_LABELS, pn as consumableLowPercentOf, pr as zoneAnalyticsCapability, pt as NcAlarmConfigSchema, q as MAX_TRACK_RELATIONS, qn as readMotionTriggeredBy, qt as SceneMonitorSchema, rn as TrackRelationKindSchema, rr as servedTemplateVars, rt as NC_CONDITION_CATALOG, s as readClusterModelPinStrict, sn as addonWidgetsSourceCapability, sr as systemEventFamilyProblems, st as NC_SENSOR_TRANSITION_KINDS, t as BUILTIN_CLIP_MODELS, tn as TimelapseRuleSchema, tr as sceneMonitorCapability, tt as NC_CLIP_PRE_ROLL_DEFAULT_SEC, u as APPEARANCE_BLOCKS, un as audioMetricsCapability, ur as templateCarriesSummary, ut as NC_TANK_SYSTEM_EVENT_KINDS, v as BirthDecisionRecordSchema, vr as DeviceType, vt as NcRulePatchSchema, w as DEFAULT_TIMELAPSE_PREVIEW_TEXT, wn as evictionPolicyOfLocation, wr as defineCustomActions, wt as NcSnoozeSchema, x as ClearTrackRelationResultSchema, xr as CamProfileSchema, xt as NcRuleUpdateInputSchema, y as COCO_TO_MACRO, yr as errMsg, yt as NcRuleSchema, z as LabelAttributionSchema, zn as mayWriteToLocation, zr as partialRecord, zt as SCENE_CONFIRM_DEFAULT_TIMEOUT_MS } from "../clip-model-registry-C9Aw4qkh.mjs";
|
|
2
2
|
import { t as __exportAll } from "../embedding-encoder/index.mjs";
|
|
3
3
|
import * as fs$1 from "node:fs";
|
|
4
4
|
import { createWriteStream, promises } from "node:fs";
|
|
@@ -10644,21 +10644,49 @@ function matchesPlate(label, values, maxDistance) {
|
|
|
10644
10644
|
return false;
|
|
10645
10645
|
}
|
|
10646
10646
|
/**
|
|
10647
|
+
* Subject kinds whose `classNames` is a real, operator-meaningful detection
|
|
10648
|
+
* class — the ones the `classes` condition can actually scope a rule to.
|
|
10649
|
+
* `device-event` and `system-event` carry NO class live (`classNames: []`,
|
|
10650
|
+
* always — see `subjectFromSensorEvent`); their OUTBOX `className` is a
|
|
10651
|
+
* display placeholder instead (the sensor kind, or the literal `'event'`,
|
|
10652
|
+
* `className: subject.classNames[0] ?? subject.sensorKind ?? 'event'`), which
|
|
10653
|
+
* `cooldownSubjectFromOutbox` would otherwise reconstruct as if it were a real
|
|
10654
|
+
* class. Gating here, not on the reconstructed value, keeps the live path and
|
|
10655
|
+
* the boot reseed addressing the SAME key regardless of what a display label
|
|
10656
|
+
* happens to say (2026-10-01, caught by `system-event-cooldown-direction.spec.ts`
|
|
10657
|
+
* when the default below flipped).
|
|
10658
|
+
*/
|
|
10659
|
+
var CLASS_BEARING_SUBJECT_KINDS = new Set([
|
|
10660
|
+
"object-event",
|
|
10661
|
+
"track-end",
|
|
10662
|
+
"package-event",
|
|
10663
|
+
"occupancy-event",
|
|
10664
|
+
"audio-event",
|
|
10665
|
+
"audio-window"
|
|
10666
|
+
]);
|
|
10667
|
+
/**
|
|
10647
10668
|
* Should this subject's class be part of the cooldown key?
|
|
10648
10669
|
*
|
|
10649
10670
|
* AUDIO is unconditional and predates the setting: a rule opted into several
|
|
10650
10671
|
* audio classes (dog + scream) must not have a safety-relevant scream
|
|
10651
10672
|
* swallowed by an unrelated bark's 60 s window.
|
|
10652
10673
|
*
|
|
10653
|
-
*
|
|
10654
|
-
*
|
|
10655
|
-
*
|
|
10656
|
-
*
|
|
10657
|
-
*
|
|
10674
|
+
* A `device-event` or `system-event` subject never reads a class — see
|
|
10675
|
+
* {@link CLASS_BEARING_SUBJECT_KINDS} — so the setting has nothing to key on
|
|
10676
|
+
* regardless of `granularity`.
|
|
10677
|
+
*
|
|
10678
|
+
* Every class-bearing subject gets the same treatment unless the rule
|
|
10679
|
+
* explicitly asks for `shared` — cat→dog fires at once, cat→cat still waits.
|
|
10680
|
+
* That is the operator's default since 2026-10-01 (D36's dated note): an
|
|
10681
|
+
* UNSET rule now reads exactly like an explicit `per-class` one, new rules and
|
|
10682
|
+
* existing rules alike. Only a `granularity: 'shared'` actually stored
|
|
10683
|
+
* reproduces the old class-agnostic key byte for byte — the one case where an
|
|
10684
|
+
* operator asked for one window across every class on purpose.
|
|
10658
10685
|
*/
|
|
10659
10686
|
function keysPerClass(rule, subject) {
|
|
10660
10687
|
if (subject.kind === "audio-event" || subject.kind === "audio-window") return true;
|
|
10661
|
-
|
|
10688
|
+
if (!CLASS_BEARING_SUBJECT_KINDS.has(subject.kind)) return false;
|
|
10689
|
+
return rule.throttle.granularity !== "shared";
|
|
10662
10690
|
}
|
|
10663
10691
|
/**
|
|
10664
10692
|
* The cooldown scope of ONE system event — subject AND direction.
|
|
@@ -10743,6 +10771,42 @@ function isCoolingDown(rule, lastFiredAt, now) {
|
|
|
10743
10771
|
return now - lastFiredAt < rule.throttle.cooldownSec * 1e3;
|
|
10744
10772
|
}
|
|
10745
10773
|
/**
|
|
10774
|
+
* The media policy EVERY sensor rule attaches: one whole-scene photograph,
|
|
10775
|
+
* never a selector. `attach: 'keyFrame'` is the existing "clean full scene
|
|
10776
|
+
* frame" ladder (`attachmentKindPreference`) — the closest existing concept
|
|
10777
|
+
* to a plain snapshot, as opposed to `'best'`/`'best-matching'`, which chase a
|
|
10778
|
+
* SUBJECT crop a sensor subject (a doorbell press, a contact change) has
|
|
10779
|
+
* none of to chase. No zone crop, no gif, no clip, no profile — a sensor
|
|
10780
|
+
* subject has no zone condition to crop to and no reason to animate.
|
|
10781
|
+
*/
|
|
10782
|
+
var NC_SENSOR_MEDIA_POLICY = { attach: "keyFrame" };
|
|
10783
|
+
/**
|
|
10784
|
+
* The media policy actually used to build a delivery, for a rule of this
|
|
10785
|
+
* KIND. A sensor rule's STORED `media` is never read at evaluation — the
|
|
10786
|
+
* editor offers no media control for one any more, and `media.attach` /
|
|
10787
|
+
* `.gif` / `.clip` / `.profile` on an existing sensor rule (authored before
|
|
10788
|
+
* this ruling, from the viewer or the API) are IGNORED here, never refused at
|
|
10789
|
+
* load. See {@link mediaPolicyMatchesSensorDefault} for the sampled-log gate.
|
|
10790
|
+
*/
|
|
10791
|
+
function effectiveMediaPolicy(rule, kind) {
|
|
10792
|
+
return kind === "sensor" ? NC_SENSOR_MEDIA_POLICY : rule.media;
|
|
10793
|
+
}
|
|
10794
|
+
/**
|
|
10795
|
+
* True when a stored media policy already reads as the sensor default — the
|
|
10796
|
+
* gate the boot/evaluation path uses to decide whether an existing sensor
|
|
10797
|
+
* rule's stored choice is being silently overridden (and so is worth a
|
|
10798
|
+
* sampled log line) or already matches and there is nothing to say.
|
|
10799
|
+
*/
|
|
10800
|
+
function mediaPolicyMatchesSensorDefault(media) {
|
|
10801
|
+
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;
|
|
10802
|
+
}
|
|
10803
|
+
/** `effectiveMediaPolicy`, resolving the kind itself — the call-site shortcut
|
|
10804
|
+
* every production caller uses; `effectiveMediaPolicy` stays the unit under
|
|
10805
|
+
* test because `ruleKindOf` needs no fixture of its own there. */
|
|
10806
|
+
function effectiveMediaPolicyForRule(rule) {
|
|
10807
|
+
return effectiveMediaPolicy(rule, ruleKindOf(rule));
|
|
10808
|
+
}
|
|
10809
|
+
/**
|
|
10746
10810
|
* Ordered media-kind preference for one attachment, per policy and owner.
|
|
10747
10811
|
* `best` favours the tight subject crop; `keyFrame` the clean scene frame
|
|
10748
10812
|
* (small variant first — the degrade engine enforces per-backend byte
|
|
@@ -14324,11 +14388,11 @@ function logRefusal$3(check, ctx) {
|
|
|
14324
14388
|
try {
|
|
14325
14389
|
check();
|
|
14326
14390
|
} catch (err) {
|
|
14327
|
-
if (err instanceof NcDevicesExcludeRefusal) warnRefusal$
|
|
14391
|
+
if (err instanceof NcDevicesExcludeRefusal) warnRefusal$3(ctx.logger, err, ctx);
|
|
14328
14392
|
throw err;
|
|
14329
14393
|
}
|
|
14330
14394
|
}
|
|
14331
|
-
function warnRefusal$
|
|
14395
|
+
function warnRefusal$3(logger, err, ctx) {
|
|
14332
14396
|
logger.warn("nc rule refused: devicesExclude on a system rule", { meta: {
|
|
14333
14397
|
...ctx.ruleId !== void 0 ? { ruleId: ctx.ruleId } : {},
|
|
14334
14398
|
by: ctx.by,
|
|
@@ -14351,6 +14415,184 @@ function guardPatchDevicesExclude(existing, patch, ctx) {
|
|
|
14351
14415
|
logRefusal$3(() => assertDevicesExcludeDelivery(merged), ctx);
|
|
14352
14416
|
}
|
|
14353
14417
|
//#endregion
|
|
14418
|
+
//#region src/notification-center/device-compat-guard.ts
|
|
14419
|
+
/**
|
|
14420
|
+
* A NEW or UPDATED rule's `conditions.devices` (the "Only these" INCLUDE list)
|
|
14421
|
+
* names only devices that can actually produce this rule's KIND of subject —
|
|
14422
|
+
* operator ruling, 2026-10-01: "the DEVICE picker of a rule must offer only
|
|
14423
|
+
* devices compatible with the rule type … the server should also refuse a
|
|
14424
|
+
* save whose deviceIds include an incompatible device".
|
|
14425
|
+
*
|
|
14426
|
+
* THE authority, same arrangement as `device-scope-guard.ts` beside it: the
|
|
14427
|
+
* cap-provider route (`notificationRules.createRule/updateRule`, index.ts)
|
|
14428
|
+
* and the rule bridge (`nc.createRule/updateRule`, nc-actions.ts) both call
|
|
14429
|
+
* `guardRuleDeviceCompat` / `guardPatchDeviceCompat`. Which devices a kind
|
|
14430
|
+
* may scope is `ruleDeviceCompatCapQueries` (`@camstack/types`) — the SAME
|
|
14431
|
+
* table the admin's Devices tab narrows by, so a save can never refuse a
|
|
14432
|
+
* device its own picker just offered. A `sensor` rule's set includes every
|
|
14433
|
+
* camera: a camera-scoped sensor rule is legitimate (D630 §6).
|
|
14434
|
+
*
|
|
14435
|
+
* ASYNC, unlike its sibling guards: it has to ask `deviceManager` which
|
|
14436
|
+
* devices carry the caps. `listDeviceIdsByCaps` is therefore OPTIONAL on the
|
|
14437
|
+
* context — absent on an older wiring, if the cross-addon call throws, or if
|
|
14438
|
+
* it answers EMPTY (the registry not yet warm at boot: `getAllBindings`
|
|
14439
|
+
* answers `[]` with only a warning, which is not a measurement — D315/D393),
|
|
14440
|
+
* the guard FAILS OPEN (never refuses) rather than blocking every save on a
|
|
14441
|
+
* read it cannot make (D49). It is never SILENT about it: an unjudged scoped
|
|
14442
|
+
* save logs a sampled WARN naming the rule and the reason, so a dropped wiring
|
|
14443
|
+
* is visible in production rather than a guard that quietly stopped existing.
|
|
14444
|
+
*
|
|
14445
|
+
* ON UPDATE only the ids the patch ADDS are judged. A device already stored on
|
|
14446
|
+
* the rule is never re-judged: the answer is not stable (switching detection
|
|
14447
|
+
* off on a camera removes its `detection-pipeline` binding, D62; an agent
|
|
14448
|
+
* camera's native caps are missing during the handshake), and re-judging
|
|
14449
|
+
* would make every rule naming that camera un-editable until the operator
|
|
14450
|
+
* removed it — losing a scope a later switch-on would not give back.
|
|
14451
|
+
*
|
|
14452
|
+
* NEVER called at LOAD. `NcRuleSchema.parse` and the store's own read path are
|
|
14453
|
+
* untouched — a rule saved before this ruling, or saved while the capability
|
|
14454
|
+
* read was unavailable, keeps loading and keeps firing exactly as it does
|
|
14455
|
+
* today; only a NEW save attempt is judged.
|
|
14456
|
+
*
|
|
14457
|
+
* `devicesExclude` is NOT checked: this guard judges what the rule actually
|
|
14458
|
+
* MATCHES ON (the include scope), not what it widens away from, and an
|
|
14459
|
+
* operator excluding an incompatible device from an "all except" rule is not
|
|
14460
|
+
* the mistake this exists to catch.
|
|
14461
|
+
*/
|
|
14462
|
+
/** How long one rule's "accepted unchecked" WARN stays quiet after it fired. */
|
|
14463
|
+
var UNCHECKED_WARN_WINDOW_MS = 10 * 6e4;
|
|
14464
|
+
/** Bound on remembered rule keys — cleared whole when exceeded. */
|
|
14465
|
+
var UNCHECKED_WARN_MEMO_MAX = 256;
|
|
14466
|
+
/**
|
|
14467
|
+
* Samples the "accepted unchecked" WARN per rule: an editor that autosaves, or
|
|
14468
|
+
* a script, must not turn one unreachable read into a log flood, but the first
|
|
14469
|
+
* save of every rule in a window is always reported.
|
|
14470
|
+
*/
|
|
14471
|
+
var NcDeviceCompatWarnSampler = class {
|
|
14472
|
+
lastWarnedAt = /* @__PURE__ */ new Map();
|
|
14473
|
+
shouldWarn(key, now) {
|
|
14474
|
+
const last = this.lastWarnedAt.get(key);
|
|
14475
|
+
if (last !== void 0 && now - last < UNCHECKED_WARN_WINDOW_MS) return false;
|
|
14476
|
+
if (this.lastWarnedAt.size >= UNCHECKED_WARN_MEMO_MAX) this.lastWarnedAt.clear();
|
|
14477
|
+
this.lastWarnedAt.set(key, now);
|
|
14478
|
+
return true;
|
|
14479
|
+
}
|
|
14480
|
+
};
|
|
14481
|
+
var NcDeviceCompatRefusal = class extends Error {
|
|
14482
|
+
kind;
|
|
14483
|
+
incompatibleIds;
|
|
14484
|
+
constructor(kind, incompatibleIds) {
|
|
14485
|
+
super(`rule refused: device(s) ${incompatibleIds.join(", ")} cannot produce a '${kind}' rule's subject`);
|
|
14486
|
+
this.name = "NcDeviceCompatRefusal";
|
|
14487
|
+
this.kind = kind;
|
|
14488
|
+
this.incompatibleIds = incompatibleIds;
|
|
14489
|
+
}
|
|
14490
|
+
};
|
|
14491
|
+
/** The union of every compat query's answer, or why there is none. */
|
|
14492
|
+
async function readCompatibleIds(queries, listDeviceIdsByCaps) {
|
|
14493
|
+
try {
|
|
14494
|
+
const answers = await Promise.all(queries.map((capNames) => listDeviceIdsByCaps(capNames)));
|
|
14495
|
+
return {
|
|
14496
|
+
ok: true,
|
|
14497
|
+
ids: new Set(answers.flat())
|
|
14498
|
+
};
|
|
14499
|
+
} catch (err) {
|
|
14500
|
+
return {
|
|
14501
|
+
ok: false,
|
|
14502
|
+
error: String(err)
|
|
14503
|
+
};
|
|
14504
|
+
}
|
|
14505
|
+
}
|
|
14506
|
+
/**
|
|
14507
|
+
* The check. `judged` is the ids to judge — the whole include list on a
|
|
14508
|
+
* create, only the ADDED ids on an update (see the file header).
|
|
14509
|
+
*
|
|
14510
|
+
* Resolves `not-judged` when the kind has no capability filter
|
|
14511
|
+
* (`ruleDeviceCompatCapQueries` is `null`) or there is nothing to judge —
|
|
14512
|
+
* "every device" is never refused here. Resolves `unchecked` (fail open) when
|
|
14513
|
+
* the read is not wired, throws, or answers nothing at all. Throws
|
|
14514
|
+
* {@link NcDeviceCompatRefusal} naming every incompatible id, never only the
|
|
14515
|
+
* first — an operator fixing the list needs the whole picture in one pass.
|
|
14516
|
+
*/
|
|
14517
|
+
async function assertRuleDeviceCompat(rule, listDeviceIdsByCaps, judged = rule.conditions.devices ?? []) {
|
|
14518
|
+
const kind = ruleKindOf(rule);
|
|
14519
|
+
const queries = ruleDeviceCompatCapQueries(kind);
|
|
14520
|
+
if (queries === null || judged.length === 0) return { outcome: "not-judged" };
|
|
14521
|
+
if (listDeviceIdsByCaps === void 0) return {
|
|
14522
|
+
outcome: "unchecked",
|
|
14523
|
+
reason: "unwired",
|
|
14524
|
+
ids: judged
|
|
14525
|
+
};
|
|
14526
|
+
const read = await readCompatibleIds(queries, listDeviceIdsByCaps);
|
|
14527
|
+
if (!read.ok) return {
|
|
14528
|
+
outcome: "unchecked",
|
|
14529
|
+
reason: "read-failed",
|
|
14530
|
+
ids: judged,
|
|
14531
|
+
error: read.error
|
|
14532
|
+
};
|
|
14533
|
+
if (read.ids.size === 0) return {
|
|
14534
|
+
outcome: "unchecked",
|
|
14535
|
+
reason: "answered-empty",
|
|
14536
|
+
ids: judged
|
|
14537
|
+
};
|
|
14538
|
+
const incompatible = judged.filter((id) => !read.ids.has(id));
|
|
14539
|
+
if (incompatible.length > 0) throw new NcDeviceCompatRefusal(kind, incompatible);
|
|
14540
|
+
return { outcome: "compatible" };
|
|
14541
|
+
}
|
|
14542
|
+
/**
|
|
14543
|
+
* Run the check and report what it could not do: a refusal is dropped work, so
|
|
14544
|
+
* it is logged before it is rethrown; an unchecked acceptance is a guard that
|
|
14545
|
+
* did not run, so it is logged (sampled) too.
|
|
14546
|
+
*/
|
|
14547
|
+
async function runAndReport(rule, judged, ctx) {
|
|
14548
|
+
let verdict;
|
|
14549
|
+
try {
|
|
14550
|
+
verdict = await assertRuleDeviceCompat(rule, ctx.listDeviceIdsByCaps, judged);
|
|
14551
|
+
} catch (err) {
|
|
14552
|
+
if (err instanceof NcDeviceCompatRefusal) warnRefusal$2(ctx.logger, err, ctx);
|
|
14553
|
+
throw err;
|
|
14554
|
+
}
|
|
14555
|
+
if (verdict.outcome === "unchecked") warnUnchecked(ruleKindOf(rule), verdict, ctx);
|
|
14556
|
+
}
|
|
14557
|
+
function warnRefusal$2(logger, err, ctx) {
|
|
14558
|
+
logger.warn("nc rule refused: device(s) incompatible with the rule kind", { meta: {
|
|
14559
|
+
...ctx.ruleId !== void 0 ? { ruleId: ctx.ruleId } : {},
|
|
14560
|
+
by: ctx.by,
|
|
14561
|
+
kind: err.kind,
|
|
14562
|
+
incompatibleIds: [...err.incompatibleIds]
|
|
14563
|
+
} });
|
|
14564
|
+
}
|
|
14565
|
+
function warnUnchecked(kind, verdict, ctx) {
|
|
14566
|
+
const now = (ctx.now ?? Date.now)();
|
|
14567
|
+
const key = `${ctx.ruleId ?? `new:${ctx.by}`}:${verdict.reason}`;
|
|
14568
|
+
if (ctx.sampler !== void 0 && !ctx.sampler.shouldWarn(key, now)) return;
|
|
14569
|
+
ctx.logger.warn("nc rule saved WITHOUT the device-compatibility check — accepted unchecked", { meta: {
|
|
14570
|
+
...ctx.ruleId !== void 0 ? { ruleId: ctx.ruleId } : {},
|
|
14571
|
+
by: ctx.by,
|
|
14572
|
+
kind,
|
|
14573
|
+
reason: verdict.reason,
|
|
14574
|
+
uncheckedIds: [...verdict.ids],
|
|
14575
|
+
...verdict.error !== void 0 ? { error: verdict.error } : {}
|
|
14576
|
+
} });
|
|
14577
|
+
}
|
|
14578
|
+
/** The create path: the whole include list is judged. */
|
|
14579
|
+
async function guardRuleDeviceCompat(rule, ctx) {
|
|
14580
|
+
await runAndReport(rule, rule.conditions.devices ?? [], ctx);
|
|
14581
|
+
}
|
|
14582
|
+
/**
|
|
14583
|
+
* The update path: the MERGED rule decides the kind, but only the ids the
|
|
14584
|
+
* patch ADDS to `conditions.devices` are judged — a stored id is never
|
|
14585
|
+
* re-judged (see the file header). A patch that adds nothing never pays for a
|
|
14586
|
+
* capability read.
|
|
14587
|
+
*/
|
|
14588
|
+
async function guardPatchDeviceCompat(existing, patch, ctx) {
|
|
14589
|
+
if (existing === null) return;
|
|
14590
|
+
const merged = mergedFamilySubject(existing, patch);
|
|
14591
|
+
if (merged === null) return;
|
|
14592
|
+
const stored = new Set(existing.conditions.devices ?? []);
|
|
14593
|
+
await runAndReport(merged, (merged.conditions.devices ?? []).filter((id) => !stored.has(id)), ctx);
|
|
14594
|
+
}
|
|
14595
|
+
//#endregion
|
|
14354
14596
|
//#region src/notification-center/rule-summary-guard.ts
|
|
14355
14597
|
/**
|
|
14356
14598
|
* The hub's half of D647: `systemEventDelivery: { mode: 'summary' }` belongs to
|
|
@@ -17195,6 +17437,8 @@ function requireAdmin(caller) {
|
|
|
17195
17437
|
return c;
|
|
17196
17438
|
}
|
|
17197
17439
|
function makeNcActionHandlers(deps) {
|
|
17440
|
+
/** One sampler per handler set: an unchecked save warns once per rule per window. */
|
|
17441
|
+
const deviceCompatWarnSampler = new NcDeviceCompatWarnSampler();
|
|
17198
17442
|
/** The alarm half, or a refusal by name — never a made-up "disarmed". */
|
|
17199
17443
|
const requireAlarm = (action) => {
|
|
17200
17444
|
if (deps.alarm === void 0) throw new Error(`${action}: the alarm is not wired on this node`);
|
|
@@ -17352,6 +17596,12 @@ function makeNcActionHandlers(deps) {
|
|
|
17352
17596
|
logger: deps.logger,
|
|
17353
17597
|
by: c.userId
|
|
17354
17598
|
});
|
|
17599
|
+
await guardRuleDeviceCompat(parsed, {
|
|
17600
|
+
logger: deps.logger,
|
|
17601
|
+
by: c.userId,
|
|
17602
|
+
listDeviceIdsByCaps: deps.listDeviceIdsByCaps,
|
|
17603
|
+
sampler: deviceCompatWarnSampler
|
|
17604
|
+
});
|
|
17355
17605
|
guardRuleSystemEventDelivery(parsed, {
|
|
17356
17606
|
logger: deps.logger,
|
|
17357
17607
|
by: c.userId
|
|
@@ -17394,6 +17644,13 @@ function makeNcActionHandlers(deps) {
|
|
|
17394
17644
|
by: c.userId,
|
|
17395
17645
|
ruleId: input.ruleId
|
|
17396
17646
|
});
|
|
17647
|
+
await guardPatchDeviceCompat(existing, patch, {
|
|
17648
|
+
logger: deps.logger,
|
|
17649
|
+
by: c.userId,
|
|
17650
|
+
ruleId: input.ruleId,
|
|
17651
|
+
listDeviceIdsByCaps: deps.listDeviceIdsByCaps,
|
|
17652
|
+
sampler: deviceCompatWarnSampler
|
|
17653
|
+
});
|
|
17397
17654
|
guardPatchSystemEventDelivery(existing, patch, clear, {
|
|
17398
17655
|
logger: deps.logger,
|
|
17399
17656
|
by: c.userId,
|
|
@@ -17895,9 +18152,9 @@ function makeNcActionHandlers(deps) {
|
|
|
17895
18152
|
* reproduce.
|
|
17896
18153
|
*/
|
|
17897
18154
|
var NC_CLIP_DISPATCH_MARGIN_SEC = 10;
|
|
17898
|
-
/** Does this
|
|
17899
|
-
function wantsFootage(
|
|
17900
|
-
return
|
|
18155
|
+
/** Does this media policy ever cut footage from the broker's clip ring? */
|
|
18156
|
+
function wantsFootage(media) {
|
|
18157
|
+
return media.gif === true || media.clip === true;
|
|
17901
18158
|
}
|
|
17902
18159
|
/** The ask ONE rule makes, or `null` when it needs no retained footage. */
|
|
17903
18160
|
function askSecondsFor(rule) {
|
|
@@ -17906,9 +18163,10 @@ function askSecondsFor(rule) {
|
|
|
17906
18163
|
seconds: NC_CLIP_RETENTION_MAX_SEC,
|
|
17907
18164
|
reason: "occupancy"
|
|
17908
18165
|
};
|
|
17909
|
-
|
|
17910
|
-
|
|
17911
|
-
const
|
|
18166
|
+
const media = effectiveMediaPolicyForRule(rule);
|
|
18167
|
+
if (!wantsFootage(media)) return null;
|
|
18168
|
+
const pre = media.clipPreRollSec ?? NC_CLIP_PRE_ROLL_DEFAULT_SEC;
|
|
18169
|
+
const post = media.clipPostRollSec ?? NC_CLIP_POST_ROLL_DEFAULT_SEC;
|
|
17912
18170
|
return {
|
|
17913
18171
|
seconds: Math.min(pre + post + NC_CLIP_DISPATCH_MARGIN_SEC, NC_CLIP_RETENTION_MAX_SEC),
|
|
17914
18172
|
reason: "window"
|
|
@@ -17927,12 +18185,13 @@ function deriveClipRetentionAsks(rules) {
|
|
|
17927
18185
|
for (const rule of rules) {
|
|
17928
18186
|
const ask = askSecondsFor(rule);
|
|
17929
18187
|
if (ask === null) continue;
|
|
18188
|
+
const profile = effectiveMediaPolicyForRule(rule).profile;
|
|
17930
18189
|
const base = {
|
|
17931
18190
|
seconds: ask.seconds,
|
|
17932
18191
|
reason: ask.reason,
|
|
17933
18192
|
ruleId: rule.id,
|
|
17934
18193
|
ruleName: rule.name,
|
|
17935
|
-
...
|
|
18194
|
+
...profile !== void 0 ? { profile } : {}
|
|
17936
18195
|
};
|
|
17937
18196
|
const devices = rule.conditions.devices;
|
|
17938
18197
|
if (devices === void 0 || devices.length === 0) {
|
|
@@ -19789,6 +20048,114 @@ function ncGroupSubjectText(texts, input) {
|
|
|
19789
20048
|
return texts.join([...names, ...others]);
|
|
19790
20049
|
}
|
|
19791
20050
|
//#endregion
|
|
20051
|
+
//#region src/notification-center/outbox-key.ts
|
|
20052
|
+
/**
|
|
20053
|
+
* The record kinds whose `recordId` is a per-table SQLite rowid (D474), and so
|
|
20054
|
+
* may be reused after a delete. `device-event` is NOT one: sensor events are
|
|
20055
|
+
* TEXT-keyed with a producer-minted id (`sensor-event-store.ts`).
|
|
20056
|
+
*/
|
|
20057
|
+
var ROWID_RECORD_KINDS = new Set([
|
|
20058
|
+
"object-event",
|
|
20059
|
+
"package-event",
|
|
20060
|
+
"audio-event"
|
|
20061
|
+
]);
|
|
20062
|
+
/** Is this kind's record id a reusable rowid? See {@link ROWID_RECORD_KINDS}. */
|
|
20063
|
+
function isRowidRecordKind(kind) {
|
|
20064
|
+
return ROWID_RECORD_KINDS.has(kind);
|
|
20065
|
+
}
|
|
20066
|
+
/** The record identity an outbox key input speaks for. */
|
|
20067
|
+
function outboxRecordIdentity(input) {
|
|
20068
|
+
return {
|
|
20069
|
+
recordId: input.recordId,
|
|
20070
|
+
deviceId: input.deviceId,
|
|
20071
|
+
recordAt: input.payload.subject.timestamp
|
|
20072
|
+
};
|
|
20073
|
+
}
|
|
20074
|
+
/**
|
|
20075
|
+
* The record GENERATION ref — `<recordId>@<deviceId>:<recordAt>`. Exported for
|
|
20076
|
+
* the specs and for a caller that must name a rowid record outside the outbox
|
|
20077
|
+
* (none today); a key is always built through {@link outboxEntryId}.
|
|
20078
|
+
*/
|
|
20079
|
+
function recordGenerationRef(record) {
|
|
20080
|
+
return `${record.recordId}@${String(record.deviceId)}:${String(record.recordAt)}`;
|
|
20081
|
+
}
|
|
20082
|
+
/**
|
|
20083
|
+
* The middle segment of the dedup id:
|
|
20084
|
+
*
|
|
20085
|
+
* 1. an explicit `dedupRef` (upgrade / burst revision);
|
|
20086
|
+
* 2. the track id — `maxPerTrack: 1` (a track id is random, never reused);
|
|
20087
|
+
* 3. for a rowid record, its generation ref;
|
|
20088
|
+
* 4. the record id as is (synthetic ids already carry device + instant).
|
|
20089
|
+
*/
|
|
20090
|
+
function outboxDedupRef(input) {
|
|
20091
|
+
if (input.dedupRef !== void 0) return input.dedupRef;
|
|
20092
|
+
if (input.trackId !== void 0) return input.trackId;
|
|
20093
|
+
if (isRowidRecordKind(input.recordKind)) return recordGenerationRef(outboxRecordIdentity(input));
|
|
20094
|
+
return input.recordId;
|
|
20095
|
+
}
|
|
20096
|
+
/** The outbox row id — the durable per-track / per-record fire guarantee. */
|
|
20097
|
+
function outboxEntryId(input) {
|
|
20098
|
+
return `${input.ruleId}:${outboxDedupRef(input)}:${input.targetId}`;
|
|
20099
|
+
}
|
|
20100
|
+
/**
|
|
20101
|
+
* The id this input had BEFORE D694 — but only when it differs from today's,
|
|
20102
|
+
* i.e. for a rowid record keyed on its record id. `null` otherwise: there is no
|
|
20103
|
+
* older spelling to look for.
|
|
20104
|
+
*/
|
|
20105
|
+
function legacyOutboxEntryId(input) {
|
|
20106
|
+
if (input.dedupRef !== void 0 || input.trackId !== void 0) return null;
|
|
20107
|
+
if (!isRowidRecordKind(input.recordKind)) return null;
|
|
20108
|
+
return `${input.ruleId}:${input.recordId}:${input.targetId}`;
|
|
20109
|
+
}
|
|
20110
|
+
/**
|
|
20111
|
+
* A STORED row's id as it would be built today, when the row was written under
|
|
20112
|
+
* the pre-D694 spelling; `null` when the stored id is already current (or was
|
|
20113
|
+
* built from an explicit dedup ref, which a row does not record and which never
|
|
20114
|
+
* involved the record id).
|
|
20115
|
+
*/
|
|
20116
|
+
function translatedLegacyEntryId(storedId, row) {
|
|
20117
|
+
const legacy = legacyOutboxEntryId(row);
|
|
20118
|
+
if (legacy === null || legacy !== storedId) return null;
|
|
20119
|
+
return outboxEntryId(row);
|
|
20120
|
+
}
|
|
20121
|
+
/**
|
|
20122
|
+
* Does a STORED row's payload really carry its record timestamp?
|
|
20123
|
+
*
|
|
20124
|
+
* The type says it always does, but a stored payload is checked only as an
|
|
20125
|
+
* object on the way back from the store (`rowToEntry`), so a malformed or very
|
|
20126
|
+
* old row can lack `subject`. Every read of {@link outboxRecordIdentity} on a
|
|
20127
|
+
* STORED row is preceded by this check. Takes the payload as `unknown` on
|
|
20128
|
+
* purpose: the static type cannot answer the question.
|
|
20129
|
+
*/
|
|
20130
|
+
function hasRecordTimestamp(payload) {
|
|
20131
|
+
if (typeof payload !== "object" || payload === null || !("subject" in payload)) return false;
|
|
20132
|
+
const subject = payload.subject;
|
|
20133
|
+
return typeof subject === "object" && subject !== null && "timestamp" in subject && typeof subject.timestamp === "number";
|
|
20134
|
+
}
|
|
20135
|
+
/** Do two rows speak for the same RECORD? Id, camera and record timestamp. */
|
|
20136
|
+
function isSameOutboxRecord(a, b) {
|
|
20137
|
+
return a.recordId === b.recordId && a.deviceId === b.deviceId && a.recordAt === b.recordAt;
|
|
20138
|
+
}
|
|
20139
|
+
/**
|
|
20140
|
+
* The scope of the event a payload's `eventId` names.
|
|
20141
|
+
*
|
|
20142
|
+
* Usually the row's own subject. A COMBINED alarm row is the exception: its
|
|
20143
|
+
* subject is the alarm transition (its own instant), while its `eventId` is the
|
|
20144
|
+
* triggering rule's event — so the generation is the alarm SOURCE's camera and
|
|
20145
|
+
* timestamp (`NcRuleFireRef`), never the transition's.
|
|
20146
|
+
*/
|
|
20147
|
+
function eventMediaScopeOf(subject) {
|
|
20148
|
+
const source = subject.systemEvent?.alarmSource;
|
|
20149
|
+
if (source !== void 0) return {
|
|
20150
|
+
deviceId: source.deviceId,
|
|
20151
|
+
recordAt: source.timestamp
|
|
20152
|
+
};
|
|
20153
|
+
return {
|
|
20154
|
+
deviceId: subject.deviceId,
|
|
20155
|
+
recordAt: subject.timestamp
|
|
20156
|
+
};
|
|
20157
|
+
}
|
|
20158
|
+
//#endregion
|
|
19792
20159
|
//#region src/notification-center/footage-source.ts
|
|
19793
20160
|
/**
|
|
19794
20161
|
* Decide where this window must be cut from. Pure and total.
|
|
@@ -21754,7 +22121,7 @@ var NcDispatcher = class {
|
|
|
21754
22121
|
});
|
|
21755
22122
|
if (policy === "keyFrame" || frame === "full" || frame === "boxed") owners.reverse();
|
|
21756
22123
|
for (const owner of owners) try {
|
|
21757
|
-
const files = await this.deps.getMediaForOwner(owner.kind, owner.id);
|
|
22124
|
+
const files = owner.kind === "object" ? await this.deps.getMediaForOwner(owner.kind, owner.id, eventMediaScopeOf(subject)) : await this.deps.getMediaForOwner(owner.kind, owner.id);
|
|
21758
22125
|
if (files.length === 0) continue;
|
|
21759
22126
|
const preference = frame !== void 0 ? framePreference(frame, owner.kind) : policy === "best-matching" ? bestMatchingKindPreference(signal, owner.kind) : attachmentKindPreference(policy, owner.kind);
|
|
21760
22127
|
const ordered = [];
|
|
@@ -24039,11 +24406,6 @@ var DEFAULT_MAX_ATTEMPTS = 8;
|
|
|
24039
24406
|
var DEFAULT_DRAIN_BATCH = 20;
|
|
24040
24407
|
/** Dedup ids remembered at boot — bounded to recent rows (dedup horizon). */
|
|
24041
24408
|
var DEDUP_HYDRATE_WINDOW_MS = 168 * 36e5;
|
|
24042
|
-
/** Stable dedup id — the durable per-track/per-record fire guarantee. */
|
|
24043
|
-
function outboxEntryId(input) {
|
|
24044
|
-
const dedupRef = input.dedupRef ?? input.trackId ?? input.recordId;
|
|
24045
|
-
return `${input.ruleId}:${dedupRef}:${input.targetId}`;
|
|
24046
|
-
}
|
|
24047
24409
|
var NcOutbox = class {
|
|
24048
24410
|
pending = /* @__PURE__ */ new Map();
|
|
24049
24411
|
/** Every id ever enqueued within the dedup horizon (incl. sent/dead). */
|
|
@@ -24120,20 +24482,27 @@ var NcOutbox = class {
|
|
|
24120
24482
|
}
|
|
24121
24483
|
});
|
|
24122
24484
|
let superseded = 0;
|
|
24485
|
+
let translated = 0;
|
|
24123
24486
|
for (const row of rows) {
|
|
24124
|
-
|
|
24487
|
+
const entry = rowToEntry$1(row.id, row.data);
|
|
24488
|
+
const current = entry === null ? null : this.translateLegacy(row.id, entry);
|
|
24489
|
+
if (this.knownIds.has(row.id) || current !== null && this.knownIds.has(current)) {
|
|
24125
24490
|
superseded += 1;
|
|
24126
24491
|
continue;
|
|
24127
24492
|
}
|
|
24128
24493
|
this.knownIds.add(row.id);
|
|
24129
|
-
|
|
24494
|
+
if (current !== null) {
|
|
24495
|
+
this.knownIds.add(current);
|
|
24496
|
+
translated += 1;
|
|
24497
|
+
}
|
|
24130
24498
|
if (entry !== null && entry.status === "pending") this.pending.set(entry.id, entry);
|
|
24131
24499
|
}
|
|
24132
24500
|
this.horizonLoaded = true;
|
|
24133
24501
|
this.logger.info("notification outbox loaded", { meta: {
|
|
24134
24502
|
pending: this.pending.size,
|
|
24135
24503
|
known: this.knownIds.size,
|
|
24136
|
-
superseded
|
|
24504
|
+
superseded,
|
|
24505
|
+
translated
|
|
24137
24506
|
} });
|
|
24138
24507
|
} catch (err) {
|
|
24139
24508
|
this.logger.warn("notification outbox load failed", { meta: { error: String(err) } });
|
|
@@ -24157,7 +24526,7 @@ var NcOutbox = class {
|
|
|
24157
24526
|
for (const input of inputs) {
|
|
24158
24527
|
const id = outboxEntryId(input);
|
|
24159
24528
|
if (this.knownIds.has(id)) continue;
|
|
24160
|
-
if (!this.horizonLoaded && await this.
|
|
24529
|
+
if (!this.horizonLoaded && await this.alreadyStored(id, input)) {
|
|
24161
24530
|
this.knownIds.add(id);
|
|
24162
24531
|
this.logger.info("outbox enqueue deduped against the store — the horizon is still cold", {
|
|
24163
24532
|
tags: { deviceId: input.deviceId },
|
|
@@ -24374,7 +24743,7 @@ var NcOutbox = class {
|
|
|
24374
24743
|
*/
|
|
24375
24744
|
async supersede(input) {
|
|
24376
24745
|
const wanted = new Set(input.targetIds);
|
|
24377
|
-
const victims = [...this.pending.values()].filter((e) => e.ruleId === input.ruleId && e.
|
|
24746
|
+
const victims = [...this.pending.values()].filter((e) => e.ruleId === input.ruleId && wanted.has(e.targetId) && hasRecordTimestamp(e.payload) && isSameOutboxRecord(outboxRecordIdentity(e), input));
|
|
24378
24747
|
let dropped = 0;
|
|
24379
24748
|
for (const entry of victims) try {
|
|
24380
24749
|
await this.store.delete.mutate({
|
|
@@ -24490,6 +24859,81 @@ var NcOutbox = class {
|
|
|
24490
24859
|
await this.mutate(retry);
|
|
24491
24860
|
}
|
|
24492
24861
|
/**
|
|
24862
|
+
* Cold-horizon dedup: is this intent already a row in the store?
|
|
24863
|
+
*
|
|
24864
|
+
* Today's id first. Then, for a rowid record, the pre-D694 spelling — which
|
|
24865
|
+
* is accepted ONLY when the row found speaks for the SAME record (camera and
|
|
24866
|
+
* record timestamp too). A legacy row of a different record that reused the
|
|
24867
|
+
* rowid is exactly the collision D694 removes, and must not swallow this one.
|
|
24868
|
+
*/
|
|
24869
|
+
async alreadyStored(id, input) {
|
|
24870
|
+
if (await this.rowExists(id)) return true;
|
|
24871
|
+
const legacy = legacyOutboxEntryId(input);
|
|
24872
|
+
if (legacy === null) return false;
|
|
24873
|
+
const row = await this.readRow(legacy);
|
|
24874
|
+
if (row === null) return false;
|
|
24875
|
+
if (!hasRecordTimestamp(row.payload)) {
|
|
24876
|
+
this.logger.warn("outbox: a pre-D694 row carries no record timestamp — not used for dedup", {
|
|
24877
|
+
tags: { deviceId: row.deviceId },
|
|
24878
|
+
meta: {
|
|
24879
|
+
id,
|
|
24880
|
+
legacyId: legacy,
|
|
24881
|
+
ruleId: input.ruleId
|
|
24882
|
+
}
|
|
24883
|
+
});
|
|
24884
|
+
return false;
|
|
24885
|
+
}
|
|
24886
|
+
if (isSameOutboxRecord(outboxRecordIdentity(row), outboxRecordIdentity(input))) return true;
|
|
24887
|
+
this.logger.info("outbox: a pre-D694 row shares this rowid but is a different record — not a duplicate", {
|
|
24888
|
+
tags: { deviceId: input.deviceId },
|
|
24889
|
+
meta: {
|
|
24890
|
+
id,
|
|
24891
|
+
legacyId: legacy,
|
|
24892
|
+
legacyDeviceId: row.deviceId,
|
|
24893
|
+
ruleId: input.ruleId
|
|
24894
|
+
}
|
|
24895
|
+
});
|
|
24896
|
+
return false;
|
|
24897
|
+
}
|
|
24898
|
+
/**
|
|
24899
|
+
* The id a stored pre-D694 row would have today, or `null`.
|
|
24900
|
+
*
|
|
24901
|
+
* Guarded per row: a stored payload is validated only as an object
|
|
24902
|
+
* (`rowToEntry`), and a row with no `subject.timestamp` would otherwise throw
|
|
24903
|
+
* inside {@link load} and leave the WHOLE dedup horizon cold. Such a row is
|
|
24904
|
+
* still loaded under its stored id; it just gets no translated twin.
|
|
24905
|
+
*/
|
|
24906
|
+
translateLegacy(storedId, entry) {
|
|
24907
|
+
if (legacyOutboxEntryId(entry) !== storedId) return null;
|
|
24908
|
+
if (!hasRecordTimestamp(entry.payload)) {
|
|
24909
|
+
this.logger.warn("outbox: a pre-D694 row carries no record timestamp — loaded without translation", {
|
|
24910
|
+
tags: { deviceId: entry.deviceId },
|
|
24911
|
+
meta: {
|
|
24912
|
+
id: storedId,
|
|
24913
|
+
ruleId: entry.ruleId
|
|
24914
|
+
}
|
|
24915
|
+
});
|
|
24916
|
+
return null;
|
|
24917
|
+
}
|
|
24918
|
+
return translatedLegacyEntryId(storedId, entry);
|
|
24919
|
+
}
|
|
24920
|
+
/** One stored row by id, or `null` (absent, unreadable, or a failed read). */
|
|
24921
|
+
async readRow(id) {
|
|
24922
|
+
try {
|
|
24923
|
+
const row = await this.store.get.query({
|
|
24924
|
+
collection: NC_OUTBOX_COLLECTION,
|
|
24925
|
+
key: id
|
|
24926
|
+
});
|
|
24927
|
+
return isStoredRow(row) ? rowToEntry$1(id, row) : null;
|
|
24928
|
+
} catch (err) {
|
|
24929
|
+
this.logger.debug("outbox cold-horizon dedup read failed", { meta: {
|
|
24930
|
+
id,
|
|
24931
|
+
error: String(err)
|
|
24932
|
+
} });
|
|
24933
|
+
return null;
|
|
24934
|
+
}
|
|
24935
|
+
}
|
|
24936
|
+
/**
|
|
24493
24937
|
* Does a row already exist under this dedup id?
|
|
24494
24938
|
*
|
|
24495
24939
|
* Asked ONLY while {@link horizonLoaded} is false. {@link persist} writes
|
|
@@ -24583,6 +25027,10 @@ var OUTBOX_RECORD_KINDS = new Set([
|
|
|
24583
25027
|
function isOutboxRecordKind(x) {
|
|
24584
25028
|
return typeof x === "string" && OUTBOX_RECORD_KINDS.has(x);
|
|
24585
25029
|
}
|
|
25030
|
+
/** `settingsStore.get` answers `unknown` — a guard, never a cast. */
|
|
25031
|
+
function isStoredRow(x) {
|
|
25032
|
+
return typeof x === "object" && x !== null && !Array.isArray(x);
|
|
25033
|
+
}
|
|
24586
25034
|
/** A stored row back to an entry — a guard, never a cast. Exported for the
|
|
24587
25035
|
* specs that read the store a centre wrote to. */
|
|
24588
25036
|
function rowToEntry$1(id, data) {
|
|
@@ -31546,9 +31994,15 @@ var NC_ALARM_ANNOUNCE_RULE_ID = "nc-alarm-announce";
|
|
|
31546
31994
|
* A rule's media policy, as the fields a row freezes — ONE derivation, used by
|
|
31547
31995
|
* every row a rule addresses AND by the alarm's own announcement row when it is
|
|
31548
31996
|
* the COMBINED siren (the triggering rule's pictures on the alarm's words).
|
|
31997
|
+
*
|
|
31998
|
+
* `effectiveMediaPolicyForRule` is what actually resolves it: a SENSOR rule's
|
|
31999
|
+
* stored `media` is ignored here, in favour of the one-snapshot default
|
|
32000
|
+
* (operator ruling, 2026-10-01) — the rule still LOADS with whatever it has
|
|
32001
|
+
* stored, and `buildEntries` is where the sampled log fires when that stored
|
|
32002
|
+
* choice is about to be overridden.
|
|
31549
32003
|
*/
|
|
31550
32004
|
function mediaPolicyFields(mediaRule, cropZoneIds) {
|
|
31551
|
-
const media = mediaRule
|
|
32005
|
+
const media = effectiveMediaPolicyForRule(mediaRule);
|
|
31552
32006
|
return {
|
|
31553
32007
|
media: media.attach,
|
|
31554
32008
|
...media.zoneCrop === true && cropZoneIds !== void 0 ? { mediaZoneIds: [...cropZoneIds] } : {},
|
|
@@ -31842,13 +32296,14 @@ function cooldownSubjectKind(kind) {
|
|
|
31842
32296
|
* the same builder the live path does, so the two can never address different
|
|
31843
32297
|
* windows. Only the four fields the key is a function of are reconstructed —
|
|
31844
32298
|
* the class (for `per-class` and audio), the subject kind (which decides
|
|
31845
|
-
* whether
|
|
32299
|
+
* whether the class is keyed at all — audio always, occupancy and detections
|
|
32300
|
+
* unless `shared`), the system event (subject +
|
|
31846
32301
|
* direction) and the device.
|
|
31847
32302
|
*/
|
|
31848
32303
|
function cooldownSubjectFromOutbox(entry) {
|
|
31849
32304
|
const subject = entry.payload.subject;
|
|
31850
32305
|
return {
|
|
31851
|
-
kind: cooldownSubjectKind(entry.recordKind),
|
|
32306
|
+
kind: entry.recordKind === "device-event" && subject.occupancy !== void 0 ? "occupancy-event" : cooldownSubjectKind(entry.recordKind),
|
|
31852
32307
|
recordId: entry.recordId,
|
|
31853
32308
|
deviceId: entry.deviceId,
|
|
31854
32309
|
timestamp: subject.timestamp,
|
|
@@ -31927,6 +32382,9 @@ function renderAudioScope(devices, excluded) {
|
|
|
31927
32382
|
if (devices.length === 0) return excluded.length === 0 ? "@all" : `@all-except:${excluded.join("+")}`;
|
|
31928
32383
|
return devices.filter((id) => !excluded.includes(id)).join("+");
|
|
31929
32384
|
}
|
|
32385
|
+
/** {@link NotificationCenter.reportedSensorMediaIgnored} cap, same shape as
|
|
32386
|
+
* the dispatcher's own annotated-fallback memo. */
|
|
32387
|
+
var NC_SENSOR_MEDIA_IGNORED_MEMO_MAX = 5e3;
|
|
31930
32388
|
var NotificationCenter = class NotificationCenter {
|
|
31931
32389
|
logger;
|
|
31932
32390
|
rules;
|
|
@@ -31934,6 +32392,11 @@ var NotificationCenter = class NotificationCenter {
|
|
|
31934
32392
|
dispatcher;
|
|
31935
32393
|
deps;
|
|
31936
32394
|
now;
|
|
32395
|
+
/** Rule ids whose stored (non-default) media policy has already been
|
|
32396
|
+
* reported as ignored at evaluation — ONE line per rule, not per fire. */
|
|
32397
|
+
reportedSensorMediaIgnored = /* @__PURE__ */ new Set();
|
|
32398
|
+
/** Samples the device-compat guard's "accepted unchecked" WARN per rule. */
|
|
32399
|
+
deviceCompatWarnSampler = new NcDeviceCompatWarnSampler();
|
|
31937
32400
|
/**
|
|
31938
32401
|
* Every word this centre can print, in the hub's language.
|
|
31939
32402
|
*
|
|
@@ -32934,7 +33397,7 @@ var NotificationCenter = class NotificationCenter {
|
|
|
32934
33397
|
...deps.dispatcher,
|
|
32935
33398
|
logger: this.logger.child("dispatch"),
|
|
32936
33399
|
texts: this.texts,
|
|
32937
|
-
getMediaForOwner: async (ownerKind, ownerId) => {
|
|
33400
|
+
getMediaForOwner: async (ownerKind, ownerId, eventScope) => {
|
|
32938
33401
|
const held = isSyntheticId(ownerId) ? this.syntheticMedia.get(ownerId) : void 0;
|
|
32939
33402
|
if (held !== void 0) return held.files;
|
|
32940
33403
|
const shelved = isStillShelfId(ownerId) ? this.stills?.get(ownerId) : void 0;
|
|
@@ -32943,7 +33406,7 @@ var NotificationCenter = class NotificationCenter {
|
|
|
32943
33406
|
if (linked !== void 0) return linked;
|
|
32944
33407
|
const departed = ownerKind === "track" && this.departureFallbacks.has(ownerId);
|
|
32945
33408
|
const owner = departed ? await this.resolveDepartedTrack(ownerId) : ownerId;
|
|
32946
|
-
const files = await deps.dispatcher.getMediaForOwner(ownerKind, owner);
|
|
33409
|
+
const files = await deps.dispatcher.getMediaForOwner(ownerKind, owner, eventScope);
|
|
32947
33410
|
if (files.length > 0 || !departed) return files;
|
|
32948
33411
|
return this.departureFallbackMedia(ownerId) ?? files;
|
|
32949
33412
|
},
|
|
@@ -33714,7 +34177,7 @@ var NotificationCenter = class NotificationCenter {
|
|
|
33714
34177
|
const shown = this.unmutedCameras(cameraIds);
|
|
33715
34178
|
const involved = [sensorId, ...rowShown];
|
|
33716
34179
|
if (!this.rules.listEnabled("device-event").some((rule) => {
|
|
33717
|
-
if (rule.
|
|
34180
|
+
if (effectiveMediaPolicyForRule(rule).attach === "none") return false;
|
|
33718
34181
|
if (!deviceEventRuleMayFireOnKind(rule, sensorKind, sensorRaised)) return false;
|
|
33719
34182
|
const excluded = rule.conditions.devicesExclude;
|
|
33720
34183
|
if (excluded !== void 0 && deviceScopeExcludes(excluded, {
|
|
@@ -34011,7 +34474,7 @@ var NotificationCenter = class NotificationCenter {
|
|
|
34011
34474
|
ruleWantsOccupancyStill(edge) {
|
|
34012
34475
|
const subject = occupancySubjectFromEdge(edge);
|
|
34013
34476
|
for (const rule of this.rules.listEnabled("device-event")) {
|
|
34014
|
-
if (rule.
|
|
34477
|
+
if (effectiveMediaPolicyForRule(rule).attach === "none") continue;
|
|
34015
34478
|
const occ = rule.conditions.occupancy;
|
|
34016
34479
|
if (occ === void 0 || !matchesOccupancy(occ, subject)) continue;
|
|
34017
34480
|
const devices = rule.conditions.devices;
|
|
@@ -34278,6 +34741,12 @@ var NotificationCenter = class NotificationCenter {
|
|
|
34278
34741
|
logger: this.logger,
|
|
34279
34742
|
by: caller.userId
|
|
34280
34743
|
});
|
|
34744
|
+
await guardRuleDeviceCompat(rule, {
|
|
34745
|
+
logger: this.logger,
|
|
34746
|
+
by: caller.userId,
|
|
34747
|
+
listDeviceIdsByCaps: this.deps.listDeviceIdsByCaps,
|
|
34748
|
+
sampler: this.deviceCompatWarnSampler
|
|
34749
|
+
});
|
|
34281
34750
|
guardRuleSystemEventDelivery(rule, {
|
|
34282
34751
|
logger: this.logger,
|
|
34283
34752
|
by: caller.userId
|
|
@@ -34315,6 +34784,13 @@ var NotificationCenter = class NotificationCenter {
|
|
|
34315
34784
|
by: caller.userId,
|
|
34316
34785
|
ruleId
|
|
34317
34786
|
});
|
|
34787
|
+
await guardPatchDeviceCompat(existing, patch, {
|
|
34788
|
+
logger: this.logger,
|
|
34789
|
+
by: caller.userId,
|
|
34790
|
+
ruleId,
|
|
34791
|
+
listDeviceIdsByCaps: this.deps.listDeviceIdsByCaps,
|
|
34792
|
+
sampler: this.deviceCompatWarnSampler
|
|
34793
|
+
});
|
|
34318
34794
|
guardPatchSystemEventDelivery(existing, patch, clear ?? [], {
|
|
34319
34795
|
logger: this.logger,
|
|
34320
34796
|
by: caller.userId,
|
|
@@ -35077,6 +35553,8 @@ var NotificationCenter = class NotificationCenter {
|
|
|
35077
35553
|
const dropped = await this.outbox.supersede({
|
|
35078
35554
|
ruleId: source.ruleId,
|
|
35079
35555
|
recordId: source.recordId,
|
|
35556
|
+
deviceId: source.deviceId,
|
|
35557
|
+
recordAt: source.timestamp,
|
|
35080
35558
|
targetIds: [...combined]
|
|
35081
35559
|
});
|
|
35082
35560
|
this.logger.info("alarm notification combined with the rule that triggered it", {
|
|
@@ -35362,7 +35840,7 @@ var NotificationCenter = class NotificationCenter {
|
|
|
35362
35840
|
buttons: unresolvable.join(", ")
|
|
35363
35841
|
}
|
|
35364
35842
|
});
|
|
35365
|
-
if (!noOwnerReported && kind === "device-event" && subject.trackId === void 0 && subject.linkedStills === void 0 && rule.
|
|
35843
|
+
if (!noOwnerReported && kind === "device-event" && subject.trackId === void 0 && subject.linkedStills === void 0 && effectiveMediaPolicyForRule(rule).attach !== "none") {
|
|
35366
35844
|
noOwnerReported = true;
|
|
35367
35845
|
const linked = subject.linkedCameraIds;
|
|
35368
35846
|
const meta = {
|
|
@@ -35740,6 +36218,24 @@ var NotificationCenter = class NotificationCenter {
|
|
|
35740
36218
|
}
|
|
35741
36219
|
});
|
|
35742
36220
|
}
|
|
36221
|
+
/**
|
|
36222
|
+
* ONE line per rule id, the first time a SENSOR rule's stored media policy
|
|
36223
|
+
* is about to be overridden by {@link effectiveMediaPolicyForRule}'s
|
|
36224
|
+
* one-snapshot default (operator ruling, 2026-10-01). Never refuses the
|
|
36225
|
+
* rule — it still loads and still fires — this only says the stored choice
|
|
36226
|
+
* is not the one being used.
|
|
36227
|
+
*/
|
|
36228
|
+
warnSensorMediaIgnoredOnce(rule) {
|
|
36229
|
+
if (mediaPolicyMatchesSensorDefault(rule.media)) return;
|
|
36230
|
+
if (this.reportedSensorMediaIgnored.has(rule.id)) return;
|
|
36231
|
+
if (this.reportedSensorMediaIgnored.size >= NC_SENSOR_MEDIA_IGNORED_MEMO_MAX) this.reportedSensorMediaIgnored.clear();
|
|
36232
|
+
this.reportedSensorMediaIgnored.add(rule.id);
|
|
36233
|
+
this.logger.info("sensor rule carries a media choice — ignored at evaluation, one snapshot is always attached", { meta: {
|
|
36234
|
+
ruleId: rule.id,
|
|
36235
|
+
rule: rule.name,
|
|
36236
|
+
storedMedia: rule.media
|
|
36237
|
+
} });
|
|
36238
|
+
}
|
|
35743
36239
|
buildEntries(rule, subject, kind, matchedOn, userTargets = [], origin = "pipeline", cooldown, upgrade, group) {
|
|
35744
36240
|
const hasEventMedia = kind === "object-event" || kind === "package-event";
|
|
35745
36241
|
const isTrackScoped = kind === "object-event" || kind === "track-end";
|
|
@@ -35747,6 +36243,7 @@ var NotificationCenter = class NotificationCenter {
|
|
|
35747
36243
|
const alarmSource = subject.systemEvent?.alarmSource;
|
|
35748
36244
|
const mediaRule = alarmSource !== void 0 ? this.rules.get(alarmSource.ruleId) ?? rule : rule;
|
|
35749
36245
|
const noMedia = isSystemEvent && alarmSource === void 0;
|
|
36246
|
+
if (ruleKindOf(mediaRule) === "sensor") this.warnSensorMediaIgnoredOnce(mediaRule);
|
|
35750
36247
|
const direct = new Set(rule.targets.map((t) => t.targetId));
|
|
35751
36248
|
const renderedZones = notificationZones(rule, subject);
|
|
35752
36249
|
const confirm = frozenConfirmPolicy(rule, isSystemEvent);
|
|
@@ -95727,6 +96224,30 @@ async function encodeClipQuery(encode, text, modelId, warn) {
|
|
|
95727
96224
|
}
|
|
95728
96225
|
}
|
|
95729
96226
|
//#endregion
|
|
96227
|
+
//#region src/pipeline-analytics/nc-owner-media-reader.ts
|
|
96228
|
+
/** Keep only the rows written for THIS generation of the event id. */
|
|
96229
|
+
function ofGeneration(files, scope) {
|
|
96230
|
+
return files.filter((f) => f.timestamp === scope.recordAt);
|
|
96231
|
+
}
|
|
96232
|
+
function makeNcOwnerMediaReader(deps) {
|
|
96233
|
+
return async (ownerKind, ownerId, eventScope) => {
|
|
96234
|
+
if (ownerKind === "track") return deps.media.listByOwner("track", await deps.resolveTrackOwnerId(ownerId));
|
|
96235
|
+
if (eventScope === void 0) return deps.media.listByOwner("object", ownerId);
|
|
96236
|
+
const onCamera = await deps.media.listByOwner("object", ownerId, eventScope.deviceId);
|
|
96237
|
+
const kept = ofGeneration(onCamera, eventScope);
|
|
96238
|
+
if (kept.length < onCamera.length) deps.logger.warn("event media of another record on a reused event id — not used", {
|
|
96239
|
+
tags: { deviceId: eventScope.deviceId },
|
|
96240
|
+
meta: {
|
|
96241
|
+
ownerId,
|
|
96242
|
+
recordAt: eventScope.recordAt,
|
|
96243
|
+
refused: onCamera.length - kept.length,
|
|
96244
|
+
kept: kept.length
|
|
96245
|
+
}
|
|
96246
|
+
});
|
|
96247
|
+
return kept;
|
|
96248
|
+
};
|
|
96249
|
+
}
|
|
96250
|
+
//#endregion
|
|
95730
96251
|
//#region src/pipeline-analytics/store/settings-raw-mirror.ts
|
|
95731
96252
|
/** How long a mirrored entry is served before a refresh is started behind the
|
|
95732
96253
|
* next reader. See the module doc for why this matches the per-kind caches'
|
|
@@ -98899,6 +99420,7 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
|
|
|
98899
99420
|
listCallerTargetIds: async (userId) => {
|
|
98900
99421
|
return (await api.notificationOutput.listTargets.query({})).filter((t) => t.config["ownerUserId"] === userId).map((t) => t.id);
|
|
98901
99422
|
},
|
|
99423
|
+
listDeviceIdsByCaps: (capNames) => api.deviceManager.listDeviceIdsByCaps.query({ capNames: [...capNames] }),
|
|
98902
99424
|
dropRuleMosaics: async (ruleId) => {
|
|
98903
99425
|
const mediaStore = this.mediaStore;
|
|
98904
99426
|
if (mediaStore === null) return;
|
|
@@ -99815,6 +100337,7 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
|
|
|
99815
100337
|
addonId: device.addonId
|
|
99816
100338
|
};
|
|
99817
100339
|
},
|
|
100340
|
+
listDeviceIdsByCaps: (capNames) => api.deviceManager.listDeviceIdsByCaps.query({ capNames: [...capNames] }),
|
|
99818
100341
|
isDeviceDeleted: async (deviceId) => await api.deviceManager.getDevice.query({ deviceId }) === null,
|
|
99819
100342
|
readNodeDirectory: () => api.nodes.directory.query(),
|
|
99820
100343
|
getSnapshot: (deviceId) => api.snapshot.getSnapshot.query({ deviceId }),
|
|
@@ -100049,7 +100572,11 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
|
|
|
100049
100572
|
}
|
|
100050
100573
|
});
|
|
100051
100574
|
},
|
|
100052
|
-
getMediaForOwner:
|
|
100575
|
+
getMediaForOwner: makeNcOwnerMediaReader({
|
|
100576
|
+
media: stores.mediaStore,
|
|
100577
|
+
resolveTrackOwnerId: (trackId) => resolveMediaOwnerId("track", trackId, (id) => this.trackAliasStore?.resolve(id) ?? Promise.resolve(null), (failure) => this.aliasResolveFailureLogger?.(failure), "getMediaForOwner"),
|
|
100578
|
+
logger: this.ctx.logger.child("nc-media")
|
|
100579
|
+
}),
|
|
100053
100580
|
getSubjectTrail: async (deviceId, trackId) => {
|
|
100054
100581
|
const dims = this.lastFrameDimsByDevice.get(deviceId);
|
|
100055
100582
|
if (dims === void 0 || dims.w <= 0 || dims.h <= 0) return [];
|