@camstack/addon-post-analysis 1.2.279 → 1.2.281

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.
@@ -1,4 +1,4 @@
1
- import { $ as RECORDING_SOURCE_CAMSTACK_ADDON, $t as CamProfileSchema, At as isScheduleActive, B as NcRuleUpdateInputSchema, Bt as readDeviceStateFrom, C as MAX_BIRTH_DECISION_RECORDS, Ct as faceGalleryCapability, D as NC_ALARM_SYSTEM_EVENT_KINDS, Dt as isEventOwnerType, E as MediaFileKindEnum, Et as isDetectionMacroClass, F as NcConditionDescriptorSchema, Ft as occupancyScope, G as NcSystemEventKindSchema, Gt as storageOccupancyCapability, H as NcSnoozeInputSchema, Ht as resolveLocationMode, I as NcRuleInputSchema, J as NcTemplatePreviewSchema, Jt as templateVarsFor, K as NcTaxonomySchema, Kt as subKindsOf, L as NcRulePatchSchema, Lt as pickClusterStepModels, M as NC_TEMPLATE_VARS, Mt as kebabToCamel, N as NcAlarmConfigSchema, Nt as mayWriteToLocation, O as NC_CONDITION_CATALOG, Ot as isKnownTemplateVar, P as NcAlarmSettingsPatchSchema, Pt as notificationRulesCapability, Q as RECORDING_EXPORT_MAX_READ_BYTES, Qt as BaseAddon, R as NcRuleSchema, Rt as pipelineAnalyticsCapability, S as MAX_ARCHIVED_DEBUG_NOTES, St as evictionPolicyOfLocation, T as MOTION_CLOSE_AFTER_MS, U as NcSnoozeSchema, V as NcScheduleSchema, Vt as readTimelapseGeneratedAt, W as NcSnoozeSuppressedSchema, Wt as sceneMonitorCapability, X as OpsLogEntrySchema, Xt as zoneAnalyticsCapability, Y as NcTemplateVarDescriptorSchema, Yt as vectorDimFromBase64, Zt as errMsg$1, _ as FIRST_LEVEL_MACRO_CLASSES, _n as string, _t as cosineSimilarity$1, a as BirthDecisionRecordSchema, an as isDeviceScopedCap, at as SceneMonitorSchema, b as LabelAttributionSchema, bt as encodeVectorBase64, c as CoverStateSchema, cn as _enum, ct as TimelapseRulePatchSchema, dn as discriminatedUnion, dt as addonWidgetsSourceCapability, en as DeviceType, et as RetrainStatusSchema, f as DETECTION_MACRO_CLASSES, fn as literal, ft as alarmPanelCapability, g as EVENT_OWNER_TYPES, gn as record, gt as buildEventKindDescriptor, h as EVENT_KIND_BY_CAP, hn as partialRecord, ht as audioModeOf, i as BaseDevice, in as hydrateSchema, it as SCENE_DIVERGED, j as NC_TAXONOMY, jt as isSourceCap, k as NC_DEFAULT_SNOOZE_MINUTES, kt as isMediaOwnerType, l as DEFAULT_EVENT_COLOR, ln as array, lt as TimelapseRuleSchema, m as DeclaredDevices, mn as object, mt as audioMetricsCapability, n as AlarmArmModeSchema, nn as customAction, nt as SCENE_DEFAULT_ANCHOR_THRESHOLD, o as CLUSTER_MODEL_SCOPED_STEPS, on as nodePin, ot as TIMELAPSE_DENSE_FLOOR_SEC, pn as number, pt as assertTimelapseCadences, q as NcTemplatePreviewInputSchema, qt as systemEventFilterApplies, r as ArchivedDebugNoteSchema, rn as defineCustomActions, rt as SCENE_DEFAULT_UNCOVERED_POLICY, s as COCO_TO_MACRO, sn as sleep$1, st as TimelapseRuleInputSchema, t as AUDIO_MACRO_LABELS, tn as createEvent, un as boolean, ut as TrackSourceSchema, v as FULL_IMAGE_BBOX, vn as unknown, vt as deriveRecordingMode, wt as failureContributionCapability, x as LockStateSchema, xt as evaluateSensorEdge, y as FailureCounters, yn as EventCategory, z as NcRuleTargetSchema, zt as plateGalleryCapability } from "../dist-D0jvZ8dq.mjs";
1
+ import { $ as RECORDING_EXPORT_MAX_READ_BYTES, $t as vectorDimFromBase64, At as isKnownTemplateVar, B as NcRuleTargetSchema, Bt as pickClusterStepModels, C as MAX_BIRTH_DECISION_RECORDS, Cn as unknown, Ct as evaluateSensorEdge, D as NC_ALARM_SYSTEM_EVENT_KINDS, E as MediaFileKindEnum, Et as failureContributionCapability, F as NcConditionDescriptorSchema, Ft as kebabToCamel, G as NcSnoozeSuppressedSchema, Gt as resolveLocationMode, H as NcScheduleSchema, Ht as plateGalleryCapability, I as NcConditionsSchema, It as mayWriteToLocation, J as NcTemplatePreviewInputSchema, Jt as storageOccupancyCapability, K as NcSystemEventKindSchema, L as NcRuleInputSchema, Lt as notificationRulesCapability, M as NC_TEMPLATE_VARS, Mt as isScheduleActive, N as NcAlarmConfigSchema, Nt as isSourceCap, O as NC_CONDITION_CATALOG, Ot as isDetectionMacroClass, P as NcAlarmSettingsPatchSchema, Pt as isSystemDelivery, Qt as templateVarsFor, R as NcRulePatchSchema, Rt as occupancyScope, S as MAX_ARCHIVED_DEBUG_NOTES, Sn as string, St as encodeVectorBase64, T as MOTION_CLOSE_AFTER_MS, Tt as faceGalleryCapability, U as NcSnoozeInputSchema, Ut as readDeviceStateFrom, V as NcRuleUpdateInputSchema, Vt as pipelineAnalyticsCapability, W as NcSnoozeSchema, Wt as readTimelapseGeneratedAt, X as NcTemplateVarDescriptorSchema, Xt as systemEventFamilyProblems, Y as NcTemplatePreviewSchema, Yt as subKindsOf, Z as OpsLogEntrySchema, Zt as systemEventFilterApplies, _ as FIRST_LEVEL_MACRO_CLASSES, _n as literal, _t as buildEventKindDescriptor, a as BirthDecisionRecordSchema, an as DeviceType, at as SCENE_DIVERGED, b as LabelAttributionSchema, bn as partialRecord, bt as describeSystemEventFamilyProblem, c as CoverStateSchema, cn as defineCustomActions, ct as TimelapseRuleInputSchema, dn as nodePin, dt as TrackSourceSchema, en as zoneAnalyticsCapability, et as RECORDING_SOURCE_CAMSTACK_ADDON, f as DETECTION_MACRO_CLASSES, fn as sleep$1, ft as addonWidgetsSourceCapability, g as EVENT_OWNER_TYPES, gn as discriminatedUnion, gt as audioModeOf, h as EVENT_KIND_BY_CAP, hn as boolean, ht as audioMetricsCapability, i as BaseDevice, in as CamProfileSchema, it as SCENE_DEFAULT_UNCOVERED_POLICY, j as NC_TAXONOMY, jt as isMediaOwnerType, k as NC_DEFAULT_SNOOZE_MINUTES, kt as isEventOwnerType, l as DEFAULT_EVENT_COLOR, ln as hydrateSchema, lt as TimelapseRulePatchSchema, m as DeclaredDevices, mn as array, mt as assertTimelapseCadences, n as AlarmArmModeSchema, nn as canonicalHash, o as CLUSTER_MODEL_SCOPED_STEPS, on as createEvent, ot as SceneMonitorSchema, pn as _enum, pt as alarmPanelCapability, q as NcTaxonomySchema, qt as sceneMonitorCapability, r as ArchivedDebugNoteSchema, rn as BaseAddon, rt as SCENE_DEFAULT_ANCHOR_THRESHOLD, s as COCO_TO_MACRO, sn as customAction, st as TIMELAPSE_DENSE_FLOOR_SEC, t as AUDIO_MACRO_LABELS, tn as errMsg$1, tt as RetrainStatusSchema, un as isDeviceScopedCap, ut as TimelapseRuleSchema, v as FULL_IMAGE_BBOX, vn as number, vt as cosineSimilarity$1, wn as EventCategory, wt as evictionPolicyOfLocation, x as LockStateSchema, xn as record, y as FailureCounters, yn as object, yt as deriveRecordingMode, z as NcRuleSchema } from "../dist-DkERzZfY.mjs";
2
2
  import { t as __exportAll } from "../embedding-encoder/index.mjs";
3
3
  import * as fs from "node:fs";
4
4
  import { promises } from "node:fs";
@@ -9540,15 +9540,20 @@ function readSensorEventType(value) {
9540
9540
  const eventType = last["eventType"];
9541
9541
  return typeof eventType === "string" && eventType.length > 0 ? eventType : void 0;
9542
9542
  }
9543
+ /** A LINKED SET subject (ADR 0632) — see {@link NcRuleSubject.linkedSensorId}. */
9544
+ function isLinkedSetSubject(subject) {
9545
+ return subject.linkedSensorId !== void 0;
9546
+ }
9543
9547
  /**
9544
9548
  * Build the subject for a `device-event` evaluation (a persisted SensorEvent —
9545
- * one row per linked camera; `deviceId` is the CAMERA).
9549
+ * one row per linked camera; `deviceId` is the CAMERA), or, with `linked`, the
9550
+ * ONE sensor-level evaluation of a linked set.
9546
9551
  *
9547
9552
  * BOTH ids ride along. The camera is what the notification shows; the sensor is
9548
9553
  * what the operator named in the rule. Carrying only the camera is what made a
9549
9554
  * sensor-scoped rule unmatched — see {@link NcRuleSubject.sourceDeviceId}.
9550
9555
  */
9551
- function subjectFromSensorEvent(ev, markerTrackId) {
9556
+ function subjectFromSensorEvent(ev, markerTrackId, linked) {
9552
9557
  const eventType = readSensorEventType(ev.value);
9553
9558
  const sensorEdge = sensorEdgeOf(ev.kind, ev.value);
9554
9559
  return {
@@ -9563,7 +9568,14 @@ function subjectFromSensorEvent(ev, markerTrackId) {
9563
9568
  source: "sensor",
9564
9569
  sensorKind: ev.kind,
9565
9570
  ...eventType !== void 0 ? { eventType } : {},
9566
- ...sensorEdge !== void 0 ? { sensorEdge } : {}
9571
+ ...sensorEdge !== void 0 ? { sensorEdge } : {},
9572
+ ...linked !== void 0 ? {
9573
+ linkedSensorId: ev.sourceDeviceId,
9574
+ linkedCameraIds: [...linked.linkedCameraIds],
9575
+ rowCameraIds: [...linked.rowCameraIds],
9576
+ mutedRowCameraIds: [...linked.mutedRowCameraIds]
9577
+ } : {},
9578
+ ...linked?.linkedStills !== void 0 ? { linkedStills: linked.linkedStills } : {}
9567
9579
  };
9568
9580
  }
9569
9581
  /**
@@ -9885,6 +9897,21 @@ function zonesVisitedBy(subject) {
9885
9897
  if (subject.crossing !== void 0) visited.add(subject.crossing.zoneId);
9886
9898
  return visited;
9887
9899
  }
9900
+ /**
9901
+ * Could this `device-event` rule fire on a sensor change of `sensorKind`?
9902
+ * The SAME two refusals {@link evaluateRule} makes before anything else a
9903
+ * sensor subject is judged on — an `occupancy` condition (such a rule targets
9904
+ * only occupancy edges, which ride the same delivery) and a `sensorKinds` list
9905
+ * that excludes the kind — for a caller that must decide from the rules alone,
9906
+ * before the subject exists (the linked-camera capture gate). Coarse in one
9907
+ * direction only: `true` may still fail later; `false` never would have fired.
9908
+ */
9909
+ function deviceEventRuleMayFireOnKind(rule, sensorKind) {
9910
+ const c = rule.conditions;
9911
+ if (c.occupancy !== void 0) return false;
9912
+ if (c.sensorKinds === void 0 || c.sensorKinds.length === 0) return true;
9913
+ return toLowerSet(c.sensorKinds).has(sensorKind.trim().toLowerCase());
9914
+ }
9888
9915
  function toLowerSet(values) {
9889
9916
  return new Set(values.map((v) => v.trim().toLowerCase()));
9890
9917
  }
@@ -10285,13 +10312,17 @@ function systemEventCooldownScope(systemEvent) {
10285
10312
  * the operator actually cares about and the one they pick in the Devices
10286
10313
  * tab. Before this, that rule matched nothing, ever, silently.
10287
10314
  *
10288
- * Consequence worth knowing: a sensor linked to N cameras persists N rows, so
10289
- * a SENSOR-scoped rule is evaluated N times for one door opening. The rule's
10290
- * own throttle is what collapses that — `scope: 'rule'` gives one
10291
- * notification, `scope: 'device'` gives one per camera, which is the right
10292
- * choice when each carries its own picture.
10315
+ * A sensor linked to MORE than one camera is evaluated once, at the sensor
10316
+ * level (ADR 0632), with `deviceId` = the primary camera. That evaluation
10317
+ * matches the SENSOR and the cameras that took a timeline row for it
10318
+ * (`rowCameraIds`) — exactly the rules the per-camera rows would have matched
10319
+ * (D630 §6). The primary is only the camera the notification leads with: one
10320
+ * that links the sensor without opting it in keeps its rules silent, and an
10321
+ * opted-in camera that is not the primary still fires its own. The other
10322
+ * linked cameras ride `linkedCameraIds` for the MEDIA only.
10293
10323
  */
10294
10324
  function matchesDeviceScope(scope, subject) {
10325
+ if (subject.linkedSensorId !== void 0) return scope.includes(subject.linkedSensorId) || (subject.rowCameraIds ?? []).some((cameraId) => scope.includes(cameraId));
10295
10326
  if (scope.includes(subject.deviceId)) return true;
10296
10327
  return subject.sourceDeviceId !== void 0 && scope.includes(subject.sourceDeviceId);
10297
10328
  }
@@ -10514,23 +10545,34 @@ function incomingFromTrack(track, info) {
10514
10545
  };
10515
10546
  }
10516
10547
  /**
10517
- * Sensor-event persist (one row per linked camera).
10548
+ * Sensor-event persist (one row per linked camera), or — with `linked` — the
10549
+ * ONE sensor-level evaluation of a sensor linked to several cameras (ADR 0632),
10550
+ * whose `event.deviceId` is the primary camera.
10518
10551
  *
10519
10552
  * `markerTrackId` is the synthetic marker track the same state change
10520
10553
  * projected onto this camera — the notification's media owner, and the only
10521
10554
  * frame a device event has. Absent when no marker landed.
10555
+ *
10556
+ * A sensor-level evaluation is a per-EVENT line, so it is tagged with the
10557
+ * SENSOR; the primary camera rides in the meta. `mutedCameraIds` are the
10558
+ * linked cameras the centre left out (D62) — for the line's counts only.
10522
10559
  */
10523
- function incomingFromSensorEvent(event, markerTrackId) {
10560
+ function incomingFromSensorEvent(event, markerTrackId, linked, mutedCameraIds = []) {
10524
10561
  return {
10525
- subject: subjectFromSensorEvent(event, markerTrackId),
10562
+ subject: subjectFromSensorEvent(event, markerTrackId, linked),
10526
10563
  kind: "device-event",
10527
10564
  origin: "sensor",
10528
10565
  log: () => ({
10529
- tags: { deviceId: event.deviceId },
10566
+ tags: { deviceId: linked !== void 0 ? event.sourceDeviceId : event.deviceId },
10530
10567
  meta: {
10531
10568
  sensorEventId: event.id,
10532
10569
  kind: event.kind,
10533
- ...markerTrackId !== void 0 ? { trackId: markerTrackId } : {}
10570
+ ...markerTrackId !== void 0 ? { trackId: markerTrackId } : {},
10571
+ ...linked !== void 0 ? {
10572
+ ...event.deviceId !== event.sourceDeviceId ? { primaryCameraId: event.deviceId } : {},
10573
+ linkedCameras: linked.linkedCameraIds.length + mutedCameraIds.length,
10574
+ mutedCameras: mutedCameraIds.length
10575
+ } : {}
10534
10576
  }
10535
10577
  })
10536
10578
  };
@@ -13101,6 +13143,141 @@ function pickTrackNarrationFrames(rows, max) {
13101
13143
  return picked;
13102
13144
  }
13103
13145
  //#endregion
13146
+ //#region src/notification-center/rule-family-guard.ts
13147
+ /**
13148
+ * The hub's half of D637: a NEW or UPDATED system-event rule answers ONE family
13149
+ * (device events, system, alarm) and carries only that family's filters.
13150
+ *
13151
+ * THE authority for the invariant. The cap-provider route
13152
+ * (`notificationRules.createRule/updateRule`, index.ts) and the rule bridge
13153
+ * (`nc.createRule/updateRule`, nc-actions.ts) both call it through
13154
+ * `guardRuleFamily` (create) and `guardPatchFamily` (update), so the two save
13155
+ * paths cannot drift. This is the same
13156
+ * arrangement as `assertRuleHasAddressee`.
13157
+ *
13158
+ * Not in Zod: a cross-field refine on `NcConditionsSchema` would also reject the
13159
+ * STORED mixed rules at load (NcRuleSchema.parse in the store), and those must
13160
+ * keep firing until the operator recreates them. The engine is untouched, and
13161
+ * so is the device-prune runner: removing a deleted device id from a stored
13162
+ * rule is not authoring.
13163
+ */
13164
+ var NcRuleFamilyRefusal = class extends Error {
13165
+ problems;
13166
+ /** The rule that was judged: the input on a create, the MERGED rule on an update. */
13167
+ rule;
13168
+ constructor(problems, rule) {
13169
+ super(`system-event rule refused: ${problems.map(describeSystemEventFamilyProblem).join("; ")}`);
13170
+ this.name = "NcRuleFamilyRefusal";
13171
+ this.problems = problems;
13172
+ this.rule = rule;
13173
+ }
13174
+ };
13175
+ function assertRuleFamily(rule) {
13176
+ if (!isSystemDelivery(rule.delivery)) return;
13177
+ const problems = systemEventFamilyProblems(rule.conditions.systemEvent);
13178
+ if (problems.length > 0) throw new NcRuleFamilyRefusal(problems, rule);
13179
+ }
13180
+ var byValue = (a, b) => a < b ? -1 : a > b ? 1 : 0;
13181
+ var byNumber = (a, b) => a - b;
13182
+ /**
13183
+ * A system-event condition with every list sorted. The engine reads `kinds` and
13184
+ * the id-list filters as SETS (membership only), so an editor that re-sorts
13185
+ * them — the admin's `toggleSystemEventKind` puts kinds in option order — has
13186
+ * not changed the rule. Copies; the input is never touched.
13187
+ */
13188
+ function sortedSystemEvent(condition) {
13189
+ const { kinds, deviceIds, excludeDeviceIds, deviceTypes, nodeIds, packageNames } = condition;
13190
+ return {
13191
+ ...condition,
13192
+ kinds: kinds.toSorted(byValue),
13193
+ ...deviceIds !== void 0 ? { deviceIds: deviceIds.toSorted(byNumber) } : {},
13194
+ ...excludeDeviceIds !== void 0 ? { excludeDeviceIds: excludeDeviceIds.toSorted(byNumber) } : {},
13195
+ ...deviceTypes !== void 0 ? { deviceTypes: deviceTypes.toSorted(byValue) } : {},
13196
+ ...nodeIds !== void 0 ? { nodeIds: nodeIds.toSorted(byValue) } : {},
13197
+ ...packageNames !== void 0 ? { packageNames: packageNames.toSorted(byValue) } : {}
13198
+ };
13199
+ }
13200
+ /** The canonical form two conditions are compared in. */
13201
+ function conditionsFingerprint(conditions) {
13202
+ const { systemEvent } = conditions;
13203
+ return canonicalHash(systemEvent === void 0 ? conditions : {
13204
+ ...conditions,
13205
+ systemEvent: sortedSystemEvent(systemEvent)
13206
+ });
13207
+ }
13208
+ /**
13209
+ * Do two conditions say the same thing? Both go through `NcConditionsSchema`
13210
+ * (the normalisation the store applies on every write) and are compared
13211
+ * canonically: key order is irrelevant, an `undefined` key equals an absent
13212
+ * one, and the system-event `kinds` and id-list filters are compared as SETS.
13213
+ * Every other array keeps its order. A side that does not parse is treated as
13214
+ * a change, so it is judged rather than waved through.
13215
+ */
13216
+ function sameConditions(a, b) {
13217
+ const left = NcConditionsSchema.safeParse(a);
13218
+ const right = NcConditionsSchema.safeParse(b);
13219
+ if (!left.success || !right.success) return false;
13220
+ return conditionsFingerprint(left.data) === conditionsFingerprint(right.data);
13221
+ }
13222
+ /**
13223
+ * The rule a patch WOULD produce, as far as the family is concerned — merged
13224
+ * the way the store merges it: `delivery` and `conditions` replace whole.
13225
+ *
13226
+ * `null` when the patch CHANGES neither. Both editors send the whole rule on
13227
+ * every save, so "the patch carries `conditions`" is not "the patch changes
13228
+ * them": an unchanged full-form save of a stored mixed rule (a rename, a
13229
+ * retarget) must pass, and only a save that alters the delivery or the
13230
+ * conditions is judged.
13231
+ */
13232
+ function mergedFamilySubject(existing, patch) {
13233
+ const deliveryChanged = patch.delivery !== void 0 && patch.delivery !== existing.delivery;
13234
+ const conditionsChanged = patch.conditions !== void 0 && !sameConditions(patch.conditions, existing.conditions);
13235
+ if (!deliveryChanged && !conditionsChanged) return null;
13236
+ return {
13237
+ delivery: patch.delivery ?? existing.delivery,
13238
+ conditions: patch.conditions ?? existing.conditions
13239
+ };
13240
+ }
13241
+ /**
13242
+ * On update, the MERGED rule is judged. A patch that changes neither `delivery`
13243
+ * nor `conditions` (absent, or equal to what is stored) leaves the kinds as
13244
+ * they are, so a stored mixed rule can still be switched off, renamed or
13245
+ * retargeted before the operator recreates it.
13246
+ */
13247
+ function assertPatchKeepsFamily(existing, patch) {
13248
+ if (existing === null) return;
13249
+ const merged = mergedFamilySubject(existing, patch);
13250
+ if (merged !== null) assertRuleFamily(merged);
13251
+ }
13252
+ /**
13253
+ * Run a family check and LOG a refusal: who tried to save, the rule that was
13254
+ * judged (its delivery and kinds) and every problem in words (the families,
13255
+ * the offending kind or filter). Then rethrow. A refused save is dropped work,
13256
+ * and dropped work is never silent. Any other error passes through unlogged.
13257
+ */
13258
+ function logRefusal(check, ctx) {
13259
+ try {
13260
+ check();
13261
+ } catch (err) {
13262
+ if (err instanceof NcRuleFamilyRefusal) ctx.logger.warn("nc rule refused: outside one family", { meta: {
13263
+ ...ctx.ruleId !== void 0 ? { ruleId: ctx.ruleId } : {},
13264
+ by: ctx.by,
13265
+ delivery: err.rule.delivery,
13266
+ kinds: err.rule.conditions.systemEvent?.kinds ?? [],
13267
+ problems: err.problems.map(describeSystemEventFamilyProblem)
13268
+ } });
13269
+ throw err;
13270
+ }
13271
+ }
13272
+ /** {@link assertRuleFamily}, logged. The create path of both routes. */
13273
+ function guardRuleFamily(rule, ctx) {
13274
+ logRefusal(() => assertRuleFamily(rule), ctx);
13275
+ }
13276
+ /** {@link assertPatchKeepsFamily}, logged. The update path of both routes. */
13277
+ function guardPatchFamily(existing, patch, ctx) {
13278
+ logRefusal(() => assertPatchKeepsFamily(existing, patch), ctx);
13279
+ }
13280
+ //#endregion
13104
13281
  //#region src/notification-center/occupancy-watcher.ts
13105
13282
  /** Sentinel key segments for the "no zone" (whole-frame) and "no class" scopes. */
13106
13283
  var FRAME_SCOPE = "@frame";
@@ -15801,6 +15978,10 @@ function makeNcActionHandlers(deps) {
15801
15978
  const parsed = NcRuleInputSchema.parse(input.rule);
15802
15979
  assertAdminOnlyFields(parsed, [], c);
15803
15980
  assertRuleHasAddressee(parsed.targets, parsed.targetUsers);
15981
+ guardRuleFamily(parsed, {
15982
+ logger: deps.logger,
15983
+ by: c.userId
15984
+ });
15804
15985
  await assertTargetsOwned(parsed.targets.map((t) => t.targetId), c);
15805
15986
  const rule = await deps.ruleStore.create({
15806
15987
  ...parsed,
@@ -15819,6 +16000,11 @@ function makeNcActionHandlers(deps) {
15819
16000
  const clear = input.clear ?? [];
15820
16001
  assertAdminOnlyFields(patch, clear, c, existing);
15821
16002
  assertPatchKeepsAddressee(existing, patch, clear);
16003
+ guardPatchFamily(existing, patch, {
16004
+ logger: deps.logger,
16005
+ by: c.userId,
16006
+ ruleId: input.ruleId
16007
+ });
15822
16008
  if (patch.targets !== void 0) await assertTargetsOwned(patch.targets.map((t) => t.targetId), c);
15823
16009
  const { ownerUserId: _owner, disabledTargetIds: _optOut, ...safe } = patch;
15824
16010
  const rule = await deps.ruleStore.update(input.ruleId, safe, clear);
@@ -16982,7 +17168,8 @@ var NcDispatcher = class {
16982
17168
  const analyse = this.deps.ruleAi;
16983
17169
  const policy = entry.payload.ai;
16984
17170
  if (analyse === void 0 || policy === void 0) return null;
16985
- const frames = policy.shape === "detection-track" ? await this.trackNarrationFrames(entry) : shippedFrames(built.notification);
17171
+ const tiles = policy.shape === "detection-track" ? [] : await this.linkedAnalysisFrames(entry);
17172
+ const frames = policy.shape === "detection-track" ? await this.trackNarrationFrames(entry) : tiles.length > 0 ? tiles : shippedFrames(built.notification);
16986
17173
  if (frames.length === 0) {
16987
17174
  this.deps.logger.debug("notification AI skipped — no picture to describe", {
16988
17175
  tags: { deviceId: entry.deviceId },
@@ -17023,6 +17210,43 @@ var NcDispatcher = class {
17023
17210
  return null;
17024
17211
  }
17025
17212
  }
17213
+ /** A linked set's raw per-camera tiles (`subject.analysisTiles`), in
17214
+ * camera order — one frame per camera whose picture landed. Best-effort: an
17215
+ * owner that answers nothing or throws is left out (a blank frame is never
17216
+ * handed to a model), and no owners at all is `[]`. */
17217
+ async linkedAnalysisFrames(entry) {
17218
+ const tiles = entry.payload.subject.analysisTiles ?? [];
17219
+ const frames = [];
17220
+ for (const { ownerId, cameraId } of tiles) {
17221
+ const file = await this.linkedStillFile(entry, ownerId, cameraId);
17222
+ const bytes = file === void 0 ? null : decodeStillBytes(file.base64);
17223
+ if (bytes !== null) frames.push({
17224
+ bytes,
17225
+ mime: "image/jpeg"
17226
+ });
17227
+ }
17228
+ return frames;
17229
+ }
17230
+ /** One linked-camera shelf owner's still — `keyFrameSmall`, else whatever it
17231
+ * holds. `undefined` when it holds nothing or the read throws (said at DEBUG
17232
+ * on `cameraId`, the camera the picture is of: the caller decides whether
17233
+ * the absence is worth a line of its own). */
17234
+ async linkedStillFile(entry, ownerId, cameraId) {
17235
+ try {
17236
+ const files = await this.deps.getMediaForOwner("track", ownerId);
17237
+ return files.find((f) => f.kind === "keyFrameSmall") ?? files[0];
17238
+ } catch (err) {
17239
+ this.deps.logger.debug("linked camera picture read failed", {
17240
+ tags: { deviceId: cameraId },
17241
+ meta: {
17242
+ recordId: entry.recordId,
17243
+ ownerId,
17244
+ error: String(err)
17245
+ }
17246
+ });
17247
+ return;
17248
+ }
17249
+ }
17026
17250
  /** The whole-scene frames spanning a CLOSED track, oldest first. Best-effort
17027
17251
  * in every direction: no track id, a throwing read or an empty store all
17028
17252
  * leave the pass with nothing, which skips it. */
@@ -17031,11 +17255,8 @@ var NcDispatcher = class {
17031
17255
  if (trackId === void 0) return [];
17032
17256
  try {
17033
17257
  return pickTrackNarrationFrames(await this.deps.getMediaForOwner("track", trackId), 3).flatMap((file) => {
17034
- const raw = Buffer.from(file.base64, "base64");
17035
- if (raw.byteLength === 0) return [];
17036
- const bytes = new Uint8Array(raw.byteLength);
17037
- bytes.set(raw);
17038
- return [{
17258
+ const bytes = decodeStillBytes(file.base64);
17259
+ return bytes === null ? [] : [{
17039
17260
  bytes,
17040
17261
  mime: "image/jpeg",
17041
17262
  atMs: file.timestamp
@@ -17483,7 +17704,7 @@ var NcDispatcher = class {
17483
17704
  const deviceId = entry.payload.subject.deviceId;
17484
17705
  const common = {
17485
17706
  deviceId: deviceId > 0 ? deviceId : null,
17486
- deviceIds: deviceId > 0 ? [deviceId] : [],
17707
+ deviceIds: entry.payload.subject.linkedCameraIds !== void 0 ? [...entry.payload.subject.linkedCameraIds] : deviceId > 0 ? [deviceId] : [],
17487
17708
  capturedAt: entry.payload.subject.timestamp,
17488
17709
  ruleId: entry.ruleId,
17489
17710
  ruleName: entry.payload.ruleName
@@ -17531,6 +17752,7 @@ var NcDispatcher = class {
17531
17752
  const footagePending = this.renderFootage(entry);
17532
17753
  const still = firstLook !== null || entry.payload.media === "none" ? firstLook : await this.waitForStill(entry, stillOverride);
17533
17754
  const footage = await footagePending;
17755
+ const extras = await this.linkedExtraStills(entry);
17534
17756
  if (still !== null) {
17535
17757
  const zoneIds = zoneIdsWanted;
17536
17758
  if (zoneIds !== void 0 && zoneIds.length > 0) {
@@ -17548,7 +17770,17 @@ var NcDispatcher = class {
17548
17770
  name: "group.jpg"
17549
17771
  } : still);
17550
17772
  }
17551
- } else if (entry.payload.media !== "none") this.deps.logger.info("no still could be resolved — this notification ships text-only", {
17773
+ } else if (entry.payload.media !== "none" && extras.length > 0) this.deps.logger.info("no still of the primary camera could be resolved — the other linked cameras' stills ship without it", {
17774
+ tags: { deviceId: entry.payload.subject.deviceId },
17775
+ meta: {
17776
+ recordKind: entry.recordKind,
17777
+ recordId: entry.recordId,
17778
+ policy: entry.payload.media,
17779
+ ...entry.payload.subject.trackId !== void 0 ? { owner: `track:${entry.payload.subject.trackId}` } : {},
17780
+ otherCameras: extras.length
17781
+ }
17782
+ });
17783
+ else if (entry.payload.media !== "none") this.deps.logger.info("no still could be resolved — this notification ships text-only", {
17552
17784
  tags: { deviceId: entry.payload.subject.deviceId },
17553
17785
  meta: {
17554
17786
  recordKind: entry.recordKind,
@@ -17559,6 +17791,43 @@ var NcDispatcher = class {
17559
17791
  }
17560
17792
  });
17561
17793
  out.push(...footage.attachments);
17794
+ out.push(...extras);
17795
+ return out;
17796
+ }
17797
+ /**
17798
+ * `each` (ADR 0632): the OTHER linked cameras' stills, in camera order —
17799
+ * AFTER the primary still and the primary's footage, so a target with little
17800
+ * room keeps both and the other cameras fill only what is left (the degrade
17801
+ * engine drops by position). A camera whose picture never landed is skipped
17802
+ * and named ON THAT CAMERA: a blank attachment would read as a broken camera.
17803
+ * The camera is the one the row CARRIES with each still (the one the shelf
17804
+ * photographed) — never inferred from a position in `linkedCameraIds`, which
17805
+ * a mute between capture and evaluation shifts. Named `camera-<id>.jpg`.
17806
+ */
17807
+ async linkedExtraStills(entry) {
17808
+ const subject = entry.payload.subject;
17809
+ const out = [];
17810
+ for (const { ownerId, cameraId } of subject.extraStills ?? []) {
17811
+ const file = await this.linkedStillFile(entry, ownerId, cameraId);
17812
+ const bytes = file === void 0 ? null : decodeStillBytes(file.base64);
17813
+ if (bytes === null) {
17814
+ this.deps.logger.info("linked camera picture missing at send — skipped", {
17815
+ tags: { deviceId: cameraId },
17816
+ meta: {
17817
+ recordId: entry.recordId,
17818
+ ruleId: entry.ruleId,
17819
+ ownerId
17820
+ }
17821
+ });
17822
+ continue;
17823
+ }
17824
+ out.push({
17825
+ mediaType: "image",
17826
+ bytes,
17827
+ mime: "image/jpeg",
17828
+ name: `camera-${String(cameraId)}.jpg`
17829
+ });
17830
+ }
17562
17831
  return out;
17563
17832
  }
17564
17833
  /**
@@ -18037,10 +18306,8 @@ var NcDispatcher = class {
18037
18306
  const shortlist = await this.narrowToTriggerZone(entry, inBand, freshness);
18038
18307
  if (shortlist.length === 0) continue;
18039
18308
  for (const file of shortlist) {
18040
- const raw = Buffer.from(file.base64, "base64");
18041
- if (raw.byteLength === 0) continue;
18042
- const bytes = new Uint8Array(raw.byteLength);
18043
- bytes.set(raw);
18309
+ const bytes = decodeStillBytes(file.base64);
18310
+ if (bytes === null) continue;
18044
18311
  return {
18045
18312
  mediaType: "image",
18046
18313
  bytes,
@@ -18273,6 +18540,18 @@ function copyBytes(bytes) {
18273
18540
  out.set(bytes);
18274
18541
  return out;
18275
18542
  }
18543
+ /**
18544
+ * A stored file's base64 as bytes backed by their OWN `ArrayBuffer`, or `null`
18545
+ * when it decodes to nothing (a blank picture is never a picture).
18546
+ *
18547
+ * The copy is the point: a pooled `Buffer` view throws `Trying to deserialize
18548
+ * unknown typed array` on the far side of a transport, and the attachment and
18549
+ * frame types both demand `Uint8Array<ArrayBuffer>`.
18550
+ */
18551
+ function decodeStillBytes(base64) {
18552
+ const raw = Buffer.from(base64, "base64");
18553
+ return raw.byteLength === 0 ? null : copyBytes(raw);
18554
+ }
18276
18555
  /** Does this rule ever cut footage from the broker's clip ring? */
18277
18556
  function wantsFootage(rule) {
18278
18557
  return rule.media.gif === true || rule.media.clip === true;
@@ -20109,6 +20388,916 @@ var DeviceLivenessHoldoff = class {
20109
20388
  }
20110
20389
  };
20111
20390
  //#endregion
20391
+ //#region src/notification-center/linked-camera-media.ts
20392
+ /** ADR 0632: absent = mosaic, applied HERE and never as a Zod default. */
20393
+ function linkedCameraMode(media) {
20394
+ return media.linkedCameras ?? "mosaic";
20395
+ }
20396
+ /** Which shelf owners one RULE's row names. The model always gets the raw tiles
20397
+ * (like the digest, D154) — a composite is a worse input than its parts. */
20398
+ function linkedMediaOwners(stills, mode) {
20399
+ const tiles = stills.cameraOwnerIds.flatMap((ownerId, i) => {
20400
+ const cameraId = stills.cameraIds[i];
20401
+ return cameraId === void 0 ? [] : [{
20402
+ ownerId,
20403
+ cameraId
20404
+ }];
20405
+ });
20406
+ const [primary] = stills.cameraOwnerIds;
20407
+ if (mode === "each" && primary !== void 0) return {
20408
+ trackId: primary,
20409
+ extraStills: tiles.slice(1),
20410
+ analysisTiles: tiles
20411
+ };
20412
+ return {
20413
+ trackId: stills.mosaicOwnerId,
20414
+ extraStills: [],
20415
+ analysisTiles: tiles
20416
+ };
20417
+ }
20418
+ /** JPEG quality for the downscaled full frame — matches the crop path. */
20419
+ var FULL_FRAME_QUALITY = 80;
20420
+ /**
20421
+ * Downscale an already-encoded JPEG full frame to FIT WITHIN
20422
+ * {@link FULL_FRAME_MAX_WIDTH}×{@link FULL_FRAME_MAX_HEIGHT}, preserving aspect
20423
+ * ratio (`fit: 'inside'`) and never enlarging a source already smaller than the
20424
+ * box. Re-encodes as JPEG. Used before persisting a synthetic sensor/control
20425
+ * track's whole-scene snapshot so a raw native-resolution frame (a 4K bedroom
20426
+ * at night) is never stored or served — the privacy fix moved to CAPTURE time.
20427
+ */
20428
+ async function downscaleFullFrameJpeg(jpeg, maxWidth = 640, maxHeight = 360) {
20429
+ return sharp(Buffer.from(jpeg)).resize(maxWidth, maxHeight, {
20430
+ fit: "inside",
20431
+ withoutEnlargement: true
20432
+ }).jpeg({ quality: FULL_FRAME_QUALITY }).toBuffer();
20433
+ }
20434
+ //#endregion
20435
+ //#region src/notification-center/still-shelf.ts
20436
+ /**
20437
+ * The still shelf — the PHOTOGRAPH a notification carries when nothing it can
20438
+ * name owns a frame.
20439
+ *
20440
+ * ## Why a trigger needs one at all
20441
+ *
20442
+ * The attachment ladder resolves media by OWNER, and most triggers have one: an
20443
+ * object or package event owns its crops, a closed track owns its best shot, a
20444
+ * doorbell press owns the marker track the same press projected
20445
+ * (`sensor-marker-projector.ts`), an occupancy edge names one of the objects it
20446
+ * counted (`chooseOccupancyMediaOwner`). Two triggers own nothing, and for the
20447
+ * same reason in both cases — the subject is an ABSENCE:
20448
+ *
20449
+ * - an **audio** match: nothing was boxed, nothing was tracked, and —
20450
+ * deliberately — nothing is persisted at all. A confirmed window is a claim
20451
+ * about sound that has already stopped, and `event-intake.ts` states why
20452
+ * replaying it later would be wrong.
20453
+ * - an **occupancy** edge whose scope is EMPTY — "posto libero". The vehicle
20454
+ * whose departure IS the news has left, so `chooseOccupancyMediaOwner` names
20455
+ * nobody and the ladder logs `no still could be resolved … owners=[]`.
20456
+ *
20457
+ * So the only honest picture is a PHOTOGRAPH of the camera taken at the moment
20458
+ * of the trigger. Not of the sound, not of the object that left — of what the
20459
+ * camera can see now that it happened. For a freed parking space that is
20460
+ * exactly the answer the operator wants: the space, empty.
20461
+ *
20462
+ * **One shelf, not two.** The mechanism is identical down to the reuse window,
20463
+ * and the only thing that differs between the two triggers is the GATE deciding
20464
+ * that a photograph is owed at all — which belongs at the trigger site, where
20465
+ * the rules are, and not here. A twin module would be a second derivation of
20466
+ * one thing. The trigger rides along as {@link NcStillTrigger} only so the logs
20467
+ * can say which absence they are about.
20468
+ *
20469
+ * ## Three properties, and each one is a decision
20470
+ *
20471
+ * **It is not a record.** The bytes live here, in RAM, under an owner id and a
20472
+ * TTL that covers the outbox's whole retry horizon — and nowhere else. The
20473
+ * alternative was the doorbell's: materialise a synthetic marker track through
20474
+ * `SyntheticTrackMaterializer` and let the notification name it. That would put
20475
+ * a durable Track on the camera's timeline for every confirmed window and every
20476
+ * emptied zone, feeding the digest's `listTracks`, retention, and the audio-
20477
+ * marker feature's own operator ceilings (`audio-marker-projector.ts` exists
20478
+ * precisely to bound how many audio markers a camera may emit). A notification
20479
+ * must not manufacture timeline history as a side effect of wanting a picture.
20480
+ *
20481
+ * **The capture STARTS immediately and is never awaited.** The subject is
20482
+ * transient — a scream is over before a snapshot round-trip completes, and a
20483
+ * freed space is about to be taken by the next car — so the fetch is kicked off
20484
+ * at the trigger, before the rule evaluation runs, and the owner id is minted
20485
+ * synchronously so the outbox row can name it. The bytes land while the row
20486
+ * waits in the queue, and the dispatcher's existing bounded still-wait (or the
20487
+ * pause its own footage render already costs) picks them up. A camera that
20488
+ * never answers costs the picture and never the notification.
20489
+ *
20490
+ * **Two triggers seconds apart share ONE capture.** A barking dog confirms
20491
+ * repeatedly and a label-mode rule has no re-arm timer at all (D157) — the
20492
+ * rule's cooldown is its only brake, and the cooldown is applied AFTER this.
20493
+ * Without a reuse window this would photograph a camera at whatever rate the
20494
+ * sound happens to occur. Each trigger still gets its OWN owner id, so two
20495
+ * outbox rows are never mistaken for one subject; they merely point at the same
20496
+ * frame, which is the truth — the scene did not change in ten seconds. The
20497
+ * window is per CAMERA and trigger-agnostic for the same reason: a sound and an
20498
+ * emptied zone ten seconds apart are two claims about one scene.
20499
+ *
20500
+ * Nothing here is silent: a capture that lands and a camera that refuses each
20501
+ * emit one line carrying `tags: { deviceId }`, because "why did 617 get a photo
20502
+ * and 615 not" is the only form that question is ever asked in.
20503
+ */
20504
+ /**
20505
+ * The owner-id namespace. It is what routes a lookup here instead of to the
20506
+ * media store, and it is the reason the dispatcher needs no audio or occupancy
20507
+ * branch — `getMediaForOwner` answers for all of them under one signature.
20508
+ */
20509
+ var NC_STILL_SHELF_PREFIX = "nc-still:";
20510
+ /** True for an owner id this shelf minted. */
20511
+ function isStillShelfId(id) {
20512
+ return id.startsWith(NC_STILL_SHELF_PREFIX);
20513
+ }
20514
+ /**
20515
+ * How long the bytes are held.
20516
+ *
20517
+ * The outbox retries 8 times with a 5 s → 300 s backoff, which tops out around
20518
+ * ten minutes; fifteen covers that with room for a slow drain. Past it the row
20519
+ * ships text-only, which is the correct degradation for a photograph of a scene
20520
+ * that is a quarter of an hour stale anyway.
20521
+ */
20522
+ var NC_STILL_SHELF_TTL_MS = 15 * 6e4;
20523
+ /** A hanging snapshot cap must not pin a capture slot forever. */
20524
+ var SNAPSHOT_TIMEOUT_MS = 8e3;
20525
+ /**
20526
+ * Hard bound on held captures. Reached only if every camera on the hub triggers
20527
+ * one inside a single TTL; the oldest is dropped first, which costs a
20528
+ * fifteen-minute-old picture nobody is waiting for.
20529
+ */
20530
+ var MAX_CAPTURES = 64;
20531
+ var NcStillShelf = class {
20532
+ deps;
20533
+ /** captureId → the photograph. */
20534
+ captures = /* @__PURE__ */ new Map();
20535
+ /** ownerId → captureId. Several owners may name one capture (the reuse window). */
20536
+ owners = /* @__PURE__ */ new Map();
20537
+ /** deviceId → its newest capture, for the reuse window. */
20538
+ newest = /* @__PURE__ */ new Map();
20539
+ constructor(deps) {
20540
+ this.deps = deps;
20541
+ }
20542
+ /**
20543
+ * Photograph `deviceId` for a trigger at `atMs`, and return the owner id the
20544
+ * subject should name. SYNCHRONOUS by contract: the caller is on the trigger
20545
+ * path and the outbox row is built from what this returns.
20546
+ */
20547
+ capture(deviceId, atMs, trigger) {
20548
+ const now = this.deps.now();
20549
+ this.prune(now);
20550
+ const ownerId = `${NC_STILL_SHELF_PREFIX}${randomUUID()}`;
20551
+ const recent = this.newest.get(deviceId);
20552
+ if (recent !== void 0 && now - recent.at < 1e4 && this.captures.has(recent.captureId)) {
20553
+ this.owners.set(ownerId, recent.captureId);
20554
+ return ownerId;
20555
+ }
20556
+ const captureId = randomUUID();
20557
+ this.captures.set(captureId, {
20558
+ deviceId,
20559
+ trigger,
20560
+ startedAt: now,
20561
+ expiresAt: now + NC_STILL_SHELF_TTL_MS,
20562
+ files: []
20563
+ });
20564
+ this.newest.set(deviceId, {
20565
+ captureId,
20566
+ at: now
20567
+ });
20568
+ this.owners.set(ownerId, captureId);
20569
+ this.fetch(captureId, deviceId, atMs, trigger);
20570
+ return ownerId;
20571
+ }
20572
+ /**
20573
+ * The media an owner id holds, or `undefined` when this shelf never minted
20574
+ * it. An EMPTY array is a different answer: the capture exists and has not
20575
+ * landed (or never will), which is exactly the case the dispatcher's bounded
20576
+ * wait and its `no still could be resolved` line are for.
20577
+ */
20578
+ get(ownerId) {
20579
+ const captureId = this.owners.get(ownerId);
20580
+ if (captureId === void 0) return void 0;
20581
+ return this.captures.get(captureId)?.files ?? [];
20582
+ }
20583
+ /** Drop expired captures and the owners that named them. */
20584
+ prune(now) {
20585
+ for (const [captureId, held] of this.captures) {
20586
+ if (held.expiresAt > now) continue;
20587
+ this.captures.delete(captureId);
20588
+ if (this.newest.get(held.deviceId)?.captureId === captureId) this.newest.delete(held.deviceId);
20589
+ }
20590
+ while (this.captures.size > MAX_CAPTURES) {
20591
+ const oldest = this.captures.keys().next();
20592
+ if (oldest.done === true) break;
20593
+ const held = this.captures.get(oldest.value);
20594
+ this.captures.delete(oldest.value);
20595
+ if (held !== void 0 && this.newest.get(held.deviceId)?.captureId === oldest.value) this.newest.delete(held.deviceId);
20596
+ }
20597
+ for (const [ownerId, captureId] of this.owners) if (!this.captures.has(captureId)) this.owners.delete(ownerId);
20598
+ }
20599
+ /** Drop everything (shutdown). */
20600
+ clear() {
20601
+ this.captures.clear();
20602
+ this.owners.clear();
20603
+ this.newest.clear();
20604
+ }
20605
+ async fetch(captureId, deviceId, atMs, trigger) {
20606
+ try {
20607
+ const shot = await withTimeout$4(this.deps.getSnapshot(deviceId), SNAPSHOT_TIMEOUT_MS);
20608
+ if (shot === null || shot.base64.length === 0) {
20609
+ this.reportMiss(deviceId, trigger, "the camera returned no snapshot");
20610
+ return;
20611
+ }
20612
+ const raw = Buffer.from(shot.base64, "base64");
20613
+ let data = raw;
20614
+ try {
20615
+ data = await downscaleFullFrameJpeg(raw, 960, 540);
20616
+ } catch {}
20617
+ const held = this.captures.get(captureId);
20618
+ if (held === void 0) return;
20619
+ this.captures.set(captureId, {
20620
+ ...held,
20621
+ files: stillShelfMedia(data, atMs)
20622
+ });
20623
+ this.deps.logger.info(`${trigger} still captured`, {
20624
+ tags: { deviceId },
20625
+ meta: {
20626
+ trigger,
20627
+ bytes: data.byteLength,
20628
+ tookMs: this.deps.now() - held.startedAt
20629
+ }
20630
+ });
20631
+ } catch (err) {
20632
+ this.reportMiss(deviceId, trigger, err instanceof Error ? err.message : String(err));
20633
+ }
20634
+ }
20635
+ /**
20636
+ * A branch that drops work says so. This one costs the operator the picture
20637
+ * on a notification they DID receive, so it is a warn and it carries the
20638
+ * camera — the only key the question is ever asked with.
20639
+ */
20640
+ reportMiss(deviceId, trigger, reason) {
20641
+ this.deps.logger.warn(`the camera did not answer the ${trigger} still — this notification ships text-only`, {
20642
+ tags: { deviceId },
20643
+ meta: {
20644
+ trigger,
20645
+ reason
20646
+ }
20647
+ });
20648
+ }
20649
+ };
20650
+ /**
20651
+ * The photograph, in the three kinds the CLEAN-SCENE ladders ask for.
20652
+ *
20653
+ * `keyFrameSmall` leads because it is the first rung of both `attach: 'best'`
20654
+ * and `attach: 'keyFrame'` on a track owner; `keyFrame` and `fullFrame` answer
20655
+ * `frame: 'full'` and `frame: 'boxed'`'s honest degrade. There is deliberately
20656
+ * no `crop` / `thumbnail`: nothing was boxed, so `frame: 'cropped'` resolves
20657
+ * NOTHING and the notification ships text-only with the ordinary line saying
20658
+ * so. Inventing a centre crop would answer a different question from the one
20659
+ * the operator asked.
20660
+ */
20661
+ function stillShelfMedia(data, timestamp) {
20662
+ const base64 = data.toString("base64");
20663
+ const file = (kind) => ({
20664
+ mediaId: nextUnstoredMediaId(),
20665
+ kind,
20666
+ base64,
20667
+ sizeBytes: data.byteLength,
20668
+ timestamp
20669
+ });
20670
+ return [
20671
+ file("keyFrameSmall"),
20672
+ file("keyFrame"),
20673
+ file("fullFrame")
20674
+ ];
20675
+ }
20676
+ /** Reject if `promise` does not settle within `ms`. */
20677
+ function withTimeout$4(promise, ms) {
20678
+ return new Promise((resolve, reject) => {
20679
+ const timer = setTimeout(() => {
20680
+ reject(/* @__PURE__ */ new Error(`snapshot cap timed out after ${String(ms)}ms`));
20681
+ }, ms);
20682
+ promise.then((value) => {
20683
+ clearTimeout(timer);
20684
+ resolve(value);
20685
+ }, (err) => {
20686
+ clearTimeout(timer);
20687
+ reject(err instanceof Error ? err : new Error(String(err)));
20688
+ });
20689
+ });
20690
+ }
20691
+ /** The canvas behind a tile whose aspect does not fill its cell. */
20692
+ var MOSAIC_BACKGROUND = {
20693
+ r: 17,
20694
+ g: 17,
20695
+ b: 17
20696
+ };
20697
+ /** Tile aspect — cameras are 16:9 and a square cell would letterbox every one. */
20698
+ var TILE_ASPECT = 9 / 16;
20699
+ /** Height of the per-tile label strip, as a fraction of the tile height. */
20700
+ var LABEL_BAND_FRACTION = .14;
20701
+ var LABEL_MIN_PX = 11;
20702
+ /** Height of the mosaic's own title band, as a fraction of a tile's height. */
20703
+ var TITLE_BAND_FRACTION = .22;
20704
+ var TITLE_MIN_PX = 16;
20705
+ /**
20706
+ * Average glyph width as a fraction of the font size, for bold sans-serif.
20707
+ *
20708
+ * An ESTIMATE, and it has to be: librsvg lays the text out long after this
20709
+ * string is written, so there are no metrics to measure against. The same
20710
+ * number and the same reasoning as `preview-frame.ts#fitTitle` — deliberately
20711
+ * GENEROUS, because a label that shrank slightly more than it had to is
20712
+ * invisible and one that overflowed runs into the tile beside it. The first
20713
+ * live mosaic did exactly that: `Videocamera cortile · 07:49 · animal` was cut
20714
+ * to `…· anima` at the tile edge, mid-word.
20715
+ */
20716
+ var GLYPH_ADVANCE = .56;
20717
+ /** Fraction of the band a label may occupy, leaving the padding either side. */
20718
+ var LABEL_USABLE_FRACTION = .94;
20719
+ /** `<`, `>`, `&`, `'`, `"` in a camera name or an operator caption would each
20720
+ * turn the overlay into a document librsvg refuses. */
20721
+ function escapeXml$1(s) {
20722
+ return s.replace(/[<>&'"]/g, (ch) => {
20723
+ switch (ch) {
20724
+ case "<": return "&lt;";
20725
+ case ">": return "&gt;";
20726
+ case "&": return "&amp;";
20727
+ case "'": return "&apos;";
20728
+ default: return "&quot;";
20729
+ }
20730
+ });
20731
+ }
20732
+ /**
20733
+ * The single decision about drawn text — and the home of the DEFAULT.
20734
+ *
20735
+ * `undefined` means `none`, not `all`: a rule stored before this option existed
20736
+ * has no `captions` key, and the operator's request is that such a rule deliver
20737
+ * pictures. The schema materialises the same default on read
20738
+ * (`NcSummaryRuleInputSchema`), so this arm only fires for a caller that never
20739
+ * had a rule — the test bridge and the spec. Both agreeing is the point.
20740
+ */
20741
+ function mosaicTextPlan(captions) {
20742
+ if (captions === "all") return {
20743
+ tileLabels: true,
20744
+ titleBand: true
20745
+ };
20746
+ if (captions === "tiles") return {
20747
+ tileLabels: true,
20748
+ titleBand: false
20749
+ };
20750
+ return {
20751
+ tileLabels: false,
20752
+ titleBand: false
20753
+ };
20754
+ }
20755
+ /** Grid shape for `n` tiles: as square as possible, rows never exceeding cols
20756
+ * by more than one — a 3×3 for nine, a 3×2 for five, never a 1×9 strip.
20757
+ *
20758
+ * `rows` is the load-bearing half — it is how many rows {@link mosaicRowCounts}
20759
+ * distributes into. `cols` describes the WIDEST row, and is no longer a cell
20760
+ * count: see {@link mosaicRowCounts} for why a `cols × rows` grid was the bug. */
20761
+ function mosaicGrid(n) {
20762
+ const count = Math.max(1, Math.floor(n));
20763
+ const cols = Math.ceil(Math.sqrt(count));
20764
+ return {
20765
+ cols,
20766
+ rows: Math.ceil(count / cols)
20767
+ };
20768
+ }
20769
+ /**
20770
+ * How many tiles each ROW carries, top to bottom — and the reason the mosaic
20771
+ * can no longer have a hole in it.
20772
+ *
20773
+ * The layout used to be a `cols × rows` GRID, and a grid holds more cells than
20774
+ * tiles for every count that is not a perfect fit: 3 into a 2×2, 5 into a 3×2,
20775
+ * 7 and 8 into a 3×3. The composite only ever wrote the tiles, so the spare
20776
+ * cells stayed the canvas — `MOSAIC_BACKGROUND`, rgb 17 — and the operator's
20777
+ * three-tile digest was a quarter black. Measured on the shipped renderer:
20778
+ * 24.8% of a three-tile mosaic was background. His words: *"se ci sono 3
20779
+ * elementi non dovrebbe renderizzare buchi neri"*.
20780
+ *
20781
+ * Counting ROWS instead removes the failure by construction rather than by
20782
+ * patching it. Every row spans the full width and divides it among the tiles it
20783
+ * actually has, so a row is full whatever the remainder and there is no such
20784
+ * thing as a spare cell. The shapes fall out of it, and they are the ones asked
20785
+ * for: 1 ⇒ full frame, 2 ⇒ side by side, 3 ⇒ one big over two small, 4 ⇒ 2×2.
20786
+ *
20787
+ * **The fewer-tiled row comes FIRST.** Tiles arrive in the selection's order,
20788
+ * so row 0 is the head of the ranking; putting the remainder at the top would
20789
+ * make the WORST tile the big one.
20790
+ *
20791
+ * Every cell keeps {@link TILE_ASPECT}, so rows differ in height — which is
20792
+ * also what stops the letterbox: a full-width hero tile is 16:9 exactly, not a
20793
+ * 16:9 picture centred in a double-height cell with bars over and under it.
20794
+ */
20795
+ function mosaicRowCounts(n) {
20796
+ const count = Math.max(1, Math.floor(n));
20797
+ const { rows } = mosaicGrid(count);
20798
+ const base = Math.floor(count / rows);
20799
+ const remainder = count % rows;
20800
+ const counts = [];
20801
+ for (let row = 0; row < rows; row += 1) counts.push(row < rows - remainder ? base : base + 1);
20802
+ return counts;
20803
+ }
20804
+ /**
20805
+ * Where every tile lands. Pure — no bytes, no sharp, no clock.
20806
+ *
20807
+ * Exported because the rectangles are DATA the artefact should carry: a viewer
20808
+ * that wants a tappable mosaic reads these rather than re-deriving a layout
20809
+ * that could drift from the one actually rendered.
20810
+ *
20811
+ * The rectangles COVER the picture area exactly — that is the property the
20812
+ * spec asserts, and it is the one the old grid broke.
20813
+ */
20814
+ function mosaicLayout(input) {
20815
+ const rowCounts = mosaicRowCounts(input.tileCount);
20816
+ const width = Math.max(1, Math.floor(input.maxWidth ?? 1920));
20817
+ const firstRowCount = rowCounts[0] ?? 1;
20818
+ const tileWidth = Math.floor(width / firstRowCount);
20819
+ const tileHeight = Math.round(tileWidth * TILE_ASPECT);
20820
+ const titleHeight = input.withTitle === true ? Math.round(tileHeight * TITLE_BAND_FRACTION) : 0;
20821
+ const tiles = [];
20822
+ let y = titleHeight;
20823
+ let index = 0;
20824
+ for (const count of rowCounts) {
20825
+ const nominalWidth = Math.floor(width / count);
20826
+ const rowHeight = Math.round(nominalWidth * TILE_ASPECT);
20827
+ let x = 0;
20828
+ for (let inRow = 0; inRow < count; inRow += 1) {
20829
+ const w = inRow === count - 1 ? width - x : nominalWidth;
20830
+ tiles.push({
20831
+ index,
20832
+ x,
20833
+ y,
20834
+ w,
20835
+ h: rowHeight
20836
+ });
20837
+ x += w;
20838
+ index += 1;
20839
+ }
20840
+ y += rowHeight;
20841
+ }
20842
+ return {
20843
+ width,
20844
+ height: y,
20845
+ cols: Math.max(...rowCounts),
20846
+ rows: rowCounts.length,
20847
+ rowCounts,
20848
+ tileWidth,
20849
+ tileHeight,
20850
+ titleHeight,
20851
+ tiles
20852
+ };
20853
+ }
20854
+ /**
20855
+ * Shrink, then ELIDE, until the line fits `usablePx`.
20856
+ *
20857
+ * Shrinking is tried first because a slightly smaller label still says
20858
+ * everything; only past `minPx` is the tail cut, and then with an ellipsis so
20859
+ * the reader can SEE that something was cut. The failure this replaces was
20860
+ * silent: SVG simply clips at the viewport, so an overlong label lost its last
20861
+ * word mid-glyph and looked like a rendering bug rather than a truncation.
20862
+ *
20863
+ * Pure and exported so the arithmetic is testable without rendering a pixel.
20864
+ */
20865
+ function fitLine(text, usablePx, basePx, minPx) {
20866
+ const flat = text.trim().replace(/\s+/g, " ");
20867
+ const fits = (s, fontPx) => s.length * fontPx * GLYPH_ADVANCE <= usablePx;
20868
+ let fontPx = Math.max(minPx, Math.round(basePx));
20869
+ while (fontPx > minPx && !fits(flat, fontPx)) fontPx = Math.max(minPx, fontPx - 1);
20870
+ if (fits(flat, fontPx)) return {
20871
+ text: flat,
20872
+ fontPx
20873
+ };
20874
+ const budget = Math.max(1, Math.floor(usablePx / (fontPx * GLYPH_ADVANCE)));
20875
+ return {
20876
+ text: `${flat.slice(0, Math.max(1, budget - 1)).trimEnd()}…`,
20877
+ fontPx
20878
+ };
20879
+ }
20880
+ /** The label strip drawn across the bottom of one tile. */
20881
+ function tileLabelSvg(width, height, label) {
20882
+ const bandHeight = Math.max(18, Math.round(height * LABEL_BAND_FRACTION));
20883
+ const basePx = Math.round(bandHeight * .62);
20884
+ const padding = Math.round(basePx * .6);
20885
+ const fitted = fitLine(label, width * LABEL_USABLE_FRACTION - padding, basePx, LABEL_MIN_PX);
20886
+ const baseline = height - Math.round((bandHeight - fitted.fontPx) / 2) - Math.round(fitted.fontPx * .18);
20887
+ return Buffer.from(`<svg width="${width}" height="${height}" xmlns="http://www.w3.org/2000/svg">
20888
+ <rect x="0" y="${height - bandHeight}" width="${width}" height="${bandHeight}" fill="black" fill-opacity="0.55"/>
20889
+ <text x="${padding}" y="${baseline}" font-size="${fitted.fontPx}" font-family="sans-serif" font-weight="bold" fill="white">${escapeXml$1(fitted.text)}</text>
20890
+ </svg>`);
20891
+ }
20892
+ /** The mosaic's own title band. */
20893
+ function titleSvg(width, height, caption) {
20894
+ const fitted = fitLine(caption, width * LABEL_USABLE_FRACTION, Math.round(height * .5), TITLE_MIN_PX);
20895
+ const baseline = Math.round(height / 2 + fitted.fontPx * .36);
20896
+ return Buffer.from(`<svg width="${width}" height="${height}" xmlns="http://www.w3.org/2000/svg">
20897
+ <rect x="0" y="0" width="${width}" height="${height}" fill="#111111"/>
20898
+ <text x="${Math.round(width / 2)}" y="${baseline}" font-size="${fitted.fontPx}" font-family="sans-serif" font-weight="bold" text-anchor="middle" fill="white">${escapeXml$1(fitted.text)}</text>
20899
+ </svg>`);
20900
+ }
20901
+ /**
20902
+ * Compose the mosaic.
20903
+ *
20904
+ * Every tile is fitted with `contain` onto the cell background rather than
20905
+ * cropped: a `cover` fit would silently cut the subject out of a portrait crop,
20906
+ * and a tile that does not show what it is about is worse than a letterboxed
20907
+ * one.
20908
+ *
20909
+ * A tile sharp cannot read is DROPPED and counted, never composited as a blank
20910
+ * cell — a black square in a digest reads as "the camera saw nothing", which is
20911
+ * the opposite of what happened. The layout is computed from the SURVIVORS, and
20912
+ * because {@link mosaicRowCounts} counts rows rather than grid cells there is no
20913
+ * remainder to leave empty either.
20914
+ *
20915
+ * @throws when every tile was dropped, and when sharp cannot produce the final
20916
+ * canvas at all; the caller treats both as "no mosaic" and still
20917
+ * delivers the counts.
20918
+ */
20919
+ async function renderMosaic(request, logger) {
20920
+ const plan = mosaicTextPlan(request.captions);
20921
+ const usable = [];
20922
+ for (const tile of request.tiles) try {
20923
+ await sharp(tile.jpeg).metadata();
20924
+ usable.push(tile);
20925
+ } catch (err) {
20926
+ logger.warn(`${request.purpose} mosaic: a tile could not be decoded and was dropped`, {
20927
+ tags: { deviceId: tile.deviceId },
20928
+ meta: {
20929
+ ...request.purpose === "summary" ? { trackId: tile.sourceId } : { sourceId: tile.sourceId },
20930
+ error: String(err)
20931
+ }
20932
+ });
20933
+ }
20934
+ const droppedTiles = request.tiles.length - usable.length;
20935
+ if (usable.length === 0) throw new Error(`${request.purpose} mosaic: no tile could be decoded (${request.tiles.length} offered)`);
20936
+ const caption = plan.titleBand ? request.caption?.trim() ?? "" : "";
20937
+ const layout = mosaicLayout({
20938
+ tileCount: usable.length,
20939
+ ...request.maxWidth !== void 0 ? { maxWidth: request.maxWidth } : {},
20940
+ withTitle: caption.length > 0
20941
+ });
20942
+ const composites = await Promise.all(usable.map(async (tile, index) => {
20943
+ const rect = layout.tiles[index];
20944
+ if (rect === void 0) throw new Error(`${request.purpose} mosaic: layout lost a tile`);
20945
+ const label = plan.tileLabels ? tile.label?.trim() ?? "" : "";
20946
+ const fitted = sharp(tile.jpeg).resize({
20947
+ width: rect.w,
20948
+ height: rect.h,
20949
+ fit: "contain",
20950
+ background: MOSAIC_BACKGROUND
20951
+ });
20952
+ return {
20953
+ input: await (label.length === 0 ? fitted : fitted.composite([{
20954
+ input: tileLabelSvg(rect.w, rect.h, label),
20955
+ top: 0,
20956
+ left: 0
20957
+ }])).jpeg({ quality: 82 }).toBuffer(),
20958
+ top: rect.y,
20959
+ left: rect.x
20960
+ };
20961
+ }));
20962
+ if (caption.length > 0 && layout.titleHeight > 0) composites.unshift({
20963
+ input: titleSvg(layout.width, layout.titleHeight, caption),
20964
+ top: 0,
20965
+ left: 0
20966
+ });
20967
+ return {
20968
+ jpeg: await sharp({ create: {
20969
+ width: layout.width,
20970
+ height: layout.height,
20971
+ channels: 3,
20972
+ background: MOSAIC_BACKGROUND
20973
+ } }).composite(composites).jpeg({ quality: 82 }).toBuffer(),
20974
+ layout,
20975
+ droppedTiles
20976
+ };
20977
+ }
20978
+ //#endregion
20979
+ //#region src/notification-center/linked-stills.ts
20980
+ /**
20981
+ * The linked-camera shelf — the photographs a SENSOR notification carries of
20982
+ * every camera that links the sensor (ADR 0632).
20983
+ *
20984
+ * Same three properties as the still shelf (`still-shelf.ts`), for the same
20985
+ * reasons: not a record (RAM, TTL, no timeline track), the capture starts at the
20986
+ * trigger and is never awaited (owner ids minted synchronously), and nothing is
20987
+ * silent (one line per camera with its outcome, one per event on the sensor).
20988
+ * A separate module because the unit is a SET: exclusion, freshness and the
20989
+ * one composition are decisions about the set, and none of them belongs in a
20990
+ * shelf that photographs one camera for an absence.
20991
+ *
20992
+ * Two reads can only ever ADD a camera back, never take one away on their own
20993
+ * failure (D49): a wrapper-state read (run in parallel with the capture) that
20994
+ * misses or throws keeps the tile, and a freshness read that misses or throws
20995
+ * keeps every tile. Only a read that ANSWERED
20996
+ * `sleeping` / `disabled` / … or a capture time older than
20997
+ * {@link SENSOR_LINKED_MAX_FRAME_AGE_MS} removes a tile — and says so, on that
20998
+ * camera.
20999
+ */
21000
+ var NC_LINKED_MOSAIC_PREFIX = "nc-mosaic:";
21001
+ var NC_LINKED_CAMERA_PREFIX = "nc-linked:";
21002
+ function isLinkedStillId(id) {
21003
+ return id.startsWith("nc-mosaic:") || id.startsWith("nc-linked:");
21004
+ }
21005
+ /** Per camera — the marker's own bound (`SENSOR_MARKER_SNAPSHOT_TIMEOUT_MS`). */
21006
+ var SENSOR_LINKED_CAPTURE_TIMEOUT_MS = 2500;
21007
+ /** The capture-free wrapper-state read, per camera. It runs IN PARALLEL with
21008
+ * the capture and under the capture's own bound, so it costs no extra time and
21009
+ * a slow-but-honest answer is still heard: a blocking reason EXCLUDES the tile
21010
+ * even when the capture returned bytes (they are a state card, not a
21011
+ * photograph). A read that misses the bound or fails is UNKNOWN and keeps the
21012
+ * tile (D49). */
21013
+ var SENSOR_LINKED_STATE_READ_TIMEOUT_MS = SENSOR_LINKED_CAPTURE_TIMEOUT_MS;
21014
+ Math.max(SENSOR_LINKED_STATE_READ_TIMEOUT_MS, SENSOR_LINKED_CAPTURE_TIMEOUT_MS) + 400;
21015
+ /** Hard bound on sets held at once, on top of the TTL. */
21016
+ var MAX_SETS = 32;
21017
+ function isLandedTile(r) {
21018
+ return r.outcome === "landed" && r.jpeg !== void 0;
21019
+ }
21020
+ var LinkedReadTimeout = class extends Error {
21021
+ constructor(what, ms) {
21022
+ super(`${what} timed out after ${String(ms)}ms`);
21023
+ this.name = "LinkedReadTimeout";
21024
+ }
21025
+ };
21026
+ var NcLinkedStillShelf = class {
21027
+ deps;
21028
+ sets = /* @__PURE__ */ new Map();
21029
+ owners = /* @__PURE__ */ new Map();
21030
+ constructor(deps) {
21031
+ this.deps = deps;
21032
+ }
21033
+ /** SYNCHRONOUS: the evaluation that follows names these owners. */
21034
+ captureSet(sensorId, cameraIds, atMs) {
21035
+ const now = this.deps.now();
21036
+ this.prune(now);
21037
+ const setId = randomUUID();
21038
+ const stills = {
21039
+ sensorId,
21040
+ cameraIds: [...cameraIds],
21041
+ mosaicOwnerId: `${NC_LINKED_MOSAIC_PREFIX}${randomUUID()}`,
21042
+ cameraOwnerIds: cameraIds.map(() => `${NC_LINKED_CAMERA_PREFIX}${randomUUID()}`)
21043
+ };
21044
+ this.sets.set(setId, {
21045
+ stills,
21046
+ expiresAt: now + NC_STILL_SHELF_TTL_MS,
21047
+ atMs,
21048
+ tiles: /* @__PURE__ */ new Map(),
21049
+ mosaic: []
21050
+ });
21051
+ this.owners.set(stills.mosaicOwnerId, {
21052
+ setId,
21053
+ camera: "mosaic"
21054
+ });
21055
+ stills.cameraOwnerIds.forEach((ownerId, i) => {
21056
+ const cameraId = cameraIds[i];
21057
+ if (cameraId !== void 0) this.owners.set(ownerId, {
21058
+ setId,
21059
+ camera: cameraId
21060
+ });
21061
+ });
21062
+ this.run(setId, stills, atMs, now).catch((err) => {
21063
+ this.deps.logger.warn("sensor notification pictures failed — it ships text-only", {
21064
+ tags: { deviceId: sensorId },
21065
+ meta: { error: String(err) }
21066
+ });
21067
+ });
21068
+ return stills;
21069
+ }
21070
+ /** `undefined` = not an owner this shelf minted (or pruned); `[]` = nothing (yet). */
21071
+ get(ownerId) {
21072
+ const ref = this.owners.get(ownerId);
21073
+ if (ref === void 0) return void 0;
21074
+ const held = this.sets.get(ref.setId);
21075
+ if (held === void 0) return [];
21076
+ if (ref.camera === "mosaic") return held.mosaic;
21077
+ return held.tiles.get(ref.camera) ?? [];
21078
+ }
21079
+ prune(now) {
21080
+ for (const [setId, held] of this.sets) if (held.expiresAt <= now) this.sets.delete(setId);
21081
+ while (this.sets.size > MAX_SETS) {
21082
+ const oldest = this.sets.keys().next();
21083
+ if (oldest.done === true) break;
21084
+ this.sets.delete(oldest.value);
21085
+ }
21086
+ for (const [ownerId, ref] of this.owners) if (!this.sets.has(ref.setId)) this.owners.delete(ownerId);
21087
+ }
21088
+ clear() {
21089
+ this.sets.clear();
21090
+ this.owners.clear();
21091
+ }
21092
+ async run(setId, stills, atMs, startedAt) {
21093
+ const results = await Promise.all(stills.cameraIds.map((id) => this.captureOne(id, startedAt)));
21094
+ const judged = await this.judgeFreshness(stills.sensorId, results, atMs);
21095
+ for (const r of judged) this.logTile(stills.sensorId, r);
21096
+ const landed = judged.filter(isLandedTile);
21097
+ const composed = await this.compose(stills.sensorId, landed, atMs);
21098
+ const undrawable = landed.length - composed.drawn;
21099
+ this.deps.logger.info(composed.mosaic.length === 0 ? "sensor notification has no linked picture — it ships text-only" : "sensor notification pictures ready", {
21100
+ tags: { deviceId: stills.sensorId },
21101
+ meta: {
21102
+ cameras: stills.cameraIds.length,
21103
+ landed: composed.drawn,
21104
+ ...undrawable > 0 ? { undrawable } : {},
21105
+ dropped: Object.fromEntries(judged.filter((r) => r.outcome !== "landed").map((r) => [String(r.cameraId), r.outcome])),
21106
+ tookMs: this.deps.now() - startedAt
21107
+ }
21108
+ });
21109
+ const held = this.sets.get(setId);
21110
+ if (held === void 0) return;
21111
+ this.sets.set(setId, {
21112
+ ...held,
21113
+ tiles: new Map(composed.servable.map((r) => [r.cameraId, stillShelfMedia(r.jpeg, atMs)])),
21114
+ mosaic: composed.mosaic
21115
+ });
21116
+ }
21117
+ async captureOne(cameraId, startedAt) {
21118
+ const took = () => this.deps.now() - startedAt;
21119
+ if (this.deps.isOnline(cameraId) === false) return {
21120
+ cameraId,
21121
+ outcome: "excluded",
21122
+ reason: "offline",
21123
+ tookMs: took()
21124
+ };
21125
+ const [verdict, capture] = await Promise.all([this.wrapperVerdict(cameraId), this.captureStill(cameraId)]);
21126
+ if (verdict.reason !== null) return {
21127
+ cameraId,
21128
+ outcome: "excluded",
21129
+ reason: verdict.reason,
21130
+ tookMs: took()
21131
+ };
21132
+ return {
21133
+ cameraId,
21134
+ ...capture,
21135
+ ...verdict.unknown !== null ? { stateUnknown: verdict.unknown } : {},
21136
+ tookMs: took()
21137
+ };
21138
+ }
21139
+ async captureStill(cameraId) {
21140
+ try {
21141
+ const shot = await withTimeout$3(this.deps.getSnapshot(cameraId), SENSOR_LINKED_CAPTURE_TIMEOUT_MS, "snapshot cap");
21142
+ if (shot === null || shot.base64.length === 0) return { outcome: "empty" };
21143
+ const jpeg = await this.decodableStill(Buffer.from(shot.base64, "base64"));
21144
+ if (jpeg === null) return {
21145
+ outcome: "error",
21146
+ reason: "undecodable"
21147
+ };
21148
+ return {
21149
+ outcome: "landed",
21150
+ jpeg
21151
+ };
21152
+ } catch (err) {
21153
+ return {
21154
+ outcome: err instanceof LinkedReadTimeout ? "timeout" : "error",
21155
+ reason: String(err)
21156
+ };
21157
+ }
21158
+ }
21159
+ /**
21160
+ * The downscaled still, or null when the bytes are not a picture. A resize
21161
+ * that fails on bytes sharp can still READ keeps them raw (a worse picture,
21162
+ * never a missing one); bytes it cannot read never ship — a decode failure is
21163
+ * a fact about the bytes, not a failed read.
21164
+ */
21165
+ async decodableStill(raw) {
21166
+ try {
21167
+ return await downscaleFullFrameJpeg(raw, 960, 540);
21168
+ } catch {
21169
+ try {
21170
+ await sharp(raw).metadata();
21171
+ return raw;
21172
+ } catch {
21173
+ return null;
21174
+ }
21175
+ }
21176
+ }
21177
+ /** A failed, slow or empty read is UNKNOWN and never excludes (D49); the
21178
+ * camera's own outcome line then carries `stateUnknown` with the cause. */
21179
+ async wrapperVerdict(cameraId) {
21180
+ const read = this.deps.getSnapshotState;
21181
+ if (read === void 0) return {
21182
+ reason: null,
21183
+ unknown: null
21184
+ };
21185
+ try {
21186
+ const state = await withTimeout$3(read(cameraId), SENSOR_LINKED_STATE_READ_TIMEOUT_MS, "snapshot state read");
21187
+ return state === void 0 ? {
21188
+ reason: null,
21189
+ unknown: "empty"
21190
+ } : {
21191
+ reason: state.reason,
21192
+ unknown: null
21193
+ };
21194
+ } catch (err) {
21195
+ return {
21196
+ reason: null,
21197
+ unknown: err instanceof LinkedReadTimeout ? "timeout" : "error"
21198
+ };
21199
+ }
21200
+ }
21201
+ async judgeFreshness(sensorId, results, atMs) {
21202
+ const read = this.deps.getFrameCapturedAt;
21203
+ const landedIds = results.filter((r) => r.outcome === "landed").map((r) => r.cameraId);
21204
+ if (read === void 0 || landedIds.length === 0) return results;
21205
+ let capturedAt;
21206
+ try {
21207
+ capturedAt = await withTimeout$3(read(landedIds), 400, "frame freshness read");
21208
+ } catch (err) {
21209
+ this.deps.logger.warn("linked pictures freshness unverified — keeping every tile", {
21210
+ tags: { deviceId: sensorId },
21211
+ meta: { error: String(err) }
21212
+ });
21213
+ return results;
21214
+ }
21215
+ return results.map((r) => {
21216
+ if (r.outcome !== "landed") return r;
21217
+ const at = capturedAt.get(r.cameraId);
21218
+ if (at === void 0 || at === null) return r;
21219
+ if (at >= atMs - 1e4) return r;
21220
+ return {
21221
+ cameraId: r.cameraId,
21222
+ outcome: "stale",
21223
+ reason: `frame ${String(atMs - at)} ms older than the event`,
21224
+ tookMs: r.tookMs
21225
+ };
21226
+ });
21227
+ }
21228
+ async compose(sensorId, landed, atMs) {
21229
+ const first = landed[0];
21230
+ if (first === void 0) return {
21231
+ mosaic: [],
21232
+ servable: [],
21233
+ drawn: 0
21234
+ };
21235
+ if (landed.length === 1) return {
21236
+ mosaic: stillShelfMedia(first.jpeg, atMs),
21237
+ servable: landed,
21238
+ drawn: 1
21239
+ };
21240
+ try {
21241
+ const { jpeg, droppedTiles } = await renderMosaic({
21242
+ purpose: "sensor",
21243
+ tiles: landed.map((r) => ({
21244
+ jpeg: r.jpeg,
21245
+ deviceId: r.cameraId,
21246
+ sourceId: String(r.cameraId)
21247
+ }))
21248
+ }, this.deps.logger);
21249
+ return {
21250
+ mosaic: stillShelfMedia(jpeg, atMs),
21251
+ servable: landed,
21252
+ drawn: landed.length - droppedTiles
21253
+ };
21254
+ } catch (err) {
21255
+ this.deps.logger.warn("sensor mosaic could not be composed — shipping the first camera", {
21256
+ tags: { deviceId: sensorId },
21257
+ meta: {
21258
+ error: String(err),
21259
+ offered: landed.length,
21260
+ shipped: first.cameraId
21261
+ }
21262
+ });
21263
+ return {
21264
+ mosaic: stillShelfMedia(first.jpeg, atMs),
21265
+ servable: landed,
21266
+ drawn: 1
21267
+ };
21268
+ }
21269
+ }
21270
+ logTile(sensorId, r) {
21271
+ const extras = {
21272
+ tags: { deviceId: r.cameraId },
21273
+ meta: {
21274
+ sensorId,
21275
+ outcome: r.outcome,
21276
+ tookMs: r.tookMs,
21277
+ ...r.reason !== void 0 ? { reason: r.reason } : {},
21278
+ ...r.stateUnknown !== void 0 ? { stateUnknown: r.stateUnknown } : {}
21279
+ }
21280
+ };
21281
+ if (r.outcome === "excluded") this.deps.logger.info("linked camera excluded from the sensor notification", extras);
21282
+ else if (r.outcome === "landed" && r.stateUnknown !== void 0) this.deps.logger.info("linked camera photographed for the sensor notification — state unknown, tile kept", extras);
21283
+ else if (r.outcome === "landed") this.deps.logger.debug("linked camera photographed for the sensor notification", extras);
21284
+ else this.deps.logger.warn("linked camera gave no usable picture for the sensor notification", extras);
21285
+ }
21286
+ };
21287
+ /** Reject with {@link LinkedReadTimeout} if `promise` does not settle within `ms`. */
21288
+ function withTimeout$3(promise, ms, what) {
21289
+ return new Promise((resolve, reject) => {
21290
+ const timer = setTimeout(() => reject(new LinkedReadTimeout(what, ms)), ms);
21291
+ promise.then((value) => {
21292
+ clearTimeout(timer);
21293
+ resolve(value);
21294
+ }, (err) => {
21295
+ clearTimeout(timer);
21296
+ reject(err instanceof Error ? err : new Error(String(err)));
21297
+ });
21298
+ });
21299
+ }
21300
+ //#endregion
20112
21301
  //#region src/notification-center/liveness-ledger.ts
20113
21302
  /**
20114
21303
  * @durable class=ledger owner=notification-center
@@ -22948,279 +24137,6 @@ var NcSnoozeStore = class {
22948
24137
  return parsed.success ? parsed.data : null;
22949
24138
  }
22950
24139
  };
22951
- /** JPEG quality for the downscaled full frame — matches the crop path. */
22952
- var FULL_FRAME_QUALITY = 80;
22953
- /**
22954
- * Downscale an already-encoded JPEG full frame to FIT WITHIN
22955
- * {@link FULL_FRAME_MAX_WIDTH}×{@link FULL_FRAME_MAX_HEIGHT}, preserving aspect
22956
- * ratio (`fit: 'inside'`) and never enlarging a source already smaller than the
22957
- * box. Re-encodes as JPEG. Used before persisting a synthetic sensor/control
22958
- * track's whole-scene snapshot so a raw native-resolution frame (a 4K bedroom
22959
- * at night) is never stored or served — the privacy fix moved to CAPTURE time.
22960
- */
22961
- async function downscaleFullFrameJpeg(jpeg, maxWidth = 640, maxHeight = 360) {
22962
- return sharp(Buffer.from(jpeg)).resize(maxWidth, maxHeight, {
22963
- fit: "inside",
22964
- withoutEnlargement: true
22965
- }).jpeg({ quality: FULL_FRAME_QUALITY }).toBuffer();
22966
- }
22967
- //#endregion
22968
- //#region src/notification-center/still-shelf.ts
22969
- /**
22970
- * The still shelf — the PHOTOGRAPH a notification carries when nothing it can
22971
- * name owns a frame.
22972
- *
22973
- * ## Why a trigger needs one at all
22974
- *
22975
- * The attachment ladder resolves media by OWNER, and most triggers have one: an
22976
- * object or package event owns its crops, a closed track owns its best shot, a
22977
- * doorbell press owns the marker track the same press projected
22978
- * (`sensor-marker-projector.ts`), an occupancy edge names one of the objects it
22979
- * counted (`chooseOccupancyMediaOwner`). Two triggers own nothing, and for the
22980
- * same reason in both cases — the subject is an ABSENCE:
22981
- *
22982
- * - an **audio** match: nothing was boxed, nothing was tracked, and —
22983
- * deliberately — nothing is persisted at all. A confirmed window is a claim
22984
- * about sound that has already stopped, and `event-intake.ts` states why
22985
- * replaying it later would be wrong.
22986
- * - an **occupancy** edge whose scope is EMPTY — "posto libero". The vehicle
22987
- * whose departure IS the news has left, so `chooseOccupancyMediaOwner` names
22988
- * nobody and the ladder logs `no still could be resolved … owners=[]`.
22989
- *
22990
- * So the only honest picture is a PHOTOGRAPH of the camera taken at the moment
22991
- * of the trigger. Not of the sound, not of the object that left — of what the
22992
- * camera can see now that it happened. For a freed parking space that is
22993
- * exactly the answer the operator wants: the space, empty.
22994
- *
22995
- * **One shelf, not two.** The mechanism is identical down to the reuse window,
22996
- * and the only thing that differs between the two triggers is the GATE deciding
22997
- * that a photograph is owed at all — which belongs at the trigger site, where
22998
- * the rules are, and not here. A twin module would be a second derivation of
22999
- * one thing. The trigger rides along as {@link NcStillTrigger} only so the logs
23000
- * can say which absence they are about.
23001
- *
23002
- * ## Three properties, and each one is a decision
23003
- *
23004
- * **It is not a record.** The bytes live here, in RAM, under an owner id and a
23005
- * TTL that covers the outbox's whole retry horizon — and nowhere else. The
23006
- * alternative was the doorbell's: materialise a synthetic marker track through
23007
- * `SyntheticTrackMaterializer` and let the notification name it. That would put
23008
- * a durable Track on the camera's timeline for every confirmed window and every
23009
- * emptied zone, feeding the digest's `listTracks`, retention, and the audio-
23010
- * marker feature's own operator ceilings (`audio-marker-projector.ts` exists
23011
- * precisely to bound how many audio markers a camera may emit). A notification
23012
- * must not manufacture timeline history as a side effect of wanting a picture.
23013
- *
23014
- * **The capture STARTS immediately and is never awaited.** The subject is
23015
- * transient — a scream is over before a snapshot round-trip completes, and a
23016
- * freed space is about to be taken by the next car — so the fetch is kicked off
23017
- * at the trigger, before the rule evaluation runs, and the owner id is minted
23018
- * synchronously so the outbox row can name it. The bytes land while the row
23019
- * waits in the queue, and the dispatcher's existing bounded still-wait (or the
23020
- * pause its own footage render already costs) picks them up. A camera that
23021
- * never answers costs the picture and never the notification.
23022
- *
23023
- * **Two triggers seconds apart share ONE capture.** A barking dog confirms
23024
- * repeatedly and a label-mode rule has no re-arm timer at all (D157) — the
23025
- * rule's cooldown is its only brake, and the cooldown is applied AFTER this.
23026
- * Without a reuse window this would photograph a camera at whatever rate the
23027
- * sound happens to occur. Each trigger still gets its OWN owner id, so two
23028
- * outbox rows are never mistaken for one subject; they merely point at the same
23029
- * frame, which is the truth — the scene did not change in ten seconds. The
23030
- * window is per CAMERA and trigger-agnostic for the same reason: a sound and an
23031
- * emptied zone ten seconds apart are two claims about one scene.
23032
- *
23033
- * Nothing here is silent: a capture that lands and a camera that refuses each
23034
- * emit one line carrying `tags: { deviceId }`, because "why did 617 get a photo
23035
- * and 615 not" is the only form that question is ever asked in.
23036
- */
23037
- /**
23038
- * The owner-id namespace. It is what routes a lookup here instead of to the
23039
- * media store, and it is the reason the dispatcher needs no audio or occupancy
23040
- * branch — `getMediaForOwner` answers for all of them under one signature.
23041
- */
23042
- var NC_STILL_SHELF_PREFIX = "nc-still:";
23043
- /** True for an owner id this shelf minted. */
23044
- function isStillShelfId(id) {
23045
- return id.startsWith(NC_STILL_SHELF_PREFIX);
23046
- }
23047
- /**
23048
- * How long the bytes are held.
23049
- *
23050
- * The outbox retries 8 times with a 5 s → 300 s backoff, which tops out around
23051
- * ten minutes; fifteen covers that with room for a slow drain. Past it the row
23052
- * ships text-only, which is the correct degradation for a photograph of a scene
23053
- * that is a quarter of an hour stale anyway.
23054
- */
23055
- var NC_STILL_SHELF_TTL_MS = 15 * 6e4;
23056
- /** A hanging snapshot cap must not pin a capture slot forever. */
23057
- var SNAPSHOT_TIMEOUT_MS = 8e3;
23058
- /**
23059
- * Hard bound on held captures. Reached only if every camera on the hub triggers
23060
- * one inside a single TTL; the oldest is dropped first, which costs a
23061
- * fifteen-minute-old picture nobody is waiting for.
23062
- */
23063
- var MAX_CAPTURES = 64;
23064
- var NcStillShelf = class {
23065
- deps;
23066
- /** captureId → the photograph. */
23067
- captures = /* @__PURE__ */ new Map();
23068
- /** ownerId → captureId. Several owners may name one capture (the reuse window). */
23069
- owners = /* @__PURE__ */ new Map();
23070
- /** deviceId → its newest capture, for the reuse window. */
23071
- newest = /* @__PURE__ */ new Map();
23072
- constructor(deps) {
23073
- this.deps = deps;
23074
- }
23075
- /**
23076
- * Photograph `deviceId` for a trigger at `atMs`, and return the owner id the
23077
- * subject should name. SYNCHRONOUS by contract: the caller is on the trigger
23078
- * path and the outbox row is built from what this returns.
23079
- */
23080
- capture(deviceId, atMs, trigger) {
23081
- const now = this.deps.now();
23082
- this.prune(now);
23083
- const ownerId = `${NC_STILL_SHELF_PREFIX}${randomUUID()}`;
23084
- const recent = this.newest.get(deviceId);
23085
- if (recent !== void 0 && now - recent.at < 1e4 && this.captures.has(recent.captureId)) {
23086
- this.owners.set(ownerId, recent.captureId);
23087
- return ownerId;
23088
- }
23089
- const captureId = randomUUID();
23090
- this.captures.set(captureId, {
23091
- deviceId,
23092
- trigger,
23093
- startedAt: now,
23094
- expiresAt: now + NC_STILL_SHELF_TTL_MS,
23095
- files: []
23096
- });
23097
- this.newest.set(deviceId, {
23098
- captureId,
23099
- at: now
23100
- });
23101
- this.owners.set(ownerId, captureId);
23102
- this.fetch(captureId, deviceId, atMs, trigger);
23103
- return ownerId;
23104
- }
23105
- /**
23106
- * The media an owner id holds, or `undefined` when this shelf never minted
23107
- * it. An EMPTY array is a different answer: the capture exists and has not
23108
- * landed (or never will), which is exactly the case the dispatcher's bounded
23109
- * wait and its `no still could be resolved` line are for.
23110
- */
23111
- get(ownerId) {
23112
- const captureId = this.owners.get(ownerId);
23113
- if (captureId === void 0) return void 0;
23114
- return this.captures.get(captureId)?.files ?? [];
23115
- }
23116
- /** Drop expired captures and the owners that named them. */
23117
- prune(now) {
23118
- for (const [captureId, held] of this.captures) {
23119
- if (held.expiresAt > now) continue;
23120
- this.captures.delete(captureId);
23121
- if (this.newest.get(held.deviceId)?.captureId === captureId) this.newest.delete(held.deviceId);
23122
- }
23123
- while (this.captures.size > MAX_CAPTURES) {
23124
- const oldest = this.captures.keys().next();
23125
- if (oldest.done === true) break;
23126
- const held = this.captures.get(oldest.value);
23127
- this.captures.delete(oldest.value);
23128
- if (held !== void 0 && this.newest.get(held.deviceId)?.captureId === oldest.value) this.newest.delete(held.deviceId);
23129
- }
23130
- for (const [ownerId, captureId] of this.owners) if (!this.captures.has(captureId)) this.owners.delete(ownerId);
23131
- }
23132
- /** Drop everything (shutdown). */
23133
- clear() {
23134
- this.captures.clear();
23135
- this.owners.clear();
23136
- this.newest.clear();
23137
- }
23138
- async fetch(captureId, deviceId, atMs, trigger) {
23139
- try {
23140
- const shot = await withTimeout$3(this.deps.getSnapshot(deviceId), SNAPSHOT_TIMEOUT_MS);
23141
- if (shot === null || shot.base64.length === 0) {
23142
- this.reportMiss(deviceId, trigger, "the camera returned no snapshot");
23143
- return;
23144
- }
23145
- const raw = Buffer.from(shot.base64, "base64");
23146
- let data = raw;
23147
- try {
23148
- data = await downscaleFullFrameJpeg(raw, 960, 540);
23149
- } catch {}
23150
- const held = this.captures.get(captureId);
23151
- if (held === void 0) return;
23152
- this.captures.set(captureId, {
23153
- ...held,
23154
- files: stillShelfMedia(data, atMs)
23155
- });
23156
- this.deps.logger.info(`${trigger} still captured`, {
23157
- tags: { deviceId },
23158
- meta: {
23159
- trigger,
23160
- bytes: data.byteLength,
23161
- tookMs: this.deps.now() - held.startedAt
23162
- }
23163
- });
23164
- } catch (err) {
23165
- this.reportMiss(deviceId, trigger, err instanceof Error ? err.message : String(err));
23166
- }
23167
- }
23168
- /**
23169
- * A branch that drops work says so. This one costs the operator the picture
23170
- * on a notification they DID receive, so it is a warn and it carries the
23171
- * camera — the only key the question is ever asked with.
23172
- */
23173
- reportMiss(deviceId, trigger, reason) {
23174
- this.deps.logger.warn(`the camera did not answer the ${trigger} still — this notification ships text-only`, {
23175
- tags: { deviceId },
23176
- meta: {
23177
- trigger,
23178
- reason
23179
- }
23180
- });
23181
- }
23182
- };
23183
- /**
23184
- * The photograph, in the three kinds the CLEAN-SCENE ladders ask for.
23185
- *
23186
- * `keyFrameSmall` leads because it is the first rung of both `attach: 'best'`
23187
- * and `attach: 'keyFrame'` on a track owner; `keyFrame` and `fullFrame` answer
23188
- * `frame: 'full'` and `frame: 'boxed'`'s honest degrade. There is deliberately
23189
- * no `crop` / `thumbnail`: nothing was boxed, so `frame: 'cropped'` resolves
23190
- * NOTHING and the notification ships text-only with the ordinary line saying
23191
- * so. Inventing a centre crop would answer a different question from the one
23192
- * the operator asked.
23193
- */
23194
- function stillShelfMedia(data, timestamp) {
23195
- const base64 = data.toString("base64");
23196
- const file = (kind) => ({
23197
- mediaId: nextUnstoredMediaId(),
23198
- kind,
23199
- base64,
23200
- sizeBytes: data.byteLength,
23201
- timestamp
23202
- });
23203
- return [
23204
- file("keyFrameSmall"),
23205
- file("keyFrame"),
23206
- file("fullFrame")
23207
- ];
23208
- }
23209
- /** Reject if `promise` does not settle within `ms`. */
23210
- function withTimeout$3(promise, ms) {
23211
- return new Promise((resolve, reject) => {
23212
- const timer = setTimeout(() => {
23213
- reject(/* @__PURE__ */ new Error(`snapshot cap timed out after ${String(ms)}ms`));
23214
- }, ms);
23215
- promise.then((value) => {
23216
- clearTimeout(timer);
23217
- resolve(value);
23218
- }, (err) => {
23219
- clearTimeout(timer);
23220
- reject(err instanceof Error ? err : new Error(String(err)));
23221
- });
23222
- });
23223
- }
23224
24140
  //#endregion
23225
24141
  //#region src/notification-center/summary/summary-ai.ts
23226
24142
  /**
@@ -23712,293 +24628,6 @@ function findNextWindowOpen(input) {
23712
24628
  endMs: null
23713
24629
  };
23714
24630
  }
23715
- /** The canvas behind a tile whose aspect does not fill its cell. */
23716
- var MOSAIC_BACKGROUND = {
23717
- r: 17,
23718
- g: 17,
23719
- b: 17
23720
- };
23721
- /** Tile aspect — cameras are 16:9 and a square cell would letterbox every one. */
23722
- var TILE_ASPECT = 9 / 16;
23723
- /** Height of the per-tile label strip, as a fraction of the tile height. */
23724
- var LABEL_BAND_FRACTION = .14;
23725
- var LABEL_MIN_PX = 11;
23726
- /** Height of the mosaic's own title band, as a fraction of a tile's height. */
23727
- var TITLE_BAND_FRACTION = .22;
23728
- var TITLE_MIN_PX = 16;
23729
- /**
23730
- * Average glyph width as a fraction of the font size, for bold sans-serif.
23731
- *
23732
- * An ESTIMATE, and it has to be: librsvg lays the text out long after this
23733
- * string is written, so there are no metrics to measure against. The same
23734
- * number and the same reasoning as `preview-frame.ts#fitTitle` — deliberately
23735
- * GENEROUS, because a label that shrank slightly more than it had to is
23736
- * invisible and one that overflowed runs into the tile beside it. The first
23737
- * live mosaic did exactly that: `Videocamera cortile · 07:49 · animal` was cut
23738
- * to `…· anima` at the tile edge, mid-word.
23739
- */
23740
- var GLYPH_ADVANCE = .56;
23741
- /** Fraction of the band a label may occupy, leaving the padding either side. */
23742
- var LABEL_USABLE_FRACTION = .94;
23743
- /** `<`, `>`, `&`, `'`, `"` in a camera name or an operator caption would each
23744
- * turn the overlay into a document librsvg refuses. */
23745
- function escapeXml$1(s) {
23746
- return s.replace(/[<>&'"]/g, (ch) => {
23747
- switch (ch) {
23748
- case "<": return "&lt;";
23749
- case ">": return "&gt;";
23750
- case "&": return "&amp;";
23751
- case "'": return "&apos;";
23752
- default: return "&quot;";
23753
- }
23754
- });
23755
- }
23756
- /**
23757
- * The single decision about drawn text — and the home of the DEFAULT.
23758
- *
23759
- * `undefined` means `none`, not `all`: a rule stored before this option existed
23760
- * has no `captions` key, and the operator's request is that such a rule deliver
23761
- * pictures. The schema materialises the same default on read
23762
- * (`NcSummaryRuleInputSchema`), so this arm only fires for a caller that never
23763
- * had a rule — the test bridge and the spec. Both agreeing is the point.
23764
- */
23765
- function mosaicTextPlan(captions) {
23766
- if (captions === "all") return {
23767
- tileLabels: true,
23768
- titleBand: true
23769
- };
23770
- if (captions === "tiles") return {
23771
- tileLabels: true,
23772
- titleBand: false
23773
- };
23774
- return {
23775
- tileLabels: false,
23776
- titleBand: false
23777
- };
23778
- }
23779
- /** Grid shape for `n` tiles: as square as possible, rows never exceeding cols
23780
- * by more than one — a 3×3 for nine, a 3×2 for five, never a 1×9 strip.
23781
- *
23782
- * `rows` is the load-bearing half — it is how many rows {@link mosaicRowCounts}
23783
- * distributes into. `cols` describes the WIDEST row, and is no longer a cell
23784
- * count: see {@link mosaicRowCounts} for why a `cols × rows` grid was the bug. */
23785
- function mosaicGrid(n) {
23786
- const count = Math.max(1, Math.floor(n));
23787
- const cols = Math.ceil(Math.sqrt(count));
23788
- return {
23789
- cols,
23790
- rows: Math.ceil(count / cols)
23791
- };
23792
- }
23793
- /**
23794
- * How many tiles each ROW carries, top to bottom — and the reason the mosaic
23795
- * can no longer have a hole in it.
23796
- *
23797
- * The layout used to be a `cols × rows` GRID, and a grid holds more cells than
23798
- * tiles for every count that is not a perfect fit: 3 into a 2×2, 5 into a 3×2,
23799
- * 7 and 8 into a 3×3. The composite only ever wrote the tiles, so the spare
23800
- * cells stayed the canvas — `MOSAIC_BACKGROUND`, rgb 17 — and the operator's
23801
- * three-tile digest was a quarter black. Measured on the shipped renderer:
23802
- * 24.8% of a three-tile mosaic was background. His words: *"se ci sono 3
23803
- * elementi non dovrebbe renderizzare buchi neri"*.
23804
- *
23805
- * Counting ROWS instead removes the failure by construction rather than by
23806
- * patching it. Every row spans the full width and divides it among the tiles it
23807
- * actually has, so a row is full whatever the remainder and there is no such
23808
- * thing as a spare cell. The shapes fall out of it, and they are the ones asked
23809
- * for: 1 ⇒ full frame, 2 ⇒ side by side, 3 ⇒ one big over two small, 4 ⇒ 2×2.
23810
- *
23811
- * **The fewer-tiled row comes FIRST.** Tiles arrive in the selection's order,
23812
- * so row 0 is the head of the ranking; putting the remainder at the top would
23813
- * make the WORST tile the big one.
23814
- *
23815
- * Every cell keeps {@link TILE_ASPECT}, so rows differ in height — which is
23816
- * also what stops the letterbox: a full-width hero tile is 16:9 exactly, not a
23817
- * 16:9 picture centred in a double-height cell with bars over and under it.
23818
- */
23819
- function mosaicRowCounts(n) {
23820
- const count = Math.max(1, Math.floor(n));
23821
- const { rows } = mosaicGrid(count);
23822
- const base = Math.floor(count / rows);
23823
- const remainder = count % rows;
23824
- const counts = [];
23825
- for (let row = 0; row < rows; row += 1) counts.push(row < rows - remainder ? base : base + 1);
23826
- return counts;
23827
- }
23828
- /**
23829
- * Where every tile lands. Pure — no bytes, no sharp, no clock.
23830
- *
23831
- * Exported because the rectangles are DATA the artefact should carry: a viewer
23832
- * that wants a tappable mosaic reads these rather than re-deriving a layout
23833
- * that could drift from the one actually rendered.
23834
- *
23835
- * The rectangles COVER the picture area exactly — that is the property the
23836
- * spec asserts, and it is the one the old grid broke.
23837
- */
23838
- function mosaicLayout(input) {
23839
- const rowCounts = mosaicRowCounts(input.tileCount);
23840
- const width = Math.max(1, Math.floor(input.maxWidth ?? 1920));
23841
- const firstRowCount = rowCounts[0] ?? 1;
23842
- const tileWidth = Math.floor(width / firstRowCount);
23843
- const tileHeight = Math.round(tileWidth * TILE_ASPECT);
23844
- const titleHeight = input.withTitle === true ? Math.round(tileHeight * TITLE_BAND_FRACTION) : 0;
23845
- const tiles = [];
23846
- let y = titleHeight;
23847
- let index = 0;
23848
- for (const count of rowCounts) {
23849
- const nominalWidth = Math.floor(width / count);
23850
- const rowHeight = Math.round(nominalWidth * TILE_ASPECT);
23851
- let x = 0;
23852
- for (let inRow = 0; inRow < count; inRow += 1) {
23853
- const w = inRow === count - 1 ? width - x : nominalWidth;
23854
- tiles.push({
23855
- index,
23856
- x,
23857
- y,
23858
- w,
23859
- h: rowHeight
23860
- });
23861
- x += w;
23862
- index += 1;
23863
- }
23864
- y += rowHeight;
23865
- }
23866
- return {
23867
- width,
23868
- height: y,
23869
- cols: Math.max(...rowCounts),
23870
- rows: rowCounts.length,
23871
- rowCounts,
23872
- tileWidth,
23873
- tileHeight,
23874
- titleHeight,
23875
- tiles
23876
- };
23877
- }
23878
- /**
23879
- * Shrink, then ELIDE, until the line fits `usablePx`.
23880
- *
23881
- * Shrinking is tried first because a slightly smaller label still says
23882
- * everything; only past `minPx` is the tail cut, and then with an ellipsis so
23883
- * the reader can SEE that something was cut. The failure this replaces was
23884
- * silent: SVG simply clips at the viewport, so an overlong label lost its last
23885
- * word mid-glyph and looked like a rendering bug rather than a truncation.
23886
- *
23887
- * Pure and exported so the arithmetic is testable without rendering a pixel.
23888
- */
23889
- function fitLine(text, usablePx, basePx, minPx) {
23890
- const flat = text.trim().replace(/\s+/g, " ");
23891
- const fits = (s, fontPx) => s.length * fontPx * GLYPH_ADVANCE <= usablePx;
23892
- let fontPx = Math.max(minPx, Math.round(basePx));
23893
- while (fontPx > minPx && !fits(flat, fontPx)) fontPx = Math.max(minPx, fontPx - 1);
23894
- if (fits(flat, fontPx)) return {
23895
- text: flat,
23896
- fontPx
23897
- };
23898
- const budget = Math.max(1, Math.floor(usablePx / (fontPx * GLYPH_ADVANCE)));
23899
- return {
23900
- text: `${flat.slice(0, Math.max(1, budget - 1)).trimEnd()}…`,
23901
- fontPx
23902
- };
23903
- }
23904
- /** The label strip drawn across the bottom of one tile. */
23905
- function tileLabelSvg(width, height, label) {
23906
- const bandHeight = Math.max(18, Math.round(height * LABEL_BAND_FRACTION));
23907
- const basePx = Math.round(bandHeight * .62);
23908
- const padding = Math.round(basePx * .6);
23909
- const fitted = fitLine(label, width * LABEL_USABLE_FRACTION - padding, basePx, LABEL_MIN_PX);
23910
- const baseline = height - Math.round((bandHeight - fitted.fontPx) / 2) - Math.round(fitted.fontPx * .18);
23911
- return Buffer.from(`<svg width="${width}" height="${height}" xmlns="http://www.w3.org/2000/svg">
23912
- <rect x="0" y="${height - bandHeight}" width="${width}" height="${bandHeight}" fill="black" fill-opacity="0.55"/>
23913
- <text x="${padding}" y="${baseline}" font-size="${fitted.fontPx}" font-family="sans-serif" font-weight="bold" fill="white">${escapeXml$1(fitted.text)}</text>
23914
- </svg>`);
23915
- }
23916
- /** The mosaic's own title band. */
23917
- function titleSvg(width, height, caption) {
23918
- const fitted = fitLine(caption, width * LABEL_USABLE_FRACTION, Math.round(height * .5), TITLE_MIN_PX);
23919
- const baseline = Math.round(height / 2 + fitted.fontPx * .36);
23920
- return Buffer.from(`<svg width="${width}" height="${height}" xmlns="http://www.w3.org/2000/svg">
23921
- <rect x="0" y="0" width="${width}" height="${height}" fill="#111111"/>
23922
- <text x="${Math.round(width / 2)}" y="${baseline}" font-size="${fitted.fontPx}" font-family="sans-serif" font-weight="bold" text-anchor="middle" fill="white">${escapeXml$1(fitted.text)}</text>
23923
- </svg>`);
23924
- }
23925
- /**
23926
- * Compose the mosaic.
23927
- *
23928
- * Every tile is fitted with `contain` onto the cell background rather than
23929
- * cropped: a `cover` fit would silently cut the subject out of a portrait crop,
23930
- * and a tile that does not show what it is about is worse than a letterboxed
23931
- * one.
23932
- *
23933
- * A tile sharp cannot read is DROPPED and counted, never composited as a blank
23934
- * cell — a black square in a digest reads as "the camera saw nothing", which is
23935
- * the opposite of what happened. The layout is computed from the SURVIVORS, and
23936
- * because {@link mosaicRowCounts} counts rows rather than grid cells there is no
23937
- * remainder to leave empty either.
23938
- *
23939
- * @throws when every tile was dropped, and when sharp cannot produce the final
23940
- * canvas at all; the caller treats both as "no mosaic" and still
23941
- * delivers the counts.
23942
- */
23943
- async function renderMosaic(request, logger) {
23944
- const plan = mosaicTextPlan(request.captions);
23945
- const usable = [];
23946
- for (const tile of request.tiles) try {
23947
- await sharp(tile.jpeg).metadata();
23948
- usable.push(tile);
23949
- } catch (err) {
23950
- logger.warn("summary mosaic: a tile could not be decoded and was dropped", {
23951
- tags: { deviceId: tile.deviceId },
23952
- meta: {
23953
- trackId: tile.trackId,
23954
- error: String(err)
23955
- }
23956
- });
23957
- }
23958
- const droppedTiles = request.tiles.length - usable.length;
23959
- if (usable.length === 0) throw new Error(`summary mosaic: no tile could be decoded (${request.tiles.length} offered)`);
23960
- const caption = plan.titleBand ? request.caption?.trim() ?? "" : "";
23961
- const layout = mosaicLayout({
23962
- tileCount: usable.length,
23963
- ...request.maxWidth !== void 0 ? { maxWidth: request.maxWidth } : {},
23964
- withTitle: caption.length > 0
23965
- });
23966
- const composites = await Promise.all(usable.map(async (tile, index) => {
23967
- const rect = layout.tiles[index];
23968
- if (rect === void 0) throw new Error("summary mosaic: layout lost a tile");
23969
- const label = plan.tileLabels ? tile.label?.trim() ?? "" : "";
23970
- const fitted = sharp(tile.jpeg).resize({
23971
- width: rect.w,
23972
- height: rect.h,
23973
- fit: "contain",
23974
- background: MOSAIC_BACKGROUND
23975
- });
23976
- return {
23977
- input: await (label.length === 0 ? fitted : fitted.composite([{
23978
- input: tileLabelSvg(rect.w, rect.h, label),
23979
- top: 0,
23980
- left: 0
23981
- }])).jpeg({ quality: 82 }).toBuffer(),
23982
- top: rect.y,
23983
- left: rect.x
23984
- };
23985
- }));
23986
- if (caption.length > 0 && layout.titleHeight > 0) composites.unshift({
23987
- input: titleSvg(layout.width, layout.titleHeight, caption),
23988
- top: 0,
23989
- left: 0
23990
- });
23991
- return {
23992
- jpeg: await sharp({ create: {
23993
- width: layout.width,
23994
- height: layout.height,
23995
- channels: 3,
23996
- background: MOSAIC_BACKGROUND
23997
- } }).composite(composites).jpeg({ quality: 82 }).toBuffer(),
23998
- layout,
23999
- droppedTiles
24000
- };
24001
- }
24002
24631
  //#endregion
24003
24632
  //#region src/notification-center/summary/summary-ai-subject-view.ts
24004
24633
  /**
@@ -25322,11 +25951,12 @@ var NcSummaryProducer = class {
25322
25951
  try {
25323
25952
  const plan = mosaicTextPlan(rule.captions);
25324
25953
  rendered = await renderMosaic({
25954
+ purpose: "summary",
25325
25955
  tiles: tiles.map(({ candidate, jpeg }) => ({
25326
25956
  jpeg,
25327
25957
  ...plan.tileLabels ? { label: `${names.get(candidate.deviceId) ?? candidate.deviceId} · ${clockOf$1(candidate.firstSeen)} · ${candidate.className}` } : {},
25328
25958
  deviceId: candidate.deviceId,
25329
- trackId: candidate.trackId
25959
+ sourceId: candidate.trackId
25330
25960
  })),
25331
25961
  captions: rule.captions,
25332
25962
  ...plan.titleBand ? { caption: summaryCaptionText({
@@ -27689,6 +28319,12 @@ var NotificationCenter = class NotificationCenter {
27689
28319
  */
27690
28320
  stills;
27691
28321
  /**
28322
+ * The photographs a SENSOR notification carries of every camera linking the
28323
+ * sensor (`linked-stills.ts`, ADR 0632). Null when this wiring has no
28324
+ * snapshot cap — such a notification then ships text-only.
28325
+ */
28326
+ linkedStills;
28327
+ /**
27692
28328
  * Rate limit for the "matched NO rule" report — see `reportNoMatch`. Keyed by
27693
28329
  * {@link noMatchReportKey} (device + subject kind + class set), NOT by device:
27694
28330
  * a person's verdict must never consume the window a cat's verdict needed on
@@ -27699,6 +28335,10 @@ var NotificationCenter = class NotificationCenter {
27699
28335
  /** Same rate-limit pair, same key, for the per-camera mute drop — see `reportMutedDrop`. */
27700
28336
  lastMutedReportAt = /* @__PURE__ */ new Map();
27701
28337
  mutedSuppressed = /* @__PURE__ */ new Map();
28338
+ /** Same pair, keyed by CAMERA, for a muted camera left out of a linked set —
28339
+ * see `reportLinkedCameraMuted`. */
28340
+ lastLinkedMutedReportAt = /* @__PURE__ */ new Map();
28341
+ linkedMutedSuppressed = /* @__PURE__ */ new Map();
27702
28342
  /**
27703
28343
  * RAM-only same-edge dedup for the families the durable ledger does NOT
27704
28344
  * govern — stream health and the operator's disable switch. Both are already
@@ -28349,6 +28989,14 @@ var NotificationCenter = class NotificationCenter {
28349
28989
  logger: this.logger.child("still-shelf"),
28350
28990
  now: this.now
28351
28991
  }) : null;
28992
+ this.linkedStills = getSnapshot !== void 0 ? new NcLinkedStillShelf({
28993
+ getSnapshot,
28994
+ ...deps.getSnapshotState !== void 0 ? { getSnapshotState: deps.getSnapshotState } : {},
28995
+ ...deps.getFrameCapturedAt !== void 0 ? { getFrameCapturedAt: deps.getFrameCapturedAt } : {},
28996
+ isOnline: (id) => this.deviceDirectory.get(id)?.online,
28997
+ logger: this.logger.child("linked-stills"),
28998
+ now: this.now
28999
+ }) : null;
28352
29000
  this.rules = new NcRuleStore({
28353
29001
  store: deps.store,
28354
29002
  logger: this.logger.child("rules"),
@@ -28466,6 +29114,8 @@ var NotificationCenter = class NotificationCenter {
28466
29114
  if (held !== void 0) return held.files;
28467
29115
  const shelved = isStillShelfId(ownerId) ? this.stills?.get(ownerId) : void 0;
28468
29116
  if (shelved !== void 0) return shelved;
29117
+ const linked = isLinkedStillId(ownerId) ? this.linkedStills?.get(ownerId) : void 0;
29118
+ if (linked !== void 0) return linked;
28469
29119
  return deps.dispatcher.getMediaForOwner(ownerKind, ownerId);
28470
29120
  },
28471
29121
  ...deps.now !== void 0 ? { now: deps.now } : {},
@@ -28864,6 +29514,7 @@ var NotificationCenter = class NotificationCenter {
28864
29514
  this.timelapseScheduler?.stop();
28865
29515
  this.summaryProducer?.stop();
28866
29516
  this.stills?.clear();
29517
+ this.linkedStills?.clear();
28867
29518
  this.addonUpdates.dispose();
28868
29519
  this.livenessHoldoff.dispose();
28869
29520
  this.evaluationActive = false;
@@ -29192,6 +29843,110 @@ var NotificationCenter = class NotificationCenter {
29192
29843
  this.consumeEvent(incomingFromUnownedSensorEvent(event, linkedCameraIds));
29193
29844
  }
29194
29845
  /**
29846
+ * Start photographing every camera that links `sensorId`, and return the
29847
+ * owners the sensor-level evaluation names — or `undefined` when there is no
29848
+ * set (≤1 linked camera: today's marker is the picture), nothing to show
29849
+ * (every linked camera muted), the event will be dropped as muted (the
29850
+ * SENSOR is muted, or there were row cameras and every one is muted — see
29851
+ * the evaluation's mute gate), or no enabled device-event rule that could
29852
+ * fire on this sensor KIND wants a picture (the gate is the RULE's media
29853
+ * policy, scope and kind conditions, read from the in-memory mirror — D49,
29854
+ * as `audioStillOwner`; an occupancy rule, which rides the same delivery,
29855
+ * never fires on a sensor). Coarse on purpose, in one direction only: a
29856
+ * capture for a rule whose other conditions then fail is one wasted
29857
+ * snapshot, a missing capture is a notification without its picture. The
29858
+ * kind check is what keeps a chatty multi-linked motion sensor from
29859
+ * composing a mosaic on every edge for a doorbell rule.
29860
+ *
29861
+ * A MUTED camera (D62) is never dialled: its tile is left out, said on that
29862
+ * camera (once per window), and the primary is the first linked camera that
29863
+ * is not muted — the same derivation {@link onSensorEvent} makes.
29864
+ *
29865
+ * `cameraIds` is every linking camera, UNFILTERED; `rowCameraIds` the ones
29866
+ * that take a timeline row for the change — with the sensor, the device
29867
+ * scope the evaluation will match on (`matchesDeviceScope`).
29868
+ */
29869
+ beginLinkedStills(request) {
29870
+ const { sensorId, sensorKind, cameraIds, atMs, rowCameraIds } = request;
29871
+ const shelf = this.linkedStills;
29872
+ if (shelf === null || !this.evaluationActive || cameraIds.length < 2) return void 0;
29873
+ if (this.deviceMutes.isMuted(sensorId)) return void 0;
29874
+ const rowShown = this.unmutedCameras(rowCameraIds);
29875
+ if (rowCameraIds.length > 0 && rowShown.length === 0) return void 0;
29876
+ const shown = this.unmutedCameras(cameraIds);
29877
+ const involved = [sensorId, ...rowShown];
29878
+ if (!this.rules.listEnabled("device-event").some((rule) => {
29879
+ if (rule.media.attach === "none") return false;
29880
+ if (!deviceEventRuleMayFireOnKind(rule, sensorKind)) return false;
29881
+ const devices = rule.conditions.devices;
29882
+ return devices === void 0 || devices.length === 0 || devices.some((id) => involved.includes(id));
29883
+ })) return void 0;
29884
+ for (const cameraId of cameraIds) if (!shown.includes(cameraId)) this.reportLinkedCameraMuted(cameraId, sensorId);
29885
+ return shown.length > 0 ? shelf.captureSet(sensorId, shown, atMs) : void 0;
29886
+ }
29887
+ /**
29888
+ * A muted camera left out of a linked set — one INFO line per camera per
29889
+ * window, the suppressed count folded into the next one. A chatty contact
29890
+ * linked to a muted camera must not write a line on every change.
29891
+ */
29892
+ reportLinkedCameraMuted(cameraId, sensorId) {
29893
+ const now = this.now();
29894
+ const last = this.lastLinkedMutedReportAt.get(cameraId);
29895
+ const suppressed = this.linkedMutedSuppressed.get(cameraId) ?? 0;
29896
+ if (last !== void 0 && now - last < NO_MATCH_REPORT_INTERVAL_MS) {
29897
+ this.linkedMutedSuppressed.set(cameraId, suppressed + 1);
29898
+ return;
29899
+ }
29900
+ this.lastLinkedMutedReportAt.set(cameraId, now);
29901
+ this.linkedMutedSuppressed.set(cameraId, 0);
29902
+ this.logger.info("linked camera muted — left out of the sensor notification", {
29903
+ tags: { deviceId: cameraId },
29904
+ meta: {
29905
+ sensorId,
29906
+ reason: "muted",
29907
+ ...suppressed > 0 ? { alsoLeftOutSinceLastReport: suppressed } : {}
29908
+ }
29909
+ });
29910
+ }
29911
+ /**
29912
+ * ONE evaluation for one sensor state change (ADR 0632) — replaces the
29913
+ * per-camera fan-out when the sensor is linked to more than one camera.
29914
+ *
29915
+ * The linked cameras widen the MEDIA only — which cameras' stills ship —
29916
+ * never which rules fire. Matching is on the SENSOR and the cameras that
29917
+ * took a timeline row (`rowCameraIds`, not muted) — D630 §6, whatever the
29918
+ * number of linked cameras. The primary is only the camera the notification
29919
+ * leads with.
29920
+ *
29921
+ * The primary is re-derived here as the first linked camera that is NOT
29922
+ * muted (D62): a camera's mute drops only its own tile, and its marker is
29923
+ * then not the picture. Every linked camera muted ⇒ the sensor is its own
29924
+ * device and the notification is text-only. The MUTE of the event itself is
29925
+ * judged on the sensor ({@link NcRuleSubject.linkedSensorId}). A
29926
+ * `rule-device` cooldown is keyed by the primary. Same lossy boundary as
29927
+ * {@link onSensorEventPersisted}.
29928
+ */
29929
+ onSensorEvent(input) {
29930
+ this.observeOpeningEvent(input.event);
29931
+ const shown = this.unmutedCameras(input.linkedCameraIds);
29932
+ const muted = input.linkedCameraIds.filter((id) => !shown.includes(id));
29933
+ const primary = shown[0] ?? input.event.sourceDeviceId;
29934
+ const marker = primary === input.event.deviceId ? input.markerTrackId : void 0;
29935
+ this.consumeEvent(incomingFromSensorEvent({
29936
+ ...input.event,
29937
+ deviceId: primary
29938
+ }, marker, {
29939
+ linkedCameraIds: shown,
29940
+ rowCameraIds: this.unmutedCameras(input.rowCameraIds),
29941
+ mutedRowCameraIds: input.rowCameraIds.filter((id) => this.deviceMutes.isMuted(id)),
29942
+ ...input.linkedStills !== void 0 ? { linkedStills: input.linkedStills } : {}
29943
+ }, muted));
29944
+ }
29945
+ /** The linked cameras a sensor notification may show — not muted (D62), in order. */
29946
+ unmutedCameras(cameraIds) {
29947
+ return cameraIds.filter((id) => !this.deviceMutes.isMuted(id));
29948
+ }
29949
+ /**
29195
29950
  * The opening reading a sensor row carries — a contact, a cover or a lock,
29196
29951
  * read with D630's table (`alarmOpeningFromSensorEvent`) — handed to the
29197
29952
  * panel. A row that is not an opening, or whose word is unknown, hands over
@@ -29477,6 +30232,10 @@ var NotificationCenter = class NotificationCenter {
29477
30232
  getRule: async ({ ruleId }) => ({ rule: this.rules.get(ruleId) }),
29478
30233
  createRule: async ({ rule, caller }) => {
29479
30234
  assertRuleHasAddressee(rule.targets, rule.targetUsers);
30235
+ guardRuleFamily(rule, {
30236
+ logger: this.logger,
30237
+ by: caller.userId
30238
+ });
29480
30239
  await this.validateTargetRefs(rule.targets.map((t) => t.targetId));
29481
30240
  const created = await this.rules.create(rule, caller.userId);
29482
30241
  this.logger.info("notification rule created", { meta: {
@@ -29488,7 +30247,13 @@ var NotificationCenter = class NotificationCenter {
29488
30247
  },
29489
30248
  updateRule: async ({ ruleId, patch, clear, caller }) => {
29490
30249
  if (patch.targets !== void 0) await this.validateTargetRefs(patch.targets.map((t) => t.targetId));
29491
- assertPatchKeepsAddressee(this.rules.get(ruleId), patch, clear ?? []);
30250
+ const existing = this.rules.get(ruleId);
30251
+ assertPatchKeepsAddressee(existing, patch, clear ?? []);
30252
+ guardPatchFamily(existing, patch, {
30253
+ logger: this.logger,
30254
+ by: caller.userId,
30255
+ ruleId
30256
+ });
29492
30257
  const { disabledTargetIds: _optOut, ...safePatch } = patch;
29493
30258
  const updated = await this.rules.update(ruleId, safePatch, clear ?? []);
29494
30259
  this.logger.info("notification rule updated", { meta: {
@@ -30318,8 +31083,9 @@ var NotificationCenter = class NotificationCenter {
30318
31083
  outcomes,
30319
31084
  ...extra
30320
31085
  });
30321
- if (this.deviceMutes.isMuted(subject.deviceId) && controls.bypassMute !== true) {
30322
- this.reportMutedDrop(subject, kind, this.now());
31086
+ const muteDeviceId = this.mutedAuthority(subject);
31087
+ if (muteDeviceId !== void 0 && controls.bypassMute !== true) {
31088
+ this.reportMutedDrop(subject, muteDeviceId, kind, this.now());
30323
31089
  return report({ droppedBy: "muted" });
30324
31090
  }
30325
31091
  const delivery = deliveryForKind(kind);
@@ -30490,7 +31256,7 @@ var NotificationCenter = class NotificationCenter {
30490
31256
  buttons: unresolvable.join(", ")
30491
31257
  }
30492
31258
  });
30493
- if (!noOwnerReported && kind === "device-event" && subject.trackId === void 0 && rule.media.attach !== "none") {
31259
+ if (!noOwnerReported && kind === "device-event" && subject.trackId === void 0 && subject.linkedStills === void 0 && rule.media.attach !== "none") {
30494
31260
  noOwnerReported = true;
30495
31261
  const linked = subject.linkedCameraIds;
30496
31262
  const meta = {
@@ -30499,7 +31265,19 @@ var NotificationCenter = class NotificationCenter {
30499
31265
  ruleId: rule.id,
30500
31266
  rule: rule.name
30501
31267
  };
30502
- if (linked !== void 0) this.logger.debug(linked.length === 0 ? "device event from a sensor linked to no camera — this notification ships text-only" : `device event from a sensor linked to ${linked.length} camera(s), none opted into tracked events — this notification ships text-only`, {
31268
+ if (linked !== void 0 && linked.length === 0 && isLinkedSetSubject(subject)) this.logger.info("every linked camera is muted — this sensor notification ships text-only", {
31269
+ tags: { deviceId: subject.linkedSensorId ?? subject.deviceId },
31270
+ meta
31271
+ });
31272
+ else if (linked !== void 0 && isLinkedSetSubject(subject)) this.logger.warn("sensor event has no linked-camera pictures and no marker — this notification ships text-only", {
31273
+ tags: { deviceId: subject.sourceDeviceId ?? subject.deviceId },
31274
+ meta: {
31275
+ ...meta,
31276
+ linkedCameraIds: [...linked],
31277
+ snapshotWired: this.linkedStills !== null
31278
+ }
31279
+ });
31280
+ else if (linked !== void 0) this.logger.debug(linked.length === 0 ? "device event from a sensor linked to no camera — this notification ships text-only" : `device event from a sensor linked to ${linked.length} camera(s), none opted into tracked events — this notification ships text-only`, {
30503
31281
  tags: { deviceId: subject.deviceId },
30504
31282
  meta: {
30505
31283
  ...meta,
@@ -30756,14 +31534,26 @@ var NotificationCenter = class NotificationCenter {
30756
31534
  });
30757
31535
  }
30758
31536
  /**
31537
+ * The muted device that drops this subject, or `undefined` when none does.
31538
+ * A per-camera subject: its camera. A linked set: the sensor; else, when
31539
+ * the change took rows and every row camera is muted, the sensor too — the
31540
+ * line names the muted row cameras.
31541
+ */
31542
+ mutedAuthority(subject) {
31543
+ const sensorId = subject.linkedSensorId;
31544
+ if (sensorId === void 0) return this.deviceMutes.isMuted(subject.deviceId) ? subject.deviceId : void 0;
31545
+ if (this.deviceMutes.isMuted(sensorId)) return sensorId;
31546
+ return (subject.rowCameraIds ?? []).length === 0 && (subject.mutedRowCameraIds ?? []).length > 0 ? sensorId : void 0;
31547
+ }
31548
+ /**
30759
31549
  * One INFO line when a MUTED camera's event is dropped, bounded to one per
30760
31550
  * camera per window with the suppressed count folded into the next report —
30761
31551
  * identical shape to `reportNoMatch`, because the question an operator asks
30762
31552
  * is identical: "why did this camera not notify me?".
30763
31553
  */
30764
- reportMutedDrop(subject, kind, now) {
31554
+ reportMutedDrop(subject, mutedDeviceId, kind, now) {
30765
31555
  const bucket = noMatchReportKey({
30766
- deviceId: subject.deviceId,
31556
+ deviceId: mutedDeviceId,
30767
31557
  kind,
30768
31558
  classNames: subject.classNames
30769
31559
  });
@@ -30775,10 +31565,12 @@ var NotificationCenter = class NotificationCenter {
30775
31565
  }
30776
31566
  this.lastMutedReportAt.set(bucket, now);
30777
31567
  this.mutedSuppressed.set(bucket, 0);
30778
- this.logger.info("notifications muted for this camera — event dropped before rule evaluation", {
30779
- tags: { deviceId: subject.deviceId },
31568
+ const rowsMuted = subject.linkedSensorId !== void 0 && !this.deviceMutes.isMuted(subject.linkedSensorId);
31569
+ this.logger.info(subject.linkedSensorId === void 0 ? "notifications muted for this camera — event dropped before rule evaluation" : rowsMuted ? "every camera that took its row is muted — sensor event dropped before rule evaluation" : "notifications muted for this sensor — event dropped before rule evaluation", {
31570
+ tags: { deviceId: mutedDeviceId },
30780
31571
  meta: {
30781
31572
  kind,
31573
+ ...rowsMuted ? { mutedRowCameraIds: [...subject.mutedRowCameraIds ?? []] } : {},
30782
31574
  classes: subject.classNames,
30783
31575
  eventId: subject.recordId,
30784
31576
  ...subject.trackId !== void 0 ? { trackId: subject.trackId } : {},
@@ -30829,6 +31621,7 @@ var NotificationCenter = class NotificationCenter {
30829
31621
  const confirm = frozenConfirmPolicy(rule, isSystemEvent);
30830
31622
  const cropZoneIds = rule.conditions.zones !== void 0 ? rule.conditions.zones.ids : rule.conditions.occupancy?.zoneId !== void 0 ? [rule.conditions.occupancy.zoneId] : void 0;
30831
31623
  const allTargets = [...rule.targets, ...userTargets.filter((t) => !direct.has(t.targetId))];
31624
+ const linkedOwners = !noMedia && subject.linkedStills !== void 0 ? linkedMediaOwners(subject.linkedStills, linkedCameraMode(mediaRule.media)) : void 0;
30832
31625
  return (upgrade !== void 0 ? allTargets.filter((t) => upgrade.targetIds.includes(t.targetId)) : allTargets).filter((target) => !rule.disabledTargetIds.includes(target.targetId)).map((target) => {
30833
31626
  const base = {
30834
31627
  ruleName: rule.name,
@@ -30859,7 +31652,12 @@ var NotificationCenter = class NotificationCenter {
30859
31652
  ...target.params !== void 0 ? { params: target.params } : {},
30860
31653
  subject: {
30861
31654
  deviceId: subject.deviceId,
30862
- ...alarmSource?.trackId !== void 0 ? { trackId: alarmSource.trackId } : subject.trackId !== void 0 ? { trackId: subject.trackId } : {},
31655
+ ...alarmSource?.trackId !== void 0 ? { trackId: alarmSource.trackId } : linkedOwners !== void 0 ? {
31656
+ trackId: linkedOwners.trackId,
31657
+ ...linkedOwners.extraStills.length > 0 ? { extraStills: linkedOwners.extraStills.map((x) => ({ ...x })) } : {},
31658
+ analysisTiles: linkedOwners.analysisTiles.map((x) => ({ ...x }))
31659
+ } : subject.trackId !== void 0 ? { trackId: subject.trackId } : {},
31660
+ ...subject.linkedCameraIds !== void 0 && subject.linkedCameraIds.length > 1 && isLinkedSetSubject(subject) ? { linkedCameraIds: [...subject.linkedCameraIds] } : {},
30863
31661
  ...alarmSource?.eventId !== void 0 ? { eventId: alarmSource.eventId } : hasEventMedia ? { eventId: subject.recordId } : {},
30864
31662
  className: subject.classNames[0] ?? subject.sensorKind ?? "event",
30865
31663
  ...subject.label !== void 0 ? { label: subject.label } : {},
@@ -72184,6 +72982,20 @@ function hasWrappedDetection(entries) {
72184
72982
  return entries.some((e) => e.capName === "detection-pipeline" && e.kind === "wrapped");
72185
72983
  }
72186
72984
  //#endregion
72985
+ //#region src/pipeline-analytics/runtime/linked-snapshot-reads.ts
72986
+ function buildLinkedSnapshotReads(snapshot) {
72987
+ return {
72988
+ getSnapshotState: async (deviceId) => {
72989
+ return { reason: (await snapshot.getDebugState.query({ deviceId })).state.reason };
72990
+ },
72991
+ getFrameCapturedAt: async (deviceIds) => {
72992
+ if (deviceIds.length === 0) return /* @__PURE__ */ new Map();
72993
+ const rows = await snapshot.getSnapshotOverview.query({ deviceIds: [...deviceIds] });
72994
+ return new Map(rows.map((r) => [r.deviceId, r.lastCapturedAt]));
72995
+ }
72996
+ };
72997
+ }
72998
+ //#endregion
72187
72999
  //#region src/pipeline-analytics/runtime/native-crop-dedupe.ts
72188
73000
  var NativeCropDedupe = class {
72189
73001
  config;
@@ -73792,6 +74604,59 @@ async function resolveSensorEventCameras(cache, data) {
73792
74604
  };
73793
74605
  }
73794
74606
  /**
74607
+ * The cameras a NOTIFICATION about this sensor shows — the emitting device
74608
+ * itself when it is a camera that may self-emit this cap, then every camera
74609
+ * that links it, opt-in or not (ADR 0632). The order is the mosaic's tile order
74610
+ * and names the PRIMARY camera (the first). Deliberately a sibling of
74611
+ * resolveSensorEventCameras rather than a flag on it: that one decides which
74612
+ * timeline rows exist and must keep honouring the opt-in.
74613
+ *
74614
+ * `[]` is NOT knowledge. It answers both "no camera links this sensor" and
74615
+ * "the camera index has not answered yet" (cold start, no camera list read —
74616
+ * this function does not consult `answered()`). A caller must read `[]` (and a
74617
+ * single camera) as "no mosaic this time, fall back to the plain path", never
74618
+ * as a durable "linked to nothing" that drops or deletes work (D49).
74619
+ */
74620
+ async function resolveLinkedMediaCameras(cache, data) {
74621
+ const linked = await cache.allCamerasFor(data.deviceId);
74622
+ return [...await cache.isCamera(data.deviceId) && selfCameraMayEmit(data.capName) ? [data.deviceId] : [], ...linked.filter((id) => id !== data.deviceId)];
74623
+ }
74624
+ var NO_SENSOR_NOTIFICATION_CAMERAS = {
74625
+ mediaCameraIds: [],
74626
+ rowCameraIds: []
74627
+ };
74628
+ /**
74629
+ * Which cameras a notification about this change shows, known BEFORE the
74630
+ * ingest runs, so the capture can start at the trigger.
74631
+ *
74632
+ * Only a change the ingest EVALUATES has any: a mapped cap that takes rows
74633
+ * (`dropReason` null) or an unowned sensor (`no-camera`, D630 §6). A camera's
74634
+ * own `motion` no camera opted in (`camera-native-cap`) and a cold index
74635
+ * (`camera-index-unknown`) are drops today, and a camera merely LINKING the
74636
+ * device must not turn a drop into a notification. Asks the SAME resolution
74637
+ * the ingest asks (`resolveSensorEventCameras`), off the same cache.
74638
+ */
74639
+ async function resolveSensorNotificationCameras(cache, data) {
74640
+ if (EVENT_KIND_BY_CAP[data.capName] === void 0) return NO_SENSOR_NOTIFICATION_CAMERAS;
74641
+ const rows = await resolveSensorEventCameras(cache, data);
74642
+ if (rows.dropReason !== null && rows.dropReason !== "no-camera") return NO_SENSOR_NOTIFICATION_CAMERAS;
74643
+ return {
74644
+ mediaCameraIds: await resolveLinkedMediaCameras(cache, data),
74645
+ rowCameraIds: rows.cameraIds
74646
+ };
74647
+ }
74648
+ /**
74649
+ * A sensor slice as a row's `value`: the object it is, or `null` for anything
74650
+ * else (absent, a primitive, an array) — one narrowing for every sensor event
74651
+ * built from a `DeviceStateChanged`.
74652
+ */
74653
+ function sensorSliceRecord(slice) {
74654
+ return isSliceRecord(slice) ? slice : null;
74655
+ }
74656
+ function isSliceRecord(slice) {
74657
+ return typeof slice === "object" && slice !== null && !Array.isArray(slice);
74658
+ }
74659
+ /**
73795
74660
  * One `DeviceStateChanged` → N history rows, one per camera the event belongs
73796
74661
  * to: the emitting device's LINKED cameras, plus the emitting device ITSELF
73797
74662
  * when it is a camera carrying the sensor cap (a doorbell wrapper bound on
@@ -73807,8 +74672,7 @@ async function ingestSensorStateChange(deps, data, timestamp) {
73807
74672
  const descriptor = EVENT_KIND_BY_CAP[data.capName];
73808
74673
  if (descriptor === void 0) return 0;
73809
74674
  const { cameraIds, dropReason, linkedCameraIds } = await resolveSensorEventCameras(deps.cache, data);
73810
- const slice = data.slice;
73811
- const value = slice !== null && slice !== void 0 && typeof slice === "object" && !Array.isArray(slice) ? slice : null;
74675
+ const value = sensorSliceRecord(data.slice);
73812
74676
  const makeId = deps.makeId ?? (() => `pa-sensor-${randomUUID()}`);
73813
74677
  if (dropReason === "no-camera" && deps.onUnowned !== void 0) {
73814
74678
  const ev = {
@@ -74787,8 +75651,14 @@ var MotionEventSnapshotCapturer = class {
74787
75651
  /**
74788
75652
  * Project one `DeviceStateChanged` of a mapped sensor/control cap into a
74789
75653
  * synthetic track per camera the event belongs to. Returns the number of
74790
- * markers that landed. Best-effort (telemetry, D8) — a materializer that
74791
- * throws propagates to the caller, which logs it.
75654
+ * markers that landed. Best-effort (telemetry, D8).
75655
+ *
75656
+ * Every camera is materialized CONCURRENTLY — the notification about this
75657
+ * event waits for the markers, so N cameras cost one snapshot bound, not N —
75658
+ * and reported afterwards in camera order. A materializer that throws for one
75659
+ * camera no longer propagates to the caller (where it was logged against the
75660
+ * SENSOR): it is reported as `not-materialized` on ITS camera, carrying the
75661
+ * rejection's message in `error`, and the other cameras still land.
74792
75662
  */
74793
75663
  async function projectSensorMarkers(deps, data, timestamp) {
74794
75664
  const descriptor = EVENT_KIND_BY_CAP[data.capName];
@@ -74814,26 +75684,30 @@ async function projectSensorMarkers(deps, data, timestamp) {
74814
75684
  return 0;
74815
75685
  }
74816
75686
  const producingDeviceName = await deps.producingDeviceName(data.deviceId);
75687
+ const settled = await Promise.allSettled(cameraIds.map((cameraId) => deps.markers.materialize({
75688
+ cameraId,
75689
+ sourceDeviceId: data.deviceId,
75690
+ source: "sensor",
75691
+ kind: descriptor.kind,
75692
+ timestamp,
75693
+ snapshotTimeoutMs: deps.snapshotTimeoutMs ?? 2500,
75694
+ ...producingDeviceName !== void 0 ? { producingDeviceName } : {}
75695
+ })));
74817
75696
  let landed = 0;
74818
- for (const cameraId of cameraIds) {
74819
- const track = await deps.markers.materialize({
74820
- cameraId,
74821
- sourceDeviceId: data.deviceId,
74822
- source: "sensor",
74823
- kind: descriptor.kind,
74824
- timestamp,
74825
- snapshotTimeoutMs: deps.snapshotTimeoutMs ?? 2500,
74826
- ...producingDeviceName !== void 0 ? { producingDeviceName } : {}
74827
- });
75697
+ settled.forEach((outcome, i) => {
75698
+ const cameraId = cameraIds[i];
75699
+ if (cameraId === void 0) return;
75700
+ const track = outcome.status === "fulfilled" ? outcome.value : null;
74828
75701
  if (track === null) {
74829
75702
  deps.onSkipped({
74830
75703
  cameraId,
74831
75704
  sourceDeviceId: data.deviceId,
74832
75705
  capName: data.capName,
74833
75706
  kind: descriptor.kind,
74834
- reason: "not-materialized"
75707
+ reason: "not-materialized",
75708
+ ...outcome.status === "rejected" ? { error: errMsg$1(outcome.reason) } : {}
74835
75709
  });
74836
- continue;
75710
+ return;
74837
75711
  }
74838
75712
  landed++;
74839
75713
  deps.onProjected({
@@ -74845,7 +75719,7 @@ async function projectSensorMarkers(deps, data, timestamp) {
74845
75719
  selfCamera: cameraId === data.deviceId,
74846
75720
  hasMedia: track.snapshots.length > 0
74847
75721
  });
74848
- }
75722
+ });
74849
75723
  return landed;
74850
75724
  }
74851
75725
  //#endregion
@@ -79994,6 +80868,14 @@ var RETRAIN_EXPORT_MAX = 2e4;
79994
80868
  * surface to turn the refinement pipeline on/off for a camera.
79995
80869
  */
79996
80870
  var TTL_SWEEP_INTERVAL_MS = 5e3;
80871
+ /**
80872
+ * How long shutdown waits for the sensor handlers already running. Just above
80873
+ * the marker's bounded snapshot fetch (`SENSOR_MARKER_SNAPSHOT_TIMEOUT_MS`,
80874
+ * 2.5 s), the one long await a handler makes, and BELOW the kernel's SIGKILL
80875
+ * fallback (process-service.ts: 3 s after SIGTERM on the stop-all path, 5 s on
80876
+ * the others) — a longer drain would be killed mid-way and log nothing.
80877
+ */
80878
+ var SENSOR_HANDLER_DRAIN_MS = 2700;
79997
80879
  /** Throttle for the group-shot gate's window line (D470). */
79998
80880
  var GROUP_SHOT_METRIC_INTERVAL_MS = 3e4;
79999
80881
  /**
@@ -81064,6 +81946,14 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
81064
81946
  getNativeKeyFrameRgb = null;
81065
81947
  shuttingDown = false;
81066
81948
  /**
81949
+ * Sensor handlers the bus dispatched under `void` that have not finished.
81950
+ * `onShutdown` DRAINS them before it tears anything down: a handler awaits a
81951
+ * snapshot for its marker before its last context read, and one left running
81952
+ * across the teardown threw "ctx accessed before initialize()" as an
81953
+ * unhandled rejection with no device on it.
81954
+ */
81955
+ sensorHandlersInFlight = /* @__PURE__ */ new Set();
81956
+ /**
81067
81957
  * Per-device delivery-lag windows, folded at `handleInferenceResult` entry.
81068
81958
  * The instrument that tells a late DELIVERY (starved) apart from a slow
81069
81959
  * `processFrame` (waiting) — see `frame-lag-observer.ts`. Costs one map read
@@ -81610,7 +82500,7 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
81610
82500
  onMotionAnalysis: (deviceId, data, timestampMs) => this.handleMotionAnalysis(deviceId, data, timestampMs),
81611
82501
  onOnboardMotion: (deviceId, data, timestampMs) => this.handleOnboardMotion(deviceId, data, timestampMs),
81612
82502
  onBatterySample: (deviceId, percentage, at) => this.lts?.note("battery", deviceId, percentage, at),
81613
- onSensorStateChanged: (data, timestampMs) => this.handleSensorStateChanged(data, timestampMs),
82503
+ onSensorStateChanged: (data, timestampMs) => this.dispatchSensorStateChanged(data, timestampMs),
81614
82504
  onBindingsChanged: (data) => this.handleBindingsChangedEvent(data),
81615
82505
  onDeviceUnregistered: (deviceId) => this.handleDeviceUnregisteredEvent(deviceId),
81616
82506
  onClusterTopology: (nodes) => this.nodeLiveness.applySnapshot(nodes, Date.now()),
@@ -82300,10 +83190,13 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
82300
83190
  snapshot: { getSnapshot: (i) => api.snapshot.getSnapshot.query(i) },
82301
83191
  media: mediaStore,
82302
83192
  tracks: trackStore,
82303
- onError: (scope, err) => logger.warn("synthetic track error", { meta: {
82304
- scope,
82305
- error: errMsg$1(err)
82306
- } })
83193
+ onError: (scope, err, deviceId) => logger.warn("synthetic track error", {
83194
+ ...deviceId !== void 0 ? { tags: { deviceId } } : {},
83195
+ meta: {
83196
+ scope,
83197
+ error: errMsg$1(err)
83198
+ }
83199
+ })
82307
83200
  });
82308
83201
  this.syntheticTracks = syntheticTracks;
82309
83202
  this.motionEventSnapshots = new MotionEventSnapshotCapturer({
@@ -82610,6 +83503,7 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
82610
83503
  },
82611
83504
  isDeviceDeleted: async (deviceId) => await api.deviceManager.getDevice.query({ deviceId }) === null,
82612
83505
  getSnapshot: (deviceId) => api.snapshot.getSnapshot.query({ deviceId }),
83506
+ ...buildLinkedSnapshotReads(api.snapshot),
82613
83507
  identityIdsByName: async () => {
82614
83508
  const byName = /* @__PURE__ */ new Map();
82615
83509
  const identities = await stores.identityStore.listIdentities();
@@ -84145,7 +85039,7 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
84145
85039
  this.alarmPanelConvergence = null;
84146
85040
  for (const unsub of this.subscriptionUnsubs) unsub();
84147
85041
  this.subscriptionUnsubs = [];
84148
- await this.motionEpisodes?.closeAll("addon-shutdown");
85042
+ await Promise.all([this.drainSensorHandlers(), this.motionEpisodes?.closeAll("addon-shutdown")]);
84149
85043
  await this.embeddingDispatcher?.stop();
84150
85044
  this.embeddingDispatcher = null;
84151
85045
  await this.notificationCenter?.stop();
@@ -88455,11 +89349,118 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
88455
89349
  return this.queryFacade.getSensorEvents(input);
88456
89350
  }
88457
89351
  /**
89352
+ * Start the linked set's capture (ADR 0632) with the UNFILTERED camera list:
89353
+ * the centre applies the mute itself and says so per camera.
89354
+ */
89355
+ startLinkedStills(data, timestamp, cameras) {
89356
+ const sensorKind = EVENT_KIND_BY_CAP[data.capName]?.kind;
89357
+ if (sensorKind === void 0) return void 0;
89358
+ try {
89359
+ return this.notificationCenter?.beginLinkedStills({
89360
+ sensorId: data.deviceId,
89361
+ sensorKind,
89362
+ cameraIds: cameras.mediaCameraIds,
89363
+ rowCameraIds: cameras.rowCameraIds,
89364
+ atMs: timestamp
89365
+ });
89366
+ } catch (err) {
89367
+ this.ctx.logger.warn("linked-camera capture could not start — the notification ships without it", {
89368
+ tags: { deviceId: data.deviceId },
89369
+ meta: {
89370
+ capName: data.capName,
89371
+ cameraIds: [...cameras.mediaCameraIds],
89372
+ error: errMsg$1(err)
89373
+ }
89374
+ });
89375
+ return;
89376
+ }
89377
+ }
89378
+ /**
89379
+ * The ONE sensor-level evaluation of a linked set (ADR 0632). Its record is
89380
+ * the primary camera's row when that camera has one, else another camera's
89381
+ * row, else a camera-attributed event that is NOT a timeline row (no linking
89382
+ * camera opted the sensor in). The cameras that took a row travel as
89383
+ * `rowCameraIds`: with the sensor, what a camera-scoped rule matches on
89384
+ * (D630 §6).
89385
+ */
89386
+ handOffLinkedSensorEvent(input) {
89387
+ const { data, timestamp, persisted } = input;
89388
+ const primary = input.mediaCameraIds[0];
89389
+ if (primary === void 0) return;
89390
+ const event = persisted.find((ev) => ev.deviceId === primary) ?? persisted[0] ?? {
89391
+ id: `pa-sensor-${randomUUID()}`,
89392
+ deviceId: primary,
89393
+ sourceDeviceId: data.deviceId,
89394
+ kind: EVENT_KIND_BY_CAP[data.capName]?.kind ?? data.capName,
89395
+ value: sensorSliceRecord(data.slice),
89396
+ timestamp
89397
+ };
89398
+ const markerTrackId = input.markerByCamera.get(primary)?.trackId;
89399
+ try {
89400
+ this.notificationCenter?.onSensorEvent({
89401
+ event: {
89402
+ ...event,
89403
+ deviceId: primary
89404
+ },
89405
+ linkedCameraIds: input.mediaCameraIds,
89406
+ rowCameraIds: persisted.map((ev) => ev.deviceId),
89407
+ ...markerTrackId !== void 0 ? { markerTrackId } : {},
89408
+ ...input.linkedStills !== void 0 ? { linkedStills: input.linkedStills } : {}
89409
+ });
89410
+ } catch (err) {
89411
+ this.ctx.logger.warn("sensor event hand-off to the notification centre failed", {
89412
+ tags: { deviceId: data.deviceId },
89413
+ meta: {
89414
+ kind: event.kind,
89415
+ linkedCameraIds: [...input.mediaCameraIds],
89416
+ error: errMsg$1(err)
89417
+ }
89418
+ });
89419
+ }
89420
+ }
89421
+ /**
89422
+ * The bus's door into {@link handleSensorStateChanged}: tracked, so shutdown
89423
+ * can drain it, and caught, so a throw is a tagged line instead of an
89424
+ * unhandled rejection. Refused once shutdown began.
89425
+ */
89426
+ dispatchSensorStateChanged(data, timestampMs) {
89427
+ if (this.shuttingDown) return Promise.resolve();
89428
+ const run = this.handleSensorStateChanged(data, timestampMs).catch((err) => {
89429
+ this.ctxIfReady?.logger.warn("sensor state change handler failed", {
89430
+ tags: { deviceId: data.deviceId },
89431
+ meta: {
89432
+ capName: data.capName,
89433
+ error: errMsg$1(err)
89434
+ }
89435
+ });
89436
+ });
89437
+ this.sensorHandlersInFlight.add(run);
89438
+ run.finally(() => this.sensorHandlersInFlight.delete(run));
89439
+ return run;
89440
+ }
89441
+ /** Wait for the in-flight sensor handlers, bounded — see {@link sensorHandlersInFlight}. */
89442
+ async drainSensorHandlers() {
89443
+ const inFlight = [...this.sensorHandlersInFlight];
89444
+ if (inFlight.length === 0) return;
89445
+ let timer;
89446
+ const bound = new Promise((resolve) => {
89447
+ timer = setTimeout(() => resolve("bound"), SENSOR_HANDLER_DRAIN_MS);
89448
+ });
89449
+ const outcome = await Promise.race([Promise.all(inFlight).then(() => "drained"), bound]);
89450
+ clearTimeout(timer);
89451
+ if (outcome === "bound") this.ctx.logger.warn("shutdown: sensor handlers still running past the drain bound", { meta: {
89452
+ inFlight: this.sensorHandlersInFlight.size,
89453
+ boundMs: SENSOR_HANDLER_DRAIN_MS
89454
+ } });
89455
+ }
89456
+ /**
88458
89457
  * Sensor-event ingest handler — `DeviceStateChanged` of a device exposing a
88459
89458
  * mapped sensor cap. Resolves the camera set through the TTL cache (the
88460
89459
  * device's LINKED cameras, plus the device itself when it is a camera
88461
89460
  * carrying the cap) and inserts ONE row per camera. Best-effort
88462
- * (telemetry-lossy).
89461
+ * (telemetry-lossy). The NOTIFICATION is not per row when more than one
89462
+ * camera links the sensor (ADR 0632): the linked cameras are photographed at
89463
+ * the trigger and the change is evaluated ONCE, at the sensor level.
88463
89464
  *
88464
89465
  * D8/D11 boundary, stated plainly: the ride here is the LOSSY
88465
89466
  * `DeviceStateChanged` bus, and there is no reconcile that replays a missed
@@ -88480,6 +89481,18 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
88480
89481
  const cache = this.linkedCamerasCache;
88481
89482
  if (store === null || cache === null) return;
88482
89483
  if (!this.sensorSliceCarriedATransition(data)) return;
89484
+ const cameras = await resolveSensorNotificationCameras(cache, data).catch((err) => {
89485
+ this.ctx.logger.debug("linked-camera resolution failed — evaluated per row", {
89486
+ tags: { deviceId: data.deviceId },
89487
+ meta: {
89488
+ capName: data.capName,
89489
+ error: errMsg$1(err)
89490
+ }
89491
+ });
89492
+ return NO_SENSOR_NOTIFICATION_CAMERAS;
89493
+ });
89494
+ const linkedSet = cameras.mediaCameraIds.length > 1;
89495
+ const linkedStills = linkedSet ? this.startLinkedStills(data, timestamp, cameras) : void 0;
88483
89496
  const persisted = [];
88484
89497
  const unowned = [];
88485
89498
  try {
@@ -88524,18 +89537,33 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
88524
89537
  }
88525
89538
  });
88526
89539
  },
88527
- onSkipped: (r) => this.ctx.logger.debug("synthetic sensor-track NOT projected", {
88528
- tags: { deviceId: r.cameraId ?? r.sourceDeviceId },
88529
- meta: {
88530
- capName: r.capName,
88531
- kind: r.kind,
88532
- reason: r.reason,
88533
- sourceDeviceId: r.sourceDeviceId
89540
+ onSkipped: (r) => {
89541
+ if (r.error !== void 0) {
89542
+ this.ctx.logger.warn("synthetic sensor-track materialize rejected", {
89543
+ tags: { deviceId: r.cameraId ?? r.sourceDeviceId },
89544
+ meta: {
89545
+ capName: r.capName,
89546
+ kind: r.kind,
89547
+ reason: r.reason,
89548
+ sourceDeviceId: r.sourceDeviceId,
89549
+ error: r.error
89550
+ }
89551
+ });
89552
+ return;
88534
89553
  }
88535
- })
89554
+ this.ctx.logger.debug("synthetic sensor-track NOT projected", {
89555
+ tags: { deviceId: r.cameraId ?? r.sourceDeviceId },
89556
+ meta: {
89557
+ capName: r.capName,
89558
+ kind: r.kind,
89559
+ reason: r.reason,
89560
+ sourceDeviceId: r.sourceDeviceId
89561
+ }
89562
+ });
89563
+ }
88536
89564
  }, data, timestamp);
88537
89565
  } catch (err) {
88538
- this.ctx.logger.warn("synthetic sensor-track materialize failed", {
89566
+ this.ctx.logger.warn("synthetic sensor-track camera resolution / producing-device name read failed", {
88539
89567
  tags: { deviceId: data.deviceId },
88540
89568
  meta: {
88541
89569
  capName: data.capName,
@@ -88543,6 +89571,17 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
88543
89571
  }
88544
89572
  });
88545
89573
  }
89574
+ if (linkedSet) {
89575
+ this.handOffLinkedSensorEvent({
89576
+ data,
89577
+ timestamp,
89578
+ mediaCameraIds: cameras.mediaCameraIds,
89579
+ persisted,
89580
+ markerByCamera,
89581
+ ...linkedStills !== void 0 ? { linkedStills } : {}
89582
+ });
89583
+ return;
89584
+ }
88546
89585
  for (const ev of persisted) try {
88547
89586
  this.notificationCenter?.onSensorEventPersisted(ev, markerByCamera.get(ev.deviceId)?.trackId);
88548
89587
  } catch (err) {