@camstack/addon-post-analysis 1.2.281 → 1.2.282

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_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";
1
+ import { $t as systemEventFamilyProblems, A as NC_SENSOR_TRANSITION_KINDS, At as isEventOwnerType, B as NcRuleSchema, C as MAX_BIRTH_DECISION_RECORDS, Cn as partialRecord, Ct as encodeVectorBase64, D as NC_ALARM_SYSTEM_EVENT_KINDS, Dn as EventCategory, Dt as failureContributionCapability, E as MediaFileKindEnum, En as unknown, Et as faceGalleryCapability, F as NcAlarmSettingsPatchSchema, Ft as isSystemDelivery, G as NcSnoozeSchema, Gt as readTimelapseGeneratedAt, H as NcRuleUpdateInputSchema, Ht as pipelineAnalyticsCapability, I as NcConditionDescriptorSchema, It as kebabToCamel, J as NcTaxonomySchema, Jt as ruleBelongsToAlarm$1, K as NcSnoozeSuppressedSchema, Kt as resolveLocationMode, L as NcConditionsSchema, Lt as mayWriteToLocation, M as NC_TAXONOMY, Mt as isMediaOwnerType, N as NC_TEMPLATE_VARS, Nt as isScheduleActive, O as NC_CONDITION_CATALOG, P as NcAlarmConfigSchema, Pt as isSourceCap, Q as OpsLogEntrySchema, Qt as subKindsOf, R as NcRuleInputSchema, Rt as notificationRulesCapability, S as MAX_ARCHIVED_DEBUG_NOTES, Sn as object, T as MOTION_CLOSE_AFTER_MS, Tn as string, Tt as evictionPolicyOfLocation, U as NcScheduleSchema, Ut as plateGalleryCapability, V as NcRuleTargetSchema, Vt as pickClusterStepModels, W as NcSnoozeInputSchema, Wt as readDeviceStateFrom, X as NcTemplatePreviewSchema, Xt as sensorRaisedOf, Y as NcTemplatePreviewInputSchema, Yt as sceneMonitorCapability, Z as NcTemplateVarDescriptorSchema, Zt as storageOccupancyCapability, _ as FIRST_LEVEL_MACRO_CLASSES, _n as array, _t as audioModeOf, a as BirthDecisionRecordSchema, an as canonicalHash, at as SCENE_DEFAULT_UNCOVERED_POLICY, b as LabelAttributionSchema, bn as literal, bt as deriveRecordingMode, c as CoverStateSchema, cn as DeviceType, ct as TIMELAPSE_DENSE_FLOOR_SEC, dn as defineCustomActions, dt as TimelapseRuleSchema, en as systemEventFilterApplies, et as RECORDING_EXPORT_MAX_READ_BYTES, f as DETECTION_MACRO_CLASSES, fn as hydrateSchema, ft as TrackSourceSchema, g as EVENT_OWNER_TYPES, gn as _enum, gt as audioMetricsCapability, h as EVENT_KIND_BY_CAP, hn as sleep$1, ht as assertTimelapseCadences, i as BaseDevice, in as errMsg$1, it as SCENE_DEFAULT_ANCHOR_THRESHOLD, jt as isKnownTemplateVar, k as NC_DEFAULT_SNOOZE_MINUTES, kt as isDetectionMacroClass, l as DEFAULT_EVENT_COLOR, ln as createEvent, lt as TimelapseRuleInputSchema, m as DeclaredDevices, mn as nodePin, mt as alarmPanelCapability, n as AlarmArmModeSchema, nn as vectorDimFromBase64, nt as RetrainStatusSchema, o as CLUSTER_MODEL_SCOPED_STEPS, on as BaseAddon, ot as SCENE_DIVERGED, pn as isDeviceScopedCap, pt as addonWidgetsSourceCapability, q as NcSystemEventKindSchema, r as ArchivedDebugNoteSchema, rn as zoneAnalyticsCapability, s as COCO_TO_MACRO, sn as CamProfileSchema, st as SceneMonitorSchema, t as AUDIO_MACRO_LABELS, tn as templateVarsFor, tt as RECORDING_SOURCE_CAMSTACK_ADDON, un as customAction, ut as TimelapseRulePatchSchema, v as FULL_IMAGE_BBOX, vn as boolean, vt as buildEventKindDescriptor, wn as record, wt as evaluateSensorEdge, x as LockStateSchema, xn as number, xt as describeSystemEventFamilyProblem, y as FailureCounters, yn as discriminatedUnion, yt as cosineSimilarity$1, z as NcRulePatchSchema, zt as occupancyScope } from "../dist-C3zTKShG.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";
@@ -281,6 +281,7 @@ function coverageFor(rules, panelDeviceId, mode, skipProbe) {
281
281
  const ids = /* @__PURE__ */ new Set();
282
282
  let ruleCount = 0;
283
283
  let allDevices = false;
284
+ let except = null;
284
285
  for (const rule of rules) {
285
286
  if (!rule.enabled) continue;
286
287
  const gate = rule.conditions.deviceState;
@@ -288,21 +289,35 @@ function coverageFor(rules, panelDeviceId, mode, skipProbe) {
288
289
  if (!gate.states.includes(wanted)) continue;
289
290
  ruleCount += 1;
290
291
  const scope = rule.conditions.devices;
292
+ const excluded = rule.conditions.devicesExclude ?? [];
291
293
  if (scope === void 0 || scope.length === 0) {
292
294
  allDevices = true;
295
+ except = narrowExcept(except, excluded);
293
296
  continue;
294
297
  }
295
- for (const id of scope) ids.add(id);
298
+ for (const id of scope) if (!excluded.includes(id)) ids.add(id);
296
299
  }
297
300
  const deviceIds = [...ids].toSorted((a, b) => a - b);
301
+ const allDevicesExcept = except === null || except === "none" ? [] : [...except].filter((id) => !ids.has(id)).toSorted((a, b) => a - b);
298
302
  return {
299
303
  mode,
300
304
  ruleCount,
301
305
  allDevices,
306
+ allDevicesExcept,
302
307
  deviceIds,
303
308
  skippedDevices: skippedFor(deviceIds, skipProbe)
304
309
  };
305
310
  }
311
+ /**
312
+ * Fold one UNSCOPED covering rule into the except-set: with no exclusion it
313
+ * covers everything (`'none'`, final); otherwise only what every unscoped rule
314
+ * excludes stays uncovered — the intersection.
315
+ */
316
+ function narrowExcept(current, excluded) {
317
+ if (current === "none" || excluded.length === 0) return "none";
318
+ if (current === null) return new Set(excluded);
319
+ return new Set(excluded.filter((id) => current.has(id)));
320
+ }
306
321
  function skippedFor(deviceIds, skipProbe) {
307
322
  if (skipProbe === void 0) return [];
308
323
  const out = [];
@@ -376,6 +391,19 @@ function buildArmAnnouncement(coverage, deviceName, bypassed = []) {
376
391
  textCount: names.length
377
392
  };
378
393
  }
394
+ if (coverage.allDevices && coverage.allDevicesExcept.length > 0) {
395
+ const names = coverage.allDevicesExcept.map(deviceName);
396
+ return {
397
+ bodyKey: "alarm.arm.body.allDevicesExcept",
398
+ textParams: {
399
+ rules,
400
+ mode,
401
+ count: `${names.length}`,
402
+ names: names.join(", ")
403
+ },
404
+ textCount: names.length
405
+ };
406
+ }
379
407
  if (coverage.allDevices) return {
380
408
  bodyKey: "alarm.arm.body.allDevices",
381
409
  textParams: {
@@ -594,7 +622,8 @@ function alarmCoveredDevices(rules, panelDeviceId, mode) {
594
622
  if (!gate.states.includes(wanted)) continue;
595
623
  const scope = rule.conditions.devices;
596
624
  if (scope === void 0 || scope.length === 0) continue;
597
- for (const id of scope) ids.add(id);
625
+ const excluded = rule.conditions.devicesExclude ?? [];
626
+ for (const id of scope) if (!excluded.includes(id)) ids.add(id);
598
627
  }
599
628
  return [...ids].toSorted((a, b) => a - b);
600
629
  }
@@ -7788,10 +7817,10 @@ function replaceConditionReferences(monitor, condition, reference) {
7788
7817
  //#endregion
7789
7818
  //#region src/notification-center/snooze-exempt.ts
7790
7819
  /** The cap whose presence in a sequence makes the rule the alarm's trigger. */
7791
- var ALARM_PANEL_CAP$2 = "alarm-panel";
7820
+ var ALARM_PANEL_CAP$1 = "alarm-panel";
7792
7821
  var ALARM_KINDS = new Set(NC_ALARM_SYSTEM_EVENT_KINDS);
7793
7822
  function actuatesAlarmPanel(sequences) {
7794
- for (const sequence of sequences) for (const step of sequence.actions) if (step.kind === "cap" && step.cap === ALARM_PANEL_CAP$2) return true;
7823
+ for (const sequence of sequences) for (const step of sequence.actions) if (step.kind === "cap" && step.cap === ALARM_PANEL_CAP$1) return true;
7795
7824
  return false;
7796
7825
  }
7797
7826
  /**
@@ -9556,6 +9585,7 @@ function isLinkedSetSubject(subject) {
9556
9585
  function subjectFromSensorEvent(ev, markerTrackId, linked) {
9557
9586
  const eventType = readSensorEventType(ev.value);
9558
9587
  const sensorEdge = sensorEdgeOf(ev.kind, ev.value);
9588
+ const sensorRaised = sensorRaisedOf(ev.kind, ev.value);
9559
9589
  return {
9560
9590
  kind: "device-event",
9561
9591
  recordId: String(ev.id),
@@ -9569,6 +9599,7 @@ function subjectFromSensorEvent(ev, markerTrackId, linked) {
9569
9599
  sensorKind: ev.kind,
9570
9600
  ...eventType !== void 0 ? { eventType } : {},
9571
9601
  ...sensorEdge !== void 0 ? { sensorEdge } : {},
9602
+ ...sensorRaised !== void 0 ? { sensorRaised } : {},
9572
9603
  ...linked !== void 0 ? {
9573
9604
  linkedSensorId: ev.sourceDeviceId,
9574
9605
  linkedCameraIds: [...linked.linkedCameraIds],
@@ -9796,6 +9827,7 @@ function presentConditionIds(c) {
9796
9827
  const ids = [];
9797
9828
  if (c.systemEvent !== void 0) ids.push("systemEvent");
9798
9829
  if (c.devices !== void 0 && c.devices.length > 0) ids.push("devices");
9830
+ if (c.devicesExclude !== void 0 && c.devicesExclude.length > 0) ids.push("devicesExclude");
9799
9831
  if (c.source !== void 0 && c.source !== "any") ids.push("source");
9800
9832
  if (c.classes !== void 0 && c.classes.length > 0) ids.push("classes");
9801
9833
  if (c.classesExclude !== void 0 && c.classesExclude.length > 0) ids.push("classesExclude");
@@ -9812,6 +9844,7 @@ function presentConditionIds(c) {
9812
9844
  if (c.plates !== void 0) ids.push("plates");
9813
9845
  if (c.sensorKinds !== void 0 && c.sensorKinds.length > 0) ids.push("sensorKinds");
9814
9846
  if (c.eventTypeTokens !== void 0 && c.eventTypeTokens.length > 0) ids.push("eventTypeTokens");
9847
+ if (c.sensorTransition !== void 0) ids.push("sensorTransition");
9815
9848
  if (c.packagePhase !== void 0 && c.packagePhase !== "both") ids.push("packagePhase");
9816
9849
  if (c.customZones !== void 0 && c.customZones.length > 0) ids.push("customZones");
9817
9850
  if (c.occupancy !== void 0) ids.push("occupancy");
@@ -9906,12 +9939,23 @@ function zonesVisitedBy(subject) {
9906
9939
  * before the subject exists (the linked-camera capture gate). Coarse in one
9907
9940
  * direction only: `true` may still fail later; `false` never would have fired.
9908
9941
  */
9909
- function deviceEventRuleMayFireOnKind(rule, sensorKind) {
9942
+ function deviceEventRuleMayFireOnKind(rule, sensorKind, sensorRaised) {
9910
9943
  const c = rule.conditions;
9911
9944
  if (c.occupancy !== void 0) return false;
9945
+ if (sensorTransitionRefuses(c.sensorTransition, sensorRaised)) return false;
9912
9946
  if (c.sensorKinds === void 0 || c.sensorKinds.length === 0) return true;
9913
9947
  return toLowerSet(c.sensorKinds).has(sensorKind.trim().toLowerCase());
9914
9948
  }
9949
+ /**
9950
+ * Does a rule's `sensorTransition` refuse this edge? Only a KNOWN edge can be
9951
+ * refused: `sensorRaised === undefined` (a kind with no raise, an unreadable
9952
+ * slice) always passes — a flood rule never goes silent on a bad read (D640).
9953
+ * The ONE predicate {@link evaluateRule} and the capture gate both ask.
9954
+ */
9955
+ function sensorTransitionRefuses(transition, sensorRaised) {
9956
+ if (transition === void 0 || sensorRaised === void 0) return false;
9957
+ return sensorRaised !== (transition === "raised");
9958
+ }
9915
9959
  function toLowerSet(values) {
9916
9960
  return new Set(values.map((v) => v.trim().toLowerCase()));
9917
9961
  }
@@ -9948,9 +9992,6 @@ function matchesIdentityScope(subject, scope) {
9948
9992
  function ruleAwaitsRecognition(rule) {
9949
9993
  return rule.conditions.identities !== void 0 || rule.conditions.plates !== void 0;
9950
9994
  }
9951
- var ARMED_WORD_PREFIX = "armed_";
9952
- var ALARM_PANEL_CAP$1 = "alarm-panel";
9953
- var ALARM_PANEL_TRIGGER_METHOD = "trigger";
9954
9995
  /**
9955
9996
  * A rule "belongs to the alarm" when its `deviceState` gate names ANY device's
9956
9997
  * `armed_*` word — CamStack's own panel or any other, e.g. an HA-mirrored
@@ -9968,8 +10009,10 @@ var ALARM_PANEL_TRIGGER_METHOD = "trigger";
9968
10009
  * catalog on save, so a new key would be dropped by the first edit.
9969
10010
  */
9970
10011
  function ruleBelongsToAlarm(rule) {
9971
- if ((rule.conditions.deviceState?.states ?? []).some((s) => s.trim().toLowerCase().startsWith(ARMED_WORD_PREFIX))) return true;
9972
- return (rule.actions?.onTrigger ?? []).some((sequence) => sequence.enabled && sequence.actions.some((step) => step.kind === "cap" && step.cap === ALARM_PANEL_CAP$1 && step.method === ALARM_PANEL_TRIGGER_METHOD));
10012
+ return ruleBelongsToAlarm$1({
10013
+ conditions: rule.conditions,
10014
+ ...rule.actions?.onTrigger !== void 0 ? { onTrigger: rule.actions.onTrigger } : {}
10015
+ });
9973
10016
  }
9974
10017
  /**
9975
10018
  * The edge an alarm rule refuses: a close, or a halt/fault (a cover stopped
@@ -10145,6 +10188,7 @@ function evaluateRule(rule, subject, deviceState, zoneOwners, sceneStates) {
10145
10188
  if (!sceneConditionSatisfied(c.scene, observation)) return fail("scene");
10146
10189
  }
10147
10190
  if (c.devices !== void 0 && c.devices.length > 0 && !matchesDeviceScope(c.devices, subject)) return fail("devices");
10191
+ if (c.devicesExclude !== void 0 && deviceScopeExcludes(c.devicesExclude, subject)) return fail("devicesExclude");
10148
10192
  if (c.source !== void 0 && c.source !== "any") {
10149
10193
  if ((subject.source ?? "pipeline") !== c.source) return fail("source");
10150
10194
  }
@@ -10221,6 +10265,7 @@ function evaluateRule(rule, subject, deviceState, zoneOwners, sceneStates) {
10221
10265
  if (c.eventTypeTokens !== void 0 && c.eventTypeTokens.length > 0) {
10222
10266
  if (subject.eventType === void 0 || !toLowerSet(c.eventTypeTokens).has(subject.eventType.trim().toLowerCase())) return fail("eventTypeTokens");
10223
10267
  }
10268
+ if (sensorTransitionRefuses(c.sensorTransition, subject.sensorRaised)) return fail("sensorTransition");
10224
10269
  if (c.packagePhase !== void 0 && c.packagePhase !== "both") {
10225
10270
  if (subject.packagePhase !== c.packagePhase) return fail("packagePhase");
10226
10271
  }
@@ -10326,6 +10371,27 @@ function matchesDeviceScope(scope, subject) {
10326
10371
  if (scope.includes(subject.deviceId)) return true;
10327
10372
  return subject.sourceDeviceId !== void 0 && scope.includes(subject.sourceDeviceId);
10328
10373
  }
10374
+ /**
10375
+ * Does a rule's `devicesExclude` (D641) exclude this subject?
10376
+ *
10377
+ * The exact complement of {@link matchesDeviceScope}, row by row. A per-camera
10378
+ * row is excluded by its camera OR by the sensor that fired, the two ids an
10379
+ * include matches on. A linked set stands for the rows it replaced (D630 §6):
10380
+ * it is excluded when its SENSOR is, or when EVERY camera that took a row is —
10381
+ * one excluded row camera leaves the others firing, as their own rows would
10382
+ * have. A camera that only LINKS the sensor excludes nothing, for the reason it
10383
+ * includes nothing: ADR 0632 widens the media, never which rules fire.
10384
+ */
10385
+ function deviceScopeExcludes(excluded, subject) {
10386
+ if (excluded.length === 0) return false;
10387
+ if (subject.linkedSensorId !== void 0) {
10388
+ if (excluded.includes(subject.linkedSensorId)) return true;
10389
+ const rows = subject.rowCameraIds ?? [];
10390
+ return rows.length > 0 && rows.every((cameraId) => excluded.includes(cameraId));
10391
+ }
10392
+ if (excluded.includes(subject.deviceId)) return true;
10393
+ return subject.sourceDeviceId !== void 0 && excluded.includes(subject.sourceDeviceId);
10394
+ }
10329
10395
  function cooldownKey(rule, subject) {
10330
10396
  const first = subject.classNames[0];
10331
10397
  const classKey = keysPerClass(rule, subject) && first !== void 0 ? `:c:${first}` : "";
@@ -12453,6 +12519,7 @@ var en_default = {
12453
12519
  "snooze.scope.anyRule": "A rule",
12454
12520
  "snooze.scope.rule": "“{{rule}}”",
12455
12521
  "alarm.arm.body.allDevices": "Active: all devices",
12522
+ "alarm.arm.body.allDevicesExcept": "Active: all devices except {{count}}: {{names}}",
12456
12523
  "alarm.arm.body.noDevices": "Active: no device restriction",
12457
12524
  "alarm.arm.body.devices": "Active: {{names}}",
12458
12525
  "alarm.arm.body.devicesSkipped": "Active: {{names}}\nExcluded: {{skippedNames}}",
@@ -12741,6 +12808,7 @@ var it_default = {
12741
12808
  "snooze.scope.anyRule": "Una regola",
12742
12809
  "snooze.scope.rule": "“{{rule}}”",
12743
12810
  "alarm.arm.body.allDevices": "Attivi: tutti i dispositivi",
12811
+ "alarm.arm.body.allDevicesExcept": "Attivi: tutti i dispositivi tranne {{count}}: {{names}}",
12744
12812
  "alarm.arm.body.noDevices": "Attivi: nessuna restrizione",
12745
12813
  "alarm.arm.body.devices": "Attivi: {{names}}",
12746
12814
  "alarm.arm.body.devicesSkipped": "Attivi: {{names}}\nEsclusi: {{skippedNames}}",
@@ -13255,7 +13323,7 @@ function assertPatchKeepsFamily(existing, patch) {
13255
13323
  * the offending kind or filter). Then rethrow. A refused save is dropped work,
13256
13324
  * and dropped work is never silent. Any other error passes through unlogged.
13257
13325
  */
13258
- function logRefusal(check, ctx) {
13326
+ function logRefusal$2(check, ctx) {
13259
13327
  try {
13260
13328
  check();
13261
13329
  } catch (err) {
@@ -13271,11 +13339,115 @@ function logRefusal(check, ctx) {
13271
13339
  }
13272
13340
  /** {@link assertRuleFamily}, logged. The create path of both routes. */
13273
13341
  function guardRuleFamily(rule, ctx) {
13274
- logRefusal(() => assertRuleFamily(rule), ctx);
13342
+ logRefusal$2(() => assertRuleFamily(rule), ctx);
13275
13343
  }
13276
13344
  /** {@link assertPatchKeepsFamily}, logged. The update path of both routes. */
13277
13345
  function guardPatchFamily(existing, patch, ctx) {
13278
- logRefusal(() => assertPatchKeepsFamily(existing, patch), ctx);
13346
+ logRefusal$2(() => assertPatchKeepsFamily(existing, patch), ctx);
13347
+ }
13348
+ //#endregion
13349
+ //#region src/notification-center/device-scope-guard.ts
13350
+ /**
13351
+ * The deliveries whose evaluation reads the device scope — the `devices`
13352
+ * descriptor's `appliesTo`, restated as the one delivery that lacks it.
13353
+ */
13354
+ var NO_DEVICE_SCOPE_DELIVERY = "system-event";
13355
+ var NcDevicesExcludeRefusal = class extends Error {
13356
+ delivery;
13357
+ devicesExclude;
13358
+ constructor(delivery, devicesExclude) {
13359
+ super(`rule refused: devicesExclude does not apply to a '${delivery}' rule — a system rule excludes devices with systemEvent.excludeDeviceIds`);
13360
+ this.name = "NcDevicesExcludeRefusal";
13361
+ this.delivery = delivery;
13362
+ this.devicesExclude = devicesExclude;
13363
+ }
13364
+ };
13365
+ function assertDevicesExcludeDelivery(rule) {
13366
+ const excluded = rule.conditions.devicesExclude;
13367
+ if (excluded === void 0 || rule.delivery !== NO_DEVICE_SCOPE_DELIVERY) return;
13368
+ throw new NcDevicesExcludeRefusal(rule.delivery, excluded);
13369
+ }
13370
+ /** Run the check; a refusal is dropped work, so it is logged before it is rethrown. */
13371
+ function logRefusal$1(check, ctx) {
13372
+ try {
13373
+ check();
13374
+ } catch (err) {
13375
+ if (err instanceof NcDevicesExcludeRefusal) warnRefusal$1(ctx.logger, err, ctx);
13376
+ throw err;
13377
+ }
13378
+ }
13379
+ function warnRefusal$1(logger, err, ctx) {
13380
+ logger.warn("nc rule refused: devicesExclude on a system rule", { meta: {
13381
+ ...ctx.ruleId !== void 0 ? { ruleId: ctx.ruleId } : {},
13382
+ by: ctx.by,
13383
+ delivery: err.delivery,
13384
+ devicesExclude: [...err.devicesExclude]
13385
+ } });
13386
+ }
13387
+ /** The create path of both routes. */
13388
+ function guardRuleDevicesExclude(rule, ctx) {
13389
+ logRefusal$1(() => assertDevicesExcludeDelivery(rule), ctx);
13390
+ }
13391
+ /**
13392
+ * The update path of both routes: the MERGED rule is judged, and only when the
13393
+ * patch changes `delivery` or `conditions`.
13394
+ */
13395
+ function guardPatchDevicesExclude(existing, patch, ctx) {
13396
+ if (existing === null) return;
13397
+ const merged = mergedFamilySubject(existing, patch);
13398
+ if (merged === null) return;
13399
+ logRefusal$1(() => assertDevicesExcludeDelivery(merged), ctx);
13400
+ }
13401
+ //#endregion
13402
+ //#region src/notification-center/sensor-transition-guard.ts
13403
+ /** The one delivery whose subject carries a sensor edge. */
13404
+ var SENSOR_TRANSITION_DELIVERY = "device-event";
13405
+ var NcSensorTransitionRefusal = class extends Error {
13406
+ delivery;
13407
+ sensorTransition;
13408
+ constructor(delivery, sensorTransition) {
13409
+ super(`rule refused: sensorTransition '${sensorTransition}' applies only to a device-event rule, not '${delivery}'`);
13410
+ this.name = "NcSensorTransitionRefusal";
13411
+ this.delivery = delivery;
13412
+ this.sensorTransition = sensorTransition;
13413
+ }
13414
+ };
13415
+ function assertSensorTransitionDelivery(rule) {
13416
+ const transition = rule.conditions.sensorTransition;
13417
+ if (transition === void 0 || rule.delivery === SENSOR_TRANSITION_DELIVERY) return;
13418
+ throw new NcSensorTransitionRefusal(rule.delivery, transition);
13419
+ }
13420
+ /** Run the check; a refusal is dropped work, so it is logged before it is rethrown. */
13421
+ function logRefusal(check, ctx) {
13422
+ try {
13423
+ check();
13424
+ } catch (err) {
13425
+ if (err instanceof NcSensorTransitionRefusal) warnRefusal(ctx.logger, err, ctx);
13426
+ throw err;
13427
+ }
13428
+ }
13429
+ function warnRefusal(logger, err, ctx) {
13430
+ logger.warn("nc rule refused: sensorTransition off a device-event rule", { meta: {
13431
+ ...ctx.ruleId !== void 0 ? { ruleId: ctx.ruleId } : {},
13432
+ by: ctx.by,
13433
+ delivery: err.delivery,
13434
+ sensorTransition: err.sensorTransition
13435
+ } });
13436
+ }
13437
+ /** The create path of both routes. */
13438
+ function guardRuleSensorTransition(rule, ctx) {
13439
+ logRefusal(() => assertSensorTransitionDelivery(rule), ctx);
13440
+ }
13441
+ /**
13442
+ * The update path of both routes: the MERGED rule is judged, and only when the
13443
+ * patch changes `delivery` or `conditions` — an unchanged full-form save
13444
+ * (a rename, a retarget) is not re-judged.
13445
+ */
13446
+ function guardPatchSensorTransition(existing, patch, ctx) {
13447
+ if (existing === null) return;
13448
+ const merged = mergedFamilySubject(existing, patch);
13449
+ if (merged === null) return;
13450
+ logRefusal(() => assertSensorTransitionDelivery(merged), ctx);
13279
13451
  }
13280
13452
  //#endregion
13281
13453
  //#region src/notification-center/occupancy-watcher.ts
@@ -14046,6 +14218,19 @@ function labelFor(conditions) {
14046
14218
  return conditions.identities?.[0] ?? conditions.labelEquals?.[0] ?? conditions.plates?.values?.[0] ?? void 0;
14047
14219
  }
14048
14220
  /**
14221
+ * The edge a planned sensor event carries (D640): RAISED by default — the
14222
+ * alert, which is what an operator pressing Test on a flood rule expects to
14223
+ * see — and cleared only for a rule that asks for the clear. Only for a kind
14224
+ * that HAS a raise: a doorbell press carries none in production, and a test
14225
+ * subject claiming one would be a subject no real record could be.
14226
+ */
14227
+ function sensorEdgeFor(trigger, c) {
14228
+ const kind = c.sensorKinds?.[0];
14229
+ if (trigger !== "device-event" || kind === void 0) return {};
14230
+ if (!NC_SENSOR_TRANSITION_KINDS.includes(kind.trim().toLowerCase())) return {};
14231
+ return { sensorRaised: c.sensorTransition !== "cleared" };
14232
+ }
14233
+ /**
14049
14234
  * Plan the injection that fires this rule.
14050
14235
  *
14051
14236
  * Every condition the engine reads off the SUBJECT is satisfied here; the two
@@ -14067,6 +14252,10 @@ function planRuleTest(input) {
14067
14252
  note: "This rule names no camera and none was supplied, so the event is attributed to no device and carries no picture. Scope the rule to a camera, or pick one, to see the notification it would really send."
14068
14253
  });
14069
14254
  const deviceId = scopedDeviceId ?? NC_SYSTEM_DEVICE_ID;
14255
+ if (trigger !== "system-event" && (c.devicesExclude ?? []).includes(deviceId)) notes.push({
14256
+ conditionId: "devicesExclude",
14257
+ note: "The camera this test uses is one the rule excludes, so the evaluation will refuse the event — exactly as it refuses every real one there. Pick another camera to see the notification."
14258
+ });
14070
14259
  const scheduledAt = scheduleActiveAt(rule.schedule, now);
14071
14260
  if (scheduledAt === null) notes.push({
14072
14261
  conditionId: "schedule",
@@ -14116,6 +14305,7 @@ function planRuleTest(input) {
14116
14305
  } } : {},
14117
14306
  ...c.sensorKinds?.[0] !== void 0 ? { sensorKind: c.sensorKinds[0] } : {},
14118
14307
  ...c.eventTypeTokens?.[0] !== void 0 ? { eventType: c.eventTypeTokens[0] } : {},
14308
+ ...sensorEdgeFor(trigger, c),
14119
14309
  ...trigger === "package-event" ? { packagePhase: c.packagePhase === "picked-up" ? "picked-up" : "delivered" } : {},
14120
14310
  ...c.source !== void 0 && c.source !== "any" ? { source: c.source } : {},
14121
14311
  ...bbox !== void 0 ? { bbox } : {},
@@ -14634,6 +14824,10 @@ var NcTestEventInputSchema = object({
14634
14824
  /** DEVICE-EVENT: the raw device event-type token, when the source is an
14635
14825
  * event-emitter slice. */
14636
14826
  eventType: string().optional(),
14827
+ /** DEVICE-EVENT: which edge a boolean sensor moved on — true raised, false
14828
+ * cleared (D640). Absent = the slice could not say, which production
14829
+ * answers for a doorbell press or an unreadable read. */
14830
+ sensorRaised: boolean().optional(),
14637
14831
  /** PACKAGE-EVENT: which lifecycle phase. */
14638
14832
  packagePhase: _enum(["delivered", "picked-up"]).optional(),
14639
14833
  /** Detection provenance. Absent = `pipeline` (what a record with no stamped
@@ -14852,6 +15046,7 @@ function buildSyntheticEvent(input, recordId, now) {
14852
15046
  ...input.labelConfidence !== void 0 ? { labelConfidence: input.labelConfidence } : {},
14853
15047
  ...input.sensorKind !== void 0 ? { sensorKind: input.sensorKind } : {},
14854
15048
  ...input.eventType !== void 0 ? { eventType: input.eventType } : {},
15049
+ ...input.sensorRaised !== void 0 ? { sensorRaised: input.sensorRaised } : {},
14855
15050
  ...input.packagePhase !== void 0 ? { packagePhase: input.packagePhase } : {},
14856
15051
  ...input.bbox !== void 0 ? { bbox: input.bbox } : {},
14857
15052
  ...input.crossing !== void 0 ? { crossing: input.crossing } : {},
@@ -15982,6 +16177,14 @@ function makeNcActionHandlers(deps) {
15982
16177
  logger: deps.logger,
15983
16178
  by: c.userId
15984
16179
  });
16180
+ guardRuleSensorTransition(parsed, {
16181
+ logger: deps.logger,
16182
+ by: c.userId
16183
+ });
16184
+ guardRuleDevicesExclude(parsed, {
16185
+ logger: deps.logger,
16186
+ by: c.userId
16187
+ });
15985
16188
  await assertTargetsOwned(parsed.targets.map((t) => t.targetId), c);
15986
16189
  const rule = await deps.ruleStore.create({
15987
16190
  ...parsed,
@@ -16005,6 +16208,16 @@ function makeNcActionHandlers(deps) {
16005
16208
  by: c.userId,
16006
16209
  ruleId: input.ruleId
16007
16210
  });
16211
+ guardPatchSensorTransition(existing, patch, {
16212
+ logger: deps.logger,
16213
+ by: c.userId,
16214
+ ruleId: input.ruleId
16215
+ });
16216
+ guardPatchDevicesExclude(existing, patch, {
16217
+ logger: deps.logger,
16218
+ by: c.userId,
16219
+ ruleId: input.ruleId
16220
+ });
16008
16221
  if (patch.targets !== void 0) await assertTargetsOwned(patch.targets.map((t) => t.targetId), c);
16009
16222
  const { ownerUserId: _owner, disabledTargetIds: _optOut, ...safe } = patch;
16010
16223
  const rule = await deps.ruleStore.update(input.ruleId, safe, clear);
@@ -19148,6 +19361,19 @@ function pruneRuleConditions(conditions, isDead, notes) {
19148
19361
  notes.removed("conditions.devices", deviceSubjects(verdict.dead));
19149
19362
  }
19150
19363
  }
19364
+ const excluded = conditions.devicesExclude;
19365
+ if (excluded !== void 0) {
19366
+ const verdict = judgeList(excluded, isDead);
19367
+ if (!verdict.untouched) {
19368
+ const { devicesExclude: _dead, ...rest } = next;
19369
+ next = verdict.last ? rest : {
19370
+ ...rest,
19371
+ devicesExclude: [...verdict.next]
19372
+ };
19373
+ changed = true;
19374
+ notes.removed("conditions.devicesExclude", deviceSubjects(verdict.dead));
19375
+ }
19376
+ }
19151
19377
  const systemEvent = conditions.systemEvent;
19152
19378
  if (systemEvent?.deviceIds !== void 0) {
19153
19379
  const verdict = judgeList(systemEvent.deviceIds, isDead);
@@ -19328,6 +19554,7 @@ function referencedDeviceIds(snapshot) {
19328
19554
  for (const rule of snapshot.rules) {
19329
19555
  const c = rule.conditions;
19330
19556
  add(c.devices);
19557
+ add(c.devicesExclude);
19331
19558
  add(c.systemEvent?.deviceIds);
19332
19559
  if (c.deviceState !== void 0) ids.add(c.deviceState.deviceId);
19333
19560
  if (c.scene?.deviceId !== void 0) ids.add(c.scene.deviceId);
@@ -22181,6 +22408,50 @@ var NcOutbox = class {
22181
22408
  return [];
22182
22409
  }
22183
22410
  }
22411
+ /**
22412
+ * The rows `getRuleLastRuns` reduces — ONE statement: fire time inside
22413
+ * `[sinceMs, ∞)`, newest-first on `idx_nc_outbox_created`, projected to the
22414
+ * four columns the reduction reads (never the `payload` JSON), at most
22415
+ * `limit` rows. `sinceMs: null` = no lower bound (the operator keeps the
22416
+ * outbox forever); `limit` still bounds the read.
22417
+ *
22418
+ * Unlike {@link queryHistory} this THROWS on a store error: its caller
22419
+ * answers "which rules did not run", and an empty page from a failed read
22420
+ * would answer "none of them did" (D393).
22421
+ */
22422
+ async queryLastRunRows(sinceMs, limit) {
22423
+ const rows = await this.store.query.query({
22424
+ collection: NC_OUTBOX_COLLECTION,
22425
+ filter: {
22426
+ ...sinceMs !== null ? { whereBetween: { createdAt: [sinceMs, Number.MAX_SAFE_INTEGER] } } : {},
22427
+ orderBy: {
22428
+ field: "createdAt",
22429
+ direction: "desc"
22430
+ },
22431
+ limit
22432
+ },
22433
+ columns: [...NC_LAST_RUN_COLUMNS]
22434
+ });
22435
+ const out = [];
22436
+ let malformed = 0;
22437
+ let oldestReadAt = null;
22438
+ for (const row of rows) {
22439
+ const at = Number(row.data["createdAt"]);
22440
+ if (Number.isFinite(at) && (oldestReadAt === null || at < oldestReadAt)) oldestReadAt = at;
22441
+ const parsed = lastRunRow(row.data);
22442
+ if (parsed === null) malformed += 1;
22443
+ else out.push(parsed);
22444
+ }
22445
+ if (malformed > 0) this.logger.warn("last-run read skipped malformed outbox rows", { meta: {
22446
+ malformed,
22447
+ read: rows.length
22448
+ } });
22449
+ return {
22450
+ rows: out,
22451
+ truncated: rows.length >= limit,
22452
+ oldestReadAt
22453
+ };
22454
+ }
22184
22455
  async getWatermark() {
22185
22456
  try {
22186
22457
  const row = await this.store.get.query({
@@ -22468,6 +22739,27 @@ function rowToEntry$1(id, data) {
22468
22739
  payload
22469
22740
  };
22470
22741
  }
22742
+ /** The columns {@link NcOutbox.queryLastRunRows} projects. */
22743
+ var NC_LAST_RUN_COLUMNS = [
22744
+ "ruleId",
22745
+ "createdAt",
22746
+ "status",
22747
+ "deviceId"
22748
+ ];
22749
+ /** A projected row back to a {@link NcLastRunRow} — a guard, never a cast. */
22750
+ function lastRunRow(data) {
22751
+ const ruleId = data["ruleId"];
22752
+ const createdAt = Number(data["createdAt"]);
22753
+ const deviceId = Number(data["deviceId"]);
22754
+ const status = data["status"];
22755
+ if (typeof ruleId !== "string" || !Number.isFinite(createdAt) || !Number.isFinite(deviceId) || status !== "pending" && status !== "sent" && status !== "dead") return null;
22756
+ return {
22757
+ ruleId,
22758
+ createdAt,
22759
+ status,
22760
+ deviceId
22761
+ };
22762
+ }
22471
22763
  //#endregion
22472
22764
  //#region src/notification-center/rule-actions.ts
22473
22765
  var NcRuleActionRunner = class {
@@ -23159,6 +23451,58 @@ var NcRuleAiStore = class {
23159
23451
  }
23160
23452
  };
23161
23453
  //#endregion
23454
+ //#region src/notification-center/rule-last-runs.ts
23455
+ /**
23456
+ * Row bound of the one read. Projected rows are four scalars (~100 B), so the
23457
+ * bound costs ~2 MB at worst. The live outbox volume was NOT measured when
23458
+ * this was chosen. It exists so a runaway rule cannot turn an admin page load
23459
+ * into a table walk — and when it bites, `horizonMs` says so.
23460
+ */
23461
+ var NC_RULE_LAST_RUNS_SCAN_LIMIT = 2e4;
23462
+ /** Least successful first: a fire that reached some targets and not others
23463
+ * did not "deliver". */
23464
+ var STATUS_SEVERITY = {
23465
+ sent: 0,
23466
+ pending: 1,
23467
+ dead: 2
23468
+ };
23469
+ function worse(a, b) {
23470
+ return STATUS_SEVERITY[b] > STATUS_SEVERITY[a] ? b : a;
23471
+ }
23472
+ /**
23473
+ * Reduce newest-first rows to one run per rule, in one pass.
23474
+ *
23475
+ * Rows sharing the newest `createdAt` of a rule are one fire to several
23476
+ * targets; they merge into the least successful status. `read.rows` MUST be
23477
+ * ordered newest-first — that is what the outbox read asks the store for.
23478
+ */
23479
+ function summariseRuleLastRuns(read, scan) {
23480
+ const byRule = /* @__PURE__ */ new Map();
23481
+ for (const row of read.rows) {
23482
+ const seen = byRule.get(row.ruleId);
23483
+ if (seen === void 0) byRule.set(row.ruleId, {
23484
+ ruleId: row.ruleId,
23485
+ lastAt: row.createdAt,
23486
+ status: row.status,
23487
+ deviceId: row.deviceId
23488
+ });
23489
+ else if (seen.lastAt === row.createdAt && worse(seen.status, row.status) !== seen.status) byRule.set(row.ruleId, {
23490
+ ...seen,
23491
+ status: row.status,
23492
+ deviceId: row.deviceId
23493
+ });
23494
+ }
23495
+ return {
23496
+ runs: [...byRule.values()],
23497
+ horizonMs: horizonOf(read, scan)
23498
+ };
23499
+ }
23500
+ /** How far back the answer really looked — see the module doc. */
23501
+ function horizonOf(read, scan) {
23502
+ if (read.truncated && read.oldestReadAt !== null) return Math.max(0, scan.now - read.oldestReadAt);
23503
+ return scan.windowMs;
23504
+ }
23505
+ //#endregion
23162
23506
  //#region src/notification-center/durable-reread.ts
23163
23507
  function isRecord$3(value) {
23164
23508
  return typeof value === "object" && value !== null && !Array.isArray(value);
@@ -28123,9 +28467,14 @@ function outboxEntryToHistory(texts, entry) {
28123
28467
  function renderAudioSpec(spec) {
28124
28468
  return spec.mode === "label" ? `label:${spec.labels.join("+")}` : `level:${String(spec.dbThreshold)}dBFS|h${String(spec.hitPercent)}|s${String(spec.samplingSeconds)}`;
28125
28469
  }
28126
- /** The camera scope in one token — `@all` is a real answer, not a missing one. */
28127
- function renderAudioScope(devices) {
28128
- return devices.length === 0 ? "@all" : devices.join("+");
28470
+ /**
28471
+ * The camera scope in one token — `@all` is a real answer, not a missing one,
28472
+ * and `@all-except:<ids>` is a different one (D641): an excluding rule logged
28473
+ * as `@all` would claim the camera it never watches.
28474
+ */
28475
+ function renderAudioScope(devices, excluded) {
28476
+ if (devices.length === 0) return excluded.length === 0 ? "@all" : `@all-except:${excluded.join("+")}`;
28477
+ return devices.filter((id) => !excluded.includes(id)).join("+");
28129
28478
  }
28130
28479
  var NotificationCenter = class NotificationCenter {
28131
28480
  logger;
@@ -29867,7 +30216,7 @@ var NotificationCenter = class NotificationCenter {
29867
30216
  * scope the evaluation will match on (`matchesDeviceScope`).
29868
30217
  */
29869
30218
  beginLinkedStills(request) {
29870
- const { sensorId, sensorKind, cameraIds, atMs, rowCameraIds } = request;
30219
+ const { sensorId, sensorKind, cameraIds, atMs, rowCameraIds, sensorRaised } = request;
29871
30220
  const shelf = this.linkedStills;
29872
30221
  if (shelf === null || !this.evaluationActive || cameraIds.length < 2) return void 0;
29873
30222
  if (this.deviceMutes.isMuted(sensorId)) return void 0;
@@ -29877,7 +30226,13 @@ var NotificationCenter = class NotificationCenter {
29877
30226
  const involved = [sensorId, ...rowShown];
29878
30227
  if (!this.rules.listEnabled("device-event").some((rule) => {
29879
30228
  if (rule.media.attach === "none") return false;
29880
- if (!deviceEventRuleMayFireOnKind(rule, sensorKind)) return false;
30229
+ if (!deviceEventRuleMayFireOnKind(rule, sensorKind, sensorRaised)) return false;
30230
+ const excluded = rule.conditions.devicesExclude;
30231
+ if (excluded !== void 0 && deviceScopeExcludes(excluded, {
30232
+ deviceId: sensorId,
30233
+ linkedSensorId: sensorId,
30234
+ rowCameraIds: rowShown
30235
+ })) return false;
29881
30236
  const devices = rule.conditions.devices;
29882
30237
  return devices === void 0 || devices.length === 0 || devices.some((id) => involved.includes(id));
29883
30238
  })) return void 0;
@@ -29999,6 +30354,8 @@ var NotificationCenter = class NotificationCenter {
29999
30354
  if (!(mode === "condition" ? rule.conditions.audio !== void 0 : rule.conditions.audio === void 0 && referencesAudioClass(rule.conditions.classes))) continue;
30000
30355
  const devices = rule.conditions.devices;
30001
30356
  if (devices !== void 0 && devices.length > 0 && !devices.includes(deviceId)) continue;
30357
+ const excluded = rule.conditions.devicesExclude;
30358
+ if (excluded !== void 0 && deviceScopeExcludes(excluded, { deviceId })) continue;
30002
30359
  return shelf.capture(deviceId, atMs, "audio");
30003
30360
  }
30004
30361
  }
@@ -30101,6 +30458,8 @@ var NotificationCenter = class NotificationCenter {
30101
30458
  if (occ === void 0 || !matchesOccupancy(occ, subject)) continue;
30102
30459
  const devices = rule.conditions.devices;
30103
30460
  if (devices !== void 0 && devices.length > 0 && !devices.includes(edge.deviceId)) continue;
30461
+ const excluded = rule.conditions.devicesExclude;
30462
+ if (excluded !== void 0 && deviceScopeExcludes(excluded, { deviceId: edge.deviceId })) continue;
30104
30463
  return shelf.capture(edge.deviceId, edge.timestamp, "occupancy");
30105
30464
  }
30106
30465
  }
@@ -30236,6 +30595,14 @@ var NotificationCenter = class NotificationCenter {
30236
30595
  logger: this.logger,
30237
30596
  by: caller.userId
30238
30597
  });
30598
+ guardRuleSensorTransition(rule, {
30599
+ logger: this.logger,
30600
+ by: caller.userId
30601
+ });
30602
+ guardRuleDevicesExclude(rule, {
30603
+ logger: this.logger,
30604
+ by: caller.userId
30605
+ });
30239
30606
  await this.validateTargetRefs(rule.targets.map((t) => t.targetId));
30240
30607
  const created = await this.rules.create(rule, caller.userId);
30241
30608
  this.logger.info("notification rule created", { meta: {
@@ -30254,6 +30621,16 @@ var NotificationCenter = class NotificationCenter {
30254
30621
  by: caller.userId,
30255
30622
  ruleId
30256
30623
  });
30624
+ guardPatchSensorTransition(existing, patch, {
30625
+ logger: this.logger,
30626
+ by: caller.userId,
30627
+ ruleId
30628
+ });
30629
+ guardPatchDevicesExclude(existing, patch, {
30630
+ logger: this.logger,
30631
+ by: caller.userId,
30632
+ ruleId
30633
+ });
30257
30634
  const { disabledTargetIds: _optOut, ...safePatch } = patch;
30258
30635
  const updated = await this.rules.update(ruleId, safePatch, clear ?? []);
30259
30636
  this.logger.info("notification rule updated", { meta: {
@@ -30295,6 +30672,14 @@ var NotificationCenter = class NotificationCenter {
30295
30672
  }
30296
30673
  return { entries: mapped };
30297
30674
  },
30675
+ getRuleLastRuns: async () => {
30676
+ const now = this.now();
30677
+ const windowMs = (await this.deps.pruneWindows()).outboxMs;
30678
+ return summariseRuleLastRuns(await this.outbox.queryLastRunRows(windowMs !== null ? now - windowMs : null, NC_RULE_LAST_RUNS_SCAN_LIMIT), {
30679
+ now,
30680
+ windowMs
30681
+ });
30682
+ },
30298
30683
  resolveArtifactUrl: async ({ artifactId }) => ({ url: await this.deps.resolveArtifactUrl?.(artifactId) ?? null }),
30299
30684
  listDeviceMutes: async () => ({ mutedDeviceIds: this.deviceMutes.listMuted() }),
30300
30685
  setDeviceMuted: async ({ deviceId, muted }) => {
@@ -30626,6 +31011,7 @@ var NotificationCenter = class NotificationCenter {
30626
31011
  coverage: this.coverageFor(panel).map((c) => ({
30627
31012
  ...c,
30628
31013
  deviceIds: [...c.deviceIds],
31014
+ allDevicesExcept: [...c.allDevicesExcept],
30629
31015
  skippedDevices: c.skippedDevices.map((s) => ({ ...s }))
30630
31016
  }))
30631
31017
  };
@@ -31135,6 +31521,9 @@ var NotificationCenter = class NotificationCenter {
31135
31521
  failed: evaluation.failedCondition,
31136
31522
  classes: subject.classNames,
31137
31523
  ...subject.sensorEdge !== void 0 ? { sensorEdge: subject.sensorEdge } : {},
31524
+ ...subject.sensorRaised !== void 0 ? { sensorRaised: subject.sensorRaised } : {},
31525
+ ...subject.sourceDeviceId !== void 0 ? { sourceDeviceId: subject.sourceDeviceId } : {},
31526
+ ...subject.linkedSensorId !== void 0 ? { linkedSensorId: subject.linkedSensorId } : {},
31138
31527
  eventId: subject.recordId,
31139
31528
  ...subject.trackId !== void 0 ? { trackId: subject.trackId } : {}
31140
31529
  }
@@ -31244,7 +31633,11 @@ var NotificationCenter = class NotificationCenter {
31244
31633
  eventId: subject.recordId,
31245
31634
  ...subject.trackId !== void 0 ? { trackId: subject.trackId } : {},
31246
31635
  ...subject.confidence !== void 0 ? { confidence: subject.confidence } : {},
31247
- ...subject.zones.length > 0 ? { zones: subject.zones } : {}
31636
+ ...subject.zones.length > 0 ? { zones: subject.zones } : {},
31637
+ ...rule.conditions.sensorTransition !== void 0 ? {
31638
+ sensorTransition: rule.conditions.sensorTransition,
31639
+ sensorRaised: subject.sensorRaised ?? null
31640
+ } : {}
31248
31641
  }
31249
31642
  });
31250
31643
  const unresolvable = unresolvableButtons(rule.actions);
@@ -31851,7 +32244,8 @@ var NotificationCenter = class NotificationCenter {
31851
32244
  ruleId: rule.id,
31852
32245
  ruleName: rule.name,
31853
32246
  spec,
31854
- devices: rule.conditions.devices ?? []
32247
+ devices: rule.conditions.devices ?? [],
32248
+ excluded: rule.conditions.devicesExclude ?? []
31855
32249
  });
31856
32250
  }
31857
32251
  this.audioWatcher.setWatchedSpecs(audioSpecs);
@@ -31943,10 +32337,12 @@ var NotificationCenter = class NotificationCenter {
31943
32337
  * for at all"; the per-camera line answers "why is 617 not notifying" — and
31944
32338
  * that question is ALWAYS asked per camera, so the line carries
31945
32339
  * `tags.deviceId` and a rule scoped to no camera is reported as watching
31946
- * every one rather than silently omitted.
32340
+ * every one rather than silently omitted — every one EXCEPT its
32341
+ * `devicesExclude` cameras (D641), each of which gets its own NOT-watching
32342
+ * line on that camera.
31947
32343
  */
31948
32344
  reportAudioWatch(entries) {
31949
- const rendered = entries.map((e) => `${e.ruleId}=${renderAudioSpec(e.spec)}@${renderAudioScope(e.devices)}`).toSorted().join(",");
32345
+ const rendered = entries.map((e) => `${e.ruleId}=${renderAudioSpec(e.spec)}@${renderAudioScope(e.devices, e.excluded)}`).toSorted().join(",");
31950
32346
  if (rendered === this.lastAudioWatchReport) return;
31951
32347
  this.lastAudioWatchReport = rendered;
31952
32348
  this.logger.info("audio watched specs", { meta: {
@@ -31960,14 +32356,28 @@ var NotificationCenter = class NotificationCenter {
31960
32356
  mode: entry.spec.mode,
31961
32357
  spec: renderAudioSpec(entry.spec)
31962
32358
  };
32359
+ for (const deviceId of entry.excluded) this.logger.info("audio rule NOT watching camera", {
32360
+ tags: { deviceId },
32361
+ meta: {
32362
+ ...meta,
32363
+ reason: "devicesExclude"
32364
+ }
32365
+ });
31963
32366
  if (entry.devices.length === 0) {
31964
- this.logger.info("audio rule watching every camera", { meta });
32367
+ if (entry.excluded.length === 0) this.logger.info("audio rule watching every camera", { meta });
32368
+ else this.logger.info("audio rule watching every camera except the excluded", { meta: {
32369
+ ...meta,
32370
+ excluded: [...entry.excluded]
32371
+ } });
31965
32372
  continue;
31966
32373
  }
31967
- for (const deviceId of entry.devices) this.logger.info("audio rule watching camera", {
31968
- tags: { deviceId },
31969
- meta
31970
- });
32374
+ for (const deviceId of entry.devices) {
32375
+ if (entry.excluded.includes(deviceId)) continue;
32376
+ this.logger.info("audio rule watching camera", {
32377
+ tags: { deviceId },
32378
+ meta
32379
+ });
32380
+ }
31971
32381
  }
31972
32382
  }
31973
32383
  /** Boot reseed of confirmed occupancy edge-state (durability, constraint 4) —
@@ -89355,10 +89765,12 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
89355
89765
  startLinkedStills(data, timestamp, cameras) {
89356
89766
  const sensorKind = EVENT_KIND_BY_CAP[data.capName]?.kind;
89357
89767
  if (sensorKind === void 0) return void 0;
89768
+ const sensorRaised = sensorRaisedOf(sensorKind, sensorSliceRecord(data.slice));
89358
89769
  try {
89359
89770
  return this.notificationCenter?.beginLinkedStills({
89360
89771
  sensorId: data.deviceId,
89361
89772
  sensorKind,
89773
+ ...sensorRaised !== void 0 ? { sensorRaised } : {},
89362
89774
  cameraIds: cameras.mediaCameraIds,
89363
89775
  rowCameraIds: cameras.rowCameraIds,
89364
89776
  atMs: timestamp