@camstack/addon-post-analysis 1.2.280 → 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.
@@ -2,7 +2,7 @@ Object.defineProperties(exports, {
2
2
  __esModule: { value: true },
3
3
  [Symbol.toStringTag]: { value: "Module" }
4
4
  });
5
- const require_dist = require("../dist-VeiAavYR.js");
5
+ const require_dist = require("../dist-D0zzdvSd.js");
6
6
  let node_fs = require("node:fs");
7
7
  node_fs = require_dist.__toESM(node_fs, 1);
8
8
  let node_path = require("node:path");
@@ -287,6 +287,7 @@ function coverageFor(rules, panelDeviceId, mode, skipProbe) {
287
287
  const ids = /* @__PURE__ */ new Set();
288
288
  let ruleCount = 0;
289
289
  let allDevices = false;
290
+ let except = null;
290
291
  for (const rule of rules) {
291
292
  if (!rule.enabled) continue;
292
293
  const gate = rule.conditions.deviceState;
@@ -294,21 +295,35 @@ function coverageFor(rules, panelDeviceId, mode, skipProbe) {
294
295
  if (!gate.states.includes(wanted)) continue;
295
296
  ruleCount += 1;
296
297
  const scope = rule.conditions.devices;
298
+ const excluded = rule.conditions.devicesExclude ?? [];
297
299
  if (scope === void 0 || scope.length === 0) {
298
300
  allDevices = true;
301
+ except = narrowExcept(except, excluded);
299
302
  continue;
300
303
  }
301
- for (const id of scope) ids.add(id);
304
+ for (const id of scope) if (!excluded.includes(id)) ids.add(id);
302
305
  }
303
306
  const deviceIds = [...ids].toSorted((a, b) => a - b);
307
+ const allDevicesExcept = except === null || except === "none" ? [] : [...except].filter((id) => !ids.has(id)).toSorted((a, b) => a - b);
304
308
  return {
305
309
  mode,
306
310
  ruleCount,
307
311
  allDevices,
312
+ allDevicesExcept,
308
313
  deviceIds,
309
314
  skippedDevices: skippedFor(deviceIds, skipProbe)
310
315
  };
311
316
  }
317
+ /**
318
+ * Fold one UNSCOPED covering rule into the except-set: with no exclusion it
319
+ * covers everything (`'none'`, final); otherwise only what every unscoped rule
320
+ * excludes stays uncovered — the intersection.
321
+ */
322
+ function narrowExcept(current, excluded) {
323
+ if (current === "none" || excluded.length === 0) return "none";
324
+ if (current === null) return new Set(excluded);
325
+ return new Set(excluded.filter((id) => current.has(id)));
326
+ }
312
327
  function skippedFor(deviceIds, skipProbe) {
313
328
  if (skipProbe === void 0) return [];
314
329
  const out = [];
@@ -382,6 +397,19 @@ function buildArmAnnouncement(coverage, deviceName, bypassed = []) {
382
397
  textCount: names.length
383
398
  };
384
399
  }
400
+ if (coverage.allDevices && coverage.allDevicesExcept.length > 0) {
401
+ const names = coverage.allDevicesExcept.map(deviceName);
402
+ return {
403
+ bodyKey: "alarm.arm.body.allDevicesExcept",
404
+ textParams: {
405
+ rules,
406
+ mode,
407
+ count: `${names.length}`,
408
+ names: names.join(", ")
409
+ },
410
+ textCount: names.length
411
+ };
412
+ }
385
413
  if (coverage.allDevices) return {
386
414
  bodyKey: "alarm.arm.body.allDevices",
387
415
  textParams: {
@@ -600,7 +628,8 @@ function alarmCoveredDevices(rules, panelDeviceId, mode) {
600
628
  if (!gate.states.includes(wanted)) continue;
601
629
  const scope = rule.conditions.devices;
602
630
  if (scope === void 0 || scope.length === 0) continue;
603
- for (const id of scope) ids.add(id);
631
+ const excluded = rule.conditions.devicesExclude ?? [];
632
+ for (const id of scope) if (!excluded.includes(id)) ids.add(id);
604
633
  }
605
634
  return [...ids].toSorted((a, b) => a - b);
606
635
  }
@@ -7795,10 +7824,10 @@ function replaceConditionReferences(monitor, condition, reference) {
7795
7824
  //#endregion
7796
7825
  //#region src/notification-center/snooze-exempt.ts
7797
7826
  /** The cap whose presence in a sequence makes the rule the alarm's trigger. */
7798
- var ALARM_PANEL_CAP$2 = "alarm-panel";
7827
+ var ALARM_PANEL_CAP$1 = "alarm-panel";
7799
7828
  var ALARM_KINDS = new Set(require_dist.NC_ALARM_SYSTEM_EVENT_KINDS);
7800
7829
  function actuatesAlarmPanel(sequences) {
7801
- for (const sequence of sequences) for (const step of sequence.actions) if (step.kind === "cap" && step.cap === ALARM_PANEL_CAP$2) return true;
7830
+ for (const sequence of sequences) for (const step of sequence.actions) if (step.kind === "cap" && step.cap === ALARM_PANEL_CAP$1) return true;
7802
7831
  return false;
7803
7832
  }
7804
7833
  /**
@@ -9563,6 +9592,7 @@ function isLinkedSetSubject(subject) {
9563
9592
  function subjectFromSensorEvent(ev, markerTrackId, linked) {
9564
9593
  const eventType = readSensorEventType(ev.value);
9565
9594
  const sensorEdge = sensorEdgeOf(ev.kind, ev.value);
9595
+ const sensorRaised = require_dist.sensorRaisedOf(ev.kind, ev.value);
9566
9596
  return {
9567
9597
  kind: "device-event",
9568
9598
  recordId: String(ev.id),
@@ -9576,6 +9606,7 @@ function subjectFromSensorEvent(ev, markerTrackId, linked) {
9576
9606
  sensorKind: ev.kind,
9577
9607
  ...eventType !== void 0 ? { eventType } : {},
9578
9608
  ...sensorEdge !== void 0 ? { sensorEdge } : {},
9609
+ ...sensorRaised !== void 0 ? { sensorRaised } : {},
9579
9610
  ...linked !== void 0 ? {
9580
9611
  linkedSensorId: ev.sourceDeviceId,
9581
9612
  linkedCameraIds: [...linked.linkedCameraIds],
@@ -9803,6 +9834,7 @@ function presentConditionIds(c) {
9803
9834
  const ids = [];
9804
9835
  if (c.systemEvent !== void 0) ids.push("systemEvent");
9805
9836
  if (c.devices !== void 0 && c.devices.length > 0) ids.push("devices");
9837
+ if (c.devicesExclude !== void 0 && c.devicesExclude.length > 0) ids.push("devicesExclude");
9806
9838
  if (c.source !== void 0 && c.source !== "any") ids.push("source");
9807
9839
  if (c.classes !== void 0 && c.classes.length > 0) ids.push("classes");
9808
9840
  if (c.classesExclude !== void 0 && c.classesExclude.length > 0) ids.push("classesExclude");
@@ -9819,6 +9851,7 @@ function presentConditionIds(c) {
9819
9851
  if (c.plates !== void 0) ids.push("plates");
9820
9852
  if (c.sensorKinds !== void 0 && c.sensorKinds.length > 0) ids.push("sensorKinds");
9821
9853
  if (c.eventTypeTokens !== void 0 && c.eventTypeTokens.length > 0) ids.push("eventTypeTokens");
9854
+ if (c.sensorTransition !== void 0) ids.push("sensorTransition");
9822
9855
  if (c.packagePhase !== void 0 && c.packagePhase !== "both") ids.push("packagePhase");
9823
9856
  if (c.customZones !== void 0 && c.customZones.length > 0) ids.push("customZones");
9824
9857
  if (c.occupancy !== void 0) ids.push("occupancy");
@@ -9913,12 +9946,23 @@ function zonesVisitedBy(subject) {
9913
9946
  * before the subject exists (the linked-camera capture gate). Coarse in one
9914
9947
  * direction only: `true` may still fail later; `false` never would have fired.
9915
9948
  */
9916
- function deviceEventRuleMayFireOnKind(rule, sensorKind) {
9949
+ function deviceEventRuleMayFireOnKind(rule, sensorKind, sensorRaised) {
9917
9950
  const c = rule.conditions;
9918
9951
  if (c.occupancy !== void 0) return false;
9952
+ if (sensorTransitionRefuses(c.sensorTransition, sensorRaised)) return false;
9919
9953
  if (c.sensorKinds === void 0 || c.sensorKinds.length === 0) return true;
9920
9954
  return toLowerSet(c.sensorKinds).has(sensorKind.trim().toLowerCase());
9921
9955
  }
9956
+ /**
9957
+ * Does a rule's `sensorTransition` refuse this edge? Only a KNOWN edge can be
9958
+ * refused: `sensorRaised === undefined` (a kind with no raise, an unreadable
9959
+ * slice) always passes — a flood rule never goes silent on a bad read (D640).
9960
+ * The ONE predicate {@link evaluateRule} and the capture gate both ask.
9961
+ */
9962
+ function sensorTransitionRefuses(transition, sensorRaised) {
9963
+ if (transition === void 0 || sensorRaised === void 0) return false;
9964
+ return sensorRaised !== (transition === "raised");
9965
+ }
9922
9966
  function toLowerSet(values) {
9923
9967
  return new Set(values.map((v) => v.trim().toLowerCase()));
9924
9968
  }
@@ -9955,9 +9999,6 @@ function matchesIdentityScope(subject, scope) {
9955
9999
  function ruleAwaitsRecognition(rule) {
9956
10000
  return rule.conditions.identities !== void 0 || rule.conditions.plates !== void 0;
9957
10001
  }
9958
- var ARMED_WORD_PREFIX = "armed_";
9959
- var ALARM_PANEL_CAP$1 = "alarm-panel";
9960
- var ALARM_PANEL_TRIGGER_METHOD = "trigger";
9961
10002
  /**
9962
10003
  * A rule "belongs to the alarm" when its `deviceState` gate names ANY device's
9963
10004
  * `armed_*` word — CamStack's own panel or any other, e.g. an HA-mirrored
@@ -9975,8 +10016,10 @@ var ALARM_PANEL_TRIGGER_METHOD = "trigger";
9975
10016
  * catalog on save, so a new key would be dropped by the first edit.
9976
10017
  */
9977
10018
  function ruleBelongsToAlarm(rule) {
9978
- if ((rule.conditions.deviceState?.states ?? []).some((s) => s.trim().toLowerCase().startsWith(ARMED_WORD_PREFIX))) return true;
9979
- 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));
10019
+ return require_dist.ruleBelongsToAlarm({
10020
+ conditions: rule.conditions,
10021
+ ...rule.actions?.onTrigger !== void 0 ? { onTrigger: rule.actions.onTrigger } : {}
10022
+ });
9980
10023
  }
9981
10024
  /**
9982
10025
  * The edge an alarm rule refuses: a close, or a halt/fault (a cover stopped
@@ -10152,6 +10195,7 @@ function evaluateRule(rule, subject, deviceState, zoneOwners, sceneStates) {
10152
10195
  if (!sceneConditionSatisfied(c.scene, observation)) return fail("scene");
10153
10196
  }
10154
10197
  if (c.devices !== void 0 && c.devices.length > 0 && !matchesDeviceScope(c.devices, subject)) return fail("devices");
10198
+ if (c.devicesExclude !== void 0 && deviceScopeExcludes(c.devicesExclude, subject)) return fail("devicesExclude");
10155
10199
  if (c.source !== void 0 && c.source !== "any") {
10156
10200
  if ((subject.source ?? "pipeline") !== c.source) return fail("source");
10157
10201
  }
@@ -10228,6 +10272,7 @@ function evaluateRule(rule, subject, deviceState, zoneOwners, sceneStates) {
10228
10272
  if (c.eventTypeTokens !== void 0 && c.eventTypeTokens.length > 0) {
10229
10273
  if (subject.eventType === void 0 || !toLowerSet(c.eventTypeTokens).has(subject.eventType.trim().toLowerCase())) return fail("eventTypeTokens");
10230
10274
  }
10275
+ if (sensorTransitionRefuses(c.sensorTransition, subject.sensorRaised)) return fail("sensorTransition");
10231
10276
  if (c.packagePhase !== void 0 && c.packagePhase !== "both") {
10232
10277
  if (subject.packagePhase !== c.packagePhase) return fail("packagePhase");
10233
10278
  }
@@ -10333,6 +10378,27 @@ function matchesDeviceScope(scope, subject) {
10333
10378
  if (scope.includes(subject.deviceId)) return true;
10334
10379
  return subject.sourceDeviceId !== void 0 && scope.includes(subject.sourceDeviceId);
10335
10380
  }
10381
+ /**
10382
+ * Does a rule's `devicesExclude` (D641) exclude this subject?
10383
+ *
10384
+ * The exact complement of {@link matchesDeviceScope}, row by row. A per-camera
10385
+ * row is excluded by its camera OR by the sensor that fired, the two ids an
10386
+ * include matches on. A linked set stands for the rows it replaced (D630 §6):
10387
+ * it is excluded when its SENSOR is, or when EVERY camera that took a row is —
10388
+ * one excluded row camera leaves the others firing, as their own rows would
10389
+ * have. A camera that only LINKS the sensor excludes nothing, for the reason it
10390
+ * includes nothing: ADR 0632 widens the media, never which rules fire.
10391
+ */
10392
+ function deviceScopeExcludes(excluded, subject) {
10393
+ if (excluded.length === 0) return false;
10394
+ if (subject.linkedSensorId !== void 0) {
10395
+ if (excluded.includes(subject.linkedSensorId)) return true;
10396
+ const rows = subject.rowCameraIds ?? [];
10397
+ return rows.length > 0 && rows.every((cameraId) => excluded.includes(cameraId));
10398
+ }
10399
+ if (excluded.includes(subject.deviceId)) return true;
10400
+ return subject.sourceDeviceId !== void 0 && excluded.includes(subject.sourceDeviceId);
10401
+ }
10336
10402
  function cooldownKey(rule, subject) {
10337
10403
  const first = subject.classNames[0];
10338
10404
  const classKey = keysPerClass(rule, subject) && first !== void 0 ? `:c:${first}` : "";
@@ -12460,6 +12526,7 @@ var en_default = {
12460
12526
  "snooze.scope.anyRule": "A rule",
12461
12527
  "snooze.scope.rule": "“{{rule}}”",
12462
12528
  "alarm.arm.body.allDevices": "Active: all devices",
12529
+ "alarm.arm.body.allDevicesExcept": "Active: all devices except {{count}}: {{names}}",
12463
12530
  "alarm.arm.body.noDevices": "Active: no device restriction",
12464
12531
  "alarm.arm.body.devices": "Active: {{names}}",
12465
12532
  "alarm.arm.body.devicesSkipped": "Active: {{names}}\nExcluded: {{skippedNames}}",
@@ -12748,6 +12815,7 @@ var it_default = {
12748
12815
  "snooze.scope.anyRule": "Una regola",
12749
12816
  "snooze.scope.rule": "“{{rule}}”",
12750
12817
  "alarm.arm.body.allDevices": "Attivi: tutti i dispositivi",
12818
+ "alarm.arm.body.allDevicesExcept": "Attivi: tutti i dispositivi tranne {{count}}: {{names}}",
12751
12819
  "alarm.arm.body.noDevices": "Attivi: nessuna restrizione",
12752
12820
  "alarm.arm.body.devices": "Attivi: {{names}}",
12753
12821
  "alarm.arm.body.devicesSkipped": "Attivi: {{names}}\nEsclusi: {{skippedNames}}",
@@ -13150,6 +13218,245 @@ function pickTrackNarrationFrames(rows, max) {
13150
13218
  return picked;
13151
13219
  }
13152
13220
  //#endregion
13221
+ //#region src/notification-center/rule-family-guard.ts
13222
+ /**
13223
+ * The hub's half of D637: a NEW or UPDATED system-event rule answers ONE family
13224
+ * (device events, system, alarm) and carries only that family's filters.
13225
+ *
13226
+ * THE authority for the invariant. The cap-provider route
13227
+ * (`notificationRules.createRule/updateRule`, index.ts) and the rule bridge
13228
+ * (`nc.createRule/updateRule`, nc-actions.ts) both call it through
13229
+ * `guardRuleFamily` (create) and `guardPatchFamily` (update), so the two save
13230
+ * paths cannot drift. This is the same
13231
+ * arrangement as `assertRuleHasAddressee`.
13232
+ *
13233
+ * Not in Zod: a cross-field refine on `NcConditionsSchema` would also reject the
13234
+ * STORED mixed rules at load (NcRuleSchema.parse in the store), and those must
13235
+ * keep firing until the operator recreates them. The engine is untouched, and
13236
+ * so is the device-prune runner: removing a deleted device id from a stored
13237
+ * rule is not authoring.
13238
+ */
13239
+ var NcRuleFamilyRefusal = class extends Error {
13240
+ problems;
13241
+ /** The rule that was judged: the input on a create, the MERGED rule on an update. */
13242
+ rule;
13243
+ constructor(problems, rule) {
13244
+ super(`system-event rule refused: ${problems.map(require_dist.describeSystemEventFamilyProblem).join("; ")}`);
13245
+ this.name = "NcRuleFamilyRefusal";
13246
+ this.problems = problems;
13247
+ this.rule = rule;
13248
+ }
13249
+ };
13250
+ function assertRuleFamily(rule) {
13251
+ if (!require_dist.isSystemDelivery(rule.delivery)) return;
13252
+ const problems = require_dist.systemEventFamilyProblems(rule.conditions.systemEvent);
13253
+ if (problems.length > 0) throw new NcRuleFamilyRefusal(problems, rule);
13254
+ }
13255
+ var byValue = (a, b) => a < b ? -1 : a > b ? 1 : 0;
13256
+ var byNumber = (a, b) => a - b;
13257
+ /**
13258
+ * A system-event condition with every list sorted. The engine reads `kinds` and
13259
+ * the id-list filters as SETS (membership only), so an editor that re-sorts
13260
+ * them — the admin's `toggleSystemEventKind` puts kinds in option order — has
13261
+ * not changed the rule. Copies; the input is never touched.
13262
+ */
13263
+ function sortedSystemEvent(condition) {
13264
+ const { kinds, deviceIds, excludeDeviceIds, deviceTypes, nodeIds, packageNames } = condition;
13265
+ return {
13266
+ ...condition,
13267
+ kinds: kinds.toSorted(byValue),
13268
+ ...deviceIds !== void 0 ? { deviceIds: deviceIds.toSorted(byNumber) } : {},
13269
+ ...excludeDeviceIds !== void 0 ? { excludeDeviceIds: excludeDeviceIds.toSorted(byNumber) } : {},
13270
+ ...deviceTypes !== void 0 ? { deviceTypes: deviceTypes.toSorted(byValue) } : {},
13271
+ ...nodeIds !== void 0 ? { nodeIds: nodeIds.toSorted(byValue) } : {},
13272
+ ...packageNames !== void 0 ? { packageNames: packageNames.toSorted(byValue) } : {}
13273
+ };
13274
+ }
13275
+ /** The canonical form two conditions are compared in. */
13276
+ function conditionsFingerprint(conditions) {
13277
+ const { systemEvent } = conditions;
13278
+ return require_dist.canonicalHash(systemEvent === void 0 ? conditions : {
13279
+ ...conditions,
13280
+ systemEvent: sortedSystemEvent(systemEvent)
13281
+ });
13282
+ }
13283
+ /**
13284
+ * Do two conditions say the same thing? Both go through `NcConditionsSchema`
13285
+ * (the normalisation the store applies on every write) and are compared
13286
+ * canonically: key order is irrelevant, an `undefined` key equals an absent
13287
+ * one, and the system-event `kinds` and id-list filters are compared as SETS.
13288
+ * Every other array keeps its order. A side that does not parse is treated as
13289
+ * a change, so it is judged rather than waved through.
13290
+ */
13291
+ function sameConditions(a, b) {
13292
+ const left = require_dist.NcConditionsSchema.safeParse(a);
13293
+ const right = require_dist.NcConditionsSchema.safeParse(b);
13294
+ if (!left.success || !right.success) return false;
13295
+ return conditionsFingerprint(left.data) === conditionsFingerprint(right.data);
13296
+ }
13297
+ /**
13298
+ * The rule a patch WOULD produce, as far as the family is concerned — merged
13299
+ * the way the store merges it: `delivery` and `conditions` replace whole.
13300
+ *
13301
+ * `null` when the patch CHANGES neither. Both editors send the whole rule on
13302
+ * every save, so "the patch carries `conditions`" is not "the patch changes
13303
+ * them": an unchanged full-form save of a stored mixed rule (a rename, a
13304
+ * retarget) must pass, and only a save that alters the delivery or the
13305
+ * conditions is judged.
13306
+ */
13307
+ function mergedFamilySubject(existing, patch) {
13308
+ const deliveryChanged = patch.delivery !== void 0 && patch.delivery !== existing.delivery;
13309
+ const conditionsChanged = patch.conditions !== void 0 && !sameConditions(patch.conditions, existing.conditions);
13310
+ if (!deliveryChanged && !conditionsChanged) return null;
13311
+ return {
13312
+ delivery: patch.delivery ?? existing.delivery,
13313
+ conditions: patch.conditions ?? existing.conditions
13314
+ };
13315
+ }
13316
+ /**
13317
+ * On update, the MERGED rule is judged. A patch that changes neither `delivery`
13318
+ * nor `conditions` (absent, or equal to what is stored) leaves the kinds as
13319
+ * they are, so a stored mixed rule can still be switched off, renamed or
13320
+ * retargeted before the operator recreates it.
13321
+ */
13322
+ function assertPatchKeepsFamily(existing, patch) {
13323
+ if (existing === null) return;
13324
+ const merged = mergedFamilySubject(existing, patch);
13325
+ if (merged !== null) assertRuleFamily(merged);
13326
+ }
13327
+ /**
13328
+ * Run a family check and LOG a refusal: who tried to save, the rule that was
13329
+ * judged (its delivery and kinds) and every problem in words (the families,
13330
+ * the offending kind or filter). Then rethrow. A refused save is dropped work,
13331
+ * and dropped work is never silent. Any other error passes through unlogged.
13332
+ */
13333
+ function logRefusal$2(check, ctx) {
13334
+ try {
13335
+ check();
13336
+ } catch (err) {
13337
+ if (err instanceof NcRuleFamilyRefusal) ctx.logger.warn("nc rule refused: outside one family", { meta: {
13338
+ ...ctx.ruleId !== void 0 ? { ruleId: ctx.ruleId } : {},
13339
+ by: ctx.by,
13340
+ delivery: err.rule.delivery,
13341
+ kinds: err.rule.conditions.systemEvent?.kinds ?? [],
13342
+ problems: err.problems.map(require_dist.describeSystemEventFamilyProblem)
13343
+ } });
13344
+ throw err;
13345
+ }
13346
+ }
13347
+ /** {@link assertRuleFamily}, logged. The create path of both routes. */
13348
+ function guardRuleFamily(rule, ctx) {
13349
+ logRefusal$2(() => assertRuleFamily(rule), ctx);
13350
+ }
13351
+ /** {@link assertPatchKeepsFamily}, logged. The update path of both routes. */
13352
+ function guardPatchFamily(existing, patch, ctx) {
13353
+ logRefusal$2(() => assertPatchKeepsFamily(existing, patch), ctx);
13354
+ }
13355
+ //#endregion
13356
+ //#region src/notification-center/device-scope-guard.ts
13357
+ /**
13358
+ * The deliveries whose evaluation reads the device scope — the `devices`
13359
+ * descriptor's `appliesTo`, restated as the one delivery that lacks it.
13360
+ */
13361
+ var NO_DEVICE_SCOPE_DELIVERY = "system-event";
13362
+ var NcDevicesExcludeRefusal = class extends Error {
13363
+ delivery;
13364
+ devicesExclude;
13365
+ constructor(delivery, devicesExclude) {
13366
+ super(`rule refused: devicesExclude does not apply to a '${delivery}' rule — a system rule excludes devices with systemEvent.excludeDeviceIds`);
13367
+ this.name = "NcDevicesExcludeRefusal";
13368
+ this.delivery = delivery;
13369
+ this.devicesExclude = devicesExclude;
13370
+ }
13371
+ };
13372
+ function assertDevicesExcludeDelivery(rule) {
13373
+ const excluded = rule.conditions.devicesExclude;
13374
+ if (excluded === void 0 || rule.delivery !== NO_DEVICE_SCOPE_DELIVERY) return;
13375
+ throw new NcDevicesExcludeRefusal(rule.delivery, excluded);
13376
+ }
13377
+ /** Run the check; a refusal is dropped work, so it is logged before it is rethrown. */
13378
+ function logRefusal$1(check, ctx) {
13379
+ try {
13380
+ check();
13381
+ } catch (err) {
13382
+ if (err instanceof NcDevicesExcludeRefusal) warnRefusal$1(ctx.logger, err, ctx);
13383
+ throw err;
13384
+ }
13385
+ }
13386
+ function warnRefusal$1(logger, err, ctx) {
13387
+ logger.warn("nc rule refused: devicesExclude on a system rule", { meta: {
13388
+ ...ctx.ruleId !== void 0 ? { ruleId: ctx.ruleId } : {},
13389
+ by: ctx.by,
13390
+ delivery: err.delivery,
13391
+ devicesExclude: [...err.devicesExclude]
13392
+ } });
13393
+ }
13394
+ /** The create path of both routes. */
13395
+ function guardRuleDevicesExclude(rule, ctx) {
13396
+ logRefusal$1(() => assertDevicesExcludeDelivery(rule), ctx);
13397
+ }
13398
+ /**
13399
+ * The update path of both routes: the MERGED rule is judged, and only when the
13400
+ * patch changes `delivery` or `conditions`.
13401
+ */
13402
+ function guardPatchDevicesExclude(existing, patch, ctx) {
13403
+ if (existing === null) return;
13404
+ const merged = mergedFamilySubject(existing, patch);
13405
+ if (merged === null) return;
13406
+ logRefusal$1(() => assertDevicesExcludeDelivery(merged), ctx);
13407
+ }
13408
+ //#endregion
13409
+ //#region src/notification-center/sensor-transition-guard.ts
13410
+ /** The one delivery whose subject carries a sensor edge. */
13411
+ var SENSOR_TRANSITION_DELIVERY = "device-event";
13412
+ var NcSensorTransitionRefusal = class extends Error {
13413
+ delivery;
13414
+ sensorTransition;
13415
+ constructor(delivery, sensorTransition) {
13416
+ super(`rule refused: sensorTransition '${sensorTransition}' applies only to a device-event rule, not '${delivery}'`);
13417
+ this.name = "NcSensorTransitionRefusal";
13418
+ this.delivery = delivery;
13419
+ this.sensorTransition = sensorTransition;
13420
+ }
13421
+ };
13422
+ function assertSensorTransitionDelivery(rule) {
13423
+ const transition = rule.conditions.sensorTransition;
13424
+ if (transition === void 0 || rule.delivery === SENSOR_TRANSITION_DELIVERY) return;
13425
+ throw new NcSensorTransitionRefusal(rule.delivery, transition);
13426
+ }
13427
+ /** Run the check; a refusal is dropped work, so it is logged before it is rethrown. */
13428
+ function logRefusal(check, ctx) {
13429
+ try {
13430
+ check();
13431
+ } catch (err) {
13432
+ if (err instanceof NcSensorTransitionRefusal) warnRefusal(ctx.logger, err, ctx);
13433
+ throw err;
13434
+ }
13435
+ }
13436
+ function warnRefusal(logger, err, ctx) {
13437
+ logger.warn("nc rule refused: sensorTransition off a device-event rule", { meta: {
13438
+ ...ctx.ruleId !== void 0 ? { ruleId: ctx.ruleId } : {},
13439
+ by: ctx.by,
13440
+ delivery: err.delivery,
13441
+ sensorTransition: err.sensorTransition
13442
+ } });
13443
+ }
13444
+ /** The create path of both routes. */
13445
+ function guardRuleSensorTransition(rule, ctx) {
13446
+ logRefusal(() => assertSensorTransitionDelivery(rule), ctx);
13447
+ }
13448
+ /**
13449
+ * The update path of both routes: the MERGED rule is judged, and only when the
13450
+ * patch changes `delivery` or `conditions` — an unchanged full-form save
13451
+ * (a rename, a retarget) is not re-judged.
13452
+ */
13453
+ function guardPatchSensorTransition(existing, patch, ctx) {
13454
+ if (existing === null) return;
13455
+ const merged = mergedFamilySubject(existing, patch);
13456
+ if (merged === null) return;
13457
+ logRefusal(() => assertSensorTransitionDelivery(merged), ctx);
13458
+ }
13459
+ //#endregion
13153
13460
  //#region src/notification-center/occupancy-watcher.ts
13154
13461
  /** Sentinel key segments for the "no zone" (whole-frame) and "no class" scopes. */
13155
13462
  var FRAME_SCOPE = "@frame";
@@ -13918,6 +14225,19 @@ function labelFor(conditions) {
13918
14225
  return conditions.identities?.[0] ?? conditions.labelEquals?.[0] ?? conditions.plates?.values?.[0] ?? void 0;
13919
14226
  }
13920
14227
  /**
14228
+ * The edge a planned sensor event carries (D640): RAISED by default — the
14229
+ * alert, which is what an operator pressing Test on a flood rule expects to
14230
+ * see — and cleared only for a rule that asks for the clear. Only for a kind
14231
+ * that HAS a raise: a doorbell press carries none in production, and a test
14232
+ * subject claiming one would be a subject no real record could be.
14233
+ */
14234
+ function sensorEdgeFor(trigger, c) {
14235
+ const kind = c.sensorKinds?.[0];
14236
+ if (trigger !== "device-event" || kind === void 0) return {};
14237
+ if (!require_dist.NC_SENSOR_TRANSITION_KINDS.includes(kind.trim().toLowerCase())) return {};
14238
+ return { sensorRaised: c.sensorTransition !== "cleared" };
14239
+ }
14240
+ /**
13921
14241
  * Plan the injection that fires this rule.
13922
14242
  *
13923
14243
  * Every condition the engine reads off the SUBJECT is satisfied here; the two
@@ -13939,6 +14259,10 @@ function planRuleTest(input) {
13939
14259
  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."
13940
14260
  });
13941
14261
  const deviceId = scopedDeviceId ?? NC_SYSTEM_DEVICE_ID;
14262
+ if (trigger !== "system-event" && (c.devicesExclude ?? []).includes(deviceId)) notes.push({
14263
+ conditionId: "devicesExclude",
14264
+ 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."
14265
+ });
13942
14266
  const scheduledAt = scheduleActiveAt(rule.schedule, now);
13943
14267
  if (scheduledAt === null) notes.push({
13944
14268
  conditionId: "schedule",
@@ -13988,6 +14312,7 @@ function planRuleTest(input) {
13988
14312
  } } : {},
13989
14313
  ...c.sensorKinds?.[0] !== void 0 ? { sensorKind: c.sensorKinds[0] } : {},
13990
14314
  ...c.eventTypeTokens?.[0] !== void 0 ? { eventType: c.eventTypeTokens[0] } : {},
14315
+ ...sensorEdgeFor(trigger, c),
13991
14316
  ...trigger === "package-event" ? { packagePhase: c.packagePhase === "picked-up" ? "picked-up" : "delivered" } : {},
13992
14317
  ...c.source !== void 0 && c.source !== "any" ? { source: c.source } : {},
13993
14318
  ...bbox !== void 0 ? { bbox } : {},
@@ -14521,6 +14846,10 @@ var NcTestEventInputSchema = require_dist.object({
14521
14846
  /** DEVICE-EVENT: the raw device event-type token, when the source is an
14522
14847
  * event-emitter slice. */
14523
14848
  eventType: require_dist.string().optional(),
14849
+ /** DEVICE-EVENT: which edge a boolean sensor moved on — true raised, false
14850
+ * cleared (D640). Absent = the slice could not say, which production
14851
+ * answers for a doorbell press or an unreadable read. */
14852
+ sensorRaised: require_dist.boolean().optional(),
14524
14853
  /** PACKAGE-EVENT: which lifecycle phase. */
14525
14854
  packagePhase: require_dist._enum(["delivered", "picked-up"]).optional(),
14526
14855
  /** Detection provenance. Absent = `pipeline` (what a record with no stamped
@@ -14739,6 +15068,7 @@ function buildSyntheticEvent(input, recordId, now) {
14739
15068
  ...input.labelConfidence !== void 0 ? { labelConfidence: input.labelConfidence } : {},
14740
15069
  ...input.sensorKind !== void 0 ? { sensorKind: input.sensorKind } : {},
14741
15070
  ...input.eventType !== void 0 ? { eventType: input.eventType } : {},
15071
+ ...input.sensorRaised !== void 0 ? { sensorRaised: input.sensorRaised } : {},
14742
15072
  ...input.packagePhase !== void 0 ? { packagePhase: input.packagePhase } : {},
14743
15073
  ...input.bbox !== void 0 ? { bbox: input.bbox } : {},
14744
15074
  ...input.crossing !== void 0 ? { crossing: input.crossing } : {},
@@ -15865,6 +16195,18 @@ function makeNcActionHandlers(deps) {
15865
16195
  const parsed = require_dist.NcRuleInputSchema.parse(input.rule);
15866
16196
  assertAdminOnlyFields(parsed, [], c);
15867
16197
  assertRuleHasAddressee(parsed.targets, parsed.targetUsers);
16198
+ guardRuleFamily(parsed, {
16199
+ logger: deps.logger,
16200
+ by: c.userId
16201
+ });
16202
+ guardRuleSensorTransition(parsed, {
16203
+ logger: deps.logger,
16204
+ by: c.userId
16205
+ });
16206
+ guardRuleDevicesExclude(parsed, {
16207
+ logger: deps.logger,
16208
+ by: c.userId
16209
+ });
15868
16210
  await assertTargetsOwned(parsed.targets.map((t) => t.targetId), c);
15869
16211
  const rule = await deps.ruleStore.create({
15870
16212
  ...parsed,
@@ -15883,6 +16225,21 @@ function makeNcActionHandlers(deps) {
15883
16225
  const clear = input.clear ?? [];
15884
16226
  assertAdminOnlyFields(patch, clear, c, existing);
15885
16227
  assertPatchKeepsAddressee(existing, patch, clear);
16228
+ guardPatchFamily(existing, patch, {
16229
+ logger: deps.logger,
16230
+ by: c.userId,
16231
+ ruleId: input.ruleId
16232
+ });
16233
+ guardPatchSensorTransition(existing, patch, {
16234
+ logger: deps.logger,
16235
+ by: c.userId,
16236
+ ruleId: input.ruleId
16237
+ });
16238
+ guardPatchDevicesExclude(existing, patch, {
16239
+ logger: deps.logger,
16240
+ by: c.userId,
16241
+ ruleId: input.ruleId
16242
+ });
15886
16243
  if (patch.targets !== void 0) await assertTargetsOwned(patch.targets.map((t) => t.targetId), c);
15887
16244
  const { ownerUserId: _owner, disabledTargetIds: _optOut, ...safe } = patch;
15888
16245
  const rule = await deps.ruleStore.update(input.ruleId, safe, clear);
@@ -19026,6 +19383,19 @@ function pruneRuleConditions(conditions, isDead, notes) {
19026
19383
  notes.removed("conditions.devices", deviceSubjects(verdict.dead));
19027
19384
  }
19028
19385
  }
19386
+ const excluded = conditions.devicesExclude;
19387
+ if (excluded !== void 0) {
19388
+ const verdict = judgeList(excluded, isDead);
19389
+ if (!verdict.untouched) {
19390
+ const { devicesExclude: _dead, ...rest } = next;
19391
+ next = verdict.last ? rest : {
19392
+ ...rest,
19393
+ devicesExclude: [...verdict.next]
19394
+ };
19395
+ changed = true;
19396
+ notes.removed("conditions.devicesExclude", deviceSubjects(verdict.dead));
19397
+ }
19398
+ }
19029
19399
  const systemEvent = conditions.systemEvent;
19030
19400
  if (systemEvent?.deviceIds !== void 0) {
19031
19401
  const verdict = judgeList(systemEvent.deviceIds, isDead);
@@ -19206,6 +19576,7 @@ function referencedDeviceIds(snapshot) {
19206
19576
  for (const rule of snapshot.rules) {
19207
19577
  const c = rule.conditions;
19208
19578
  add(c.devices);
19579
+ add(c.devicesExclude);
19209
19580
  add(c.systemEvent?.deviceIds);
19210
19581
  if (c.deviceState !== void 0) ids.add(c.deviceState.deviceId);
19211
19582
  if (c.scene?.deviceId !== void 0) ids.add(c.scene.deviceId);
@@ -22059,6 +22430,50 @@ var NcOutbox = class {
22059
22430
  return [];
22060
22431
  }
22061
22432
  }
22433
+ /**
22434
+ * The rows `getRuleLastRuns` reduces — ONE statement: fire time inside
22435
+ * `[sinceMs, ∞)`, newest-first on `idx_nc_outbox_created`, projected to the
22436
+ * four columns the reduction reads (never the `payload` JSON), at most
22437
+ * `limit` rows. `sinceMs: null` = no lower bound (the operator keeps the
22438
+ * outbox forever); `limit` still bounds the read.
22439
+ *
22440
+ * Unlike {@link queryHistory} this THROWS on a store error: its caller
22441
+ * answers "which rules did not run", and an empty page from a failed read
22442
+ * would answer "none of them did" (D393).
22443
+ */
22444
+ async queryLastRunRows(sinceMs, limit) {
22445
+ const rows = await this.store.query.query({
22446
+ collection: NC_OUTBOX_COLLECTION,
22447
+ filter: {
22448
+ ...sinceMs !== null ? { whereBetween: { createdAt: [sinceMs, Number.MAX_SAFE_INTEGER] } } : {},
22449
+ orderBy: {
22450
+ field: "createdAt",
22451
+ direction: "desc"
22452
+ },
22453
+ limit
22454
+ },
22455
+ columns: [...NC_LAST_RUN_COLUMNS]
22456
+ });
22457
+ const out = [];
22458
+ let malformed = 0;
22459
+ let oldestReadAt = null;
22460
+ for (const row of rows) {
22461
+ const at = Number(row.data["createdAt"]);
22462
+ if (Number.isFinite(at) && (oldestReadAt === null || at < oldestReadAt)) oldestReadAt = at;
22463
+ const parsed = lastRunRow(row.data);
22464
+ if (parsed === null) malformed += 1;
22465
+ else out.push(parsed);
22466
+ }
22467
+ if (malformed > 0) this.logger.warn("last-run read skipped malformed outbox rows", { meta: {
22468
+ malformed,
22469
+ read: rows.length
22470
+ } });
22471
+ return {
22472
+ rows: out,
22473
+ truncated: rows.length >= limit,
22474
+ oldestReadAt
22475
+ };
22476
+ }
22062
22477
  async getWatermark() {
22063
22478
  try {
22064
22479
  const row = await this.store.get.query({
@@ -22346,6 +22761,27 @@ function rowToEntry$1(id, data) {
22346
22761
  payload
22347
22762
  };
22348
22763
  }
22764
+ /** The columns {@link NcOutbox.queryLastRunRows} projects. */
22765
+ var NC_LAST_RUN_COLUMNS = [
22766
+ "ruleId",
22767
+ "createdAt",
22768
+ "status",
22769
+ "deviceId"
22770
+ ];
22771
+ /** A projected row back to a {@link NcLastRunRow} — a guard, never a cast. */
22772
+ function lastRunRow(data) {
22773
+ const ruleId = data["ruleId"];
22774
+ const createdAt = Number(data["createdAt"]);
22775
+ const deviceId = Number(data["deviceId"]);
22776
+ const status = data["status"];
22777
+ if (typeof ruleId !== "string" || !Number.isFinite(createdAt) || !Number.isFinite(deviceId) || status !== "pending" && status !== "sent" && status !== "dead") return null;
22778
+ return {
22779
+ ruleId,
22780
+ createdAt,
22781
+ status,
22782
+ deviceId
22783
+ };
22784
+ }
22349
22785
  //#endregion
22350
22786
  //#region src/notification-center/rule-actions.ts
22351
22787
  var NcRuleActionRunner = class {
@@ -23037,6 +23473,58 @@ var NcRuleAiStore = class {
23037
23473
  }
23038
23474
  };
23039
23475
  //#endregion
23476
+ //#region src/notification-center/rule-last-runs.ts
23477
+ /**
23478
+ * Row bound of the one read. Projected rows are four scalars (~100 B), so the
23479
+ * bound costs ~2 MB at worst. The live outbox volume was NOT measured when
23480
+ * this was chosen. It exists so a runaway rule cannot turn an admin page load
23481
+ * into a table walk — and when it bites, `horizonMs` says so.
23482
+ */
23483
+ var NC_RULE_LAST_RUNS_SCAN_LIMIT = 2e4;
23484
+ /** Least successful first: a fire that reached some targets and not others
23485
+ * did not "deliver". */
23486
+ var STATUS_SEVERITY = {
23487
+ sent: 0,
23488
+ pending: 1,
23489
+ dead: 2
23490
+ };
23491
+ function worse(a, b) {
23492
+ return STATUS_SEVERITY[b] > STATUS_SEVERITY[a] ? b : a;
23493
+ }
23494
+ /**
23495
+ * Reduce newest-first rows to one run per rule, in one pass.
23496
+ *
23497
+ * Rows sharing the newest `createdAt` of a rule are one fire to several
23498
+ * targets; they merge into the least successful status. `read.rows` MUST be
23499
+ * ordered newest-first — that is what the outbox read asks the store for.
23500
+ */
23501
+ function summariseRuleLastRuns(read, scan) {
23502
+ const byRule = /* @__PURE__ */ new Map();
23503
+ for (const row of read.rows) {
23504
+ const seen = byRule.get(row.ruleId);
23505
+ if (seen === void 0) byRule.set(row.ruleId, {
23506
+ ruleId: row.ruleId,
23507
+ lastAt: row.createdAt,
23508
+ status: row.status,
23509
+ deviceId: row.deviceId
23510
+ });
23511
+ else if (seen.lastAt === row.createdAt && worse(seen.status, row.status) !== seen.status) byRule.set(row.ruleId, {
23512
+ ...seen,
23513
+ status: row.status,
23514
+ deviceId: row.deviceId
23515
+ });
23516
+ }
23517
+ return {
23518
+ runs: [...byRule.values()],
23519
+ horizonMs: horizonOf(read, scan)
23520
+ };
23521
+ }
23522
+ /** How far back the answer really looked — see the module doc. */
23523
+ function horizonOf(read, scan) {
23524
+ if (read.truncated && read.oldestReadAt !== null) return Math.max(0, scan.now - read.oldestReadAt);
23525
+ return scan.windowMs;
23526
+ }
23527
+ //#endregion
23040
23528
  //#region src/notification-center/durable-reread.ts
23041
23529
  function isRecord$3(value) {
23042
23530
  return typeof value === "object" && value !== null && !Array.isArray(value);
@@ -28001,9 +28489,14 @@ function outboxEntryToHistory(texts, entry) {
28001
28489
  function renderAudioSpec(spec) {
28002
28490
  return spec.mode === "label" ? `label:${spec.labels.join("+")}` : `level:${String(spec.dbThreshold)}dBFS|h${String(spec.hitPercent)}|s${String(spec.samplingSeconds)}`;
28003
28491
  }
28004
- /** The camera scope in one token — `@all` is a real answer, not a missing one. */
28005
- function renderAudioScope(devices) {
28006
- return devices.length === 0 ? "@all" : devices.join("+");
28492
+ /**
28493
+ * The camera scope in one token — `@all` is a real answer, not a missing one,
28494
+ * and `@all-except:<ids>` is a different one (D641): an excluding rule logged
28495
+ * as `@all` would claim the camera it never watches.
28496
+ */
28497
+ function renderAudioScope(devices, excluded) {
28498
+ if (devices.length === 0) return excluded.length === 0 ? "@all" : `@all-except:${excluded.join("+")}`;
28499
+ return devices.filter((id) => !excluded.includes(id)).join("+");
28007
28500
  }
28008
28501
  var NotificationCenter = class NotificationCenter {
28009
28502
  logger;
@@ -29745,7 +30238,7 @@ var NotificationCenter = class NotificationCenter {
29745
30238
  * scope the evaluation will match on (`matchesDeviceScope`).
29746
30239
  */
29747
30240
  beginLinkedStills(request) {
29748
- const { sensorId, sensorKind, cameraIds, atMs, rowCameraIds } = request;
30241
+ const { sensorId, sensorKind, cameraIds, atMs, rowCameraIds, sensorRaised } = request;
29749
30242
  const shelf = this.linkedStills;
29750
30243
  if (shelf === null || !this.evaluationActive || cameraIds.length < 2) return void 0;
29751
30244
  if (this.deviceMutes.isMuted(sensorId)) return void 0;
@@ -29755,7 +30248,13 @@ var NotificationCenter = class NotificationCenter {
29755
30248
  const involved = [sensorId, ...rowShown];
29756
30249
  if (!this.rules.listEnabled("device-event").some((rule) => {
29757
30250
  if (rule.media.attach === "none") return false;
29758
- if (!deviceEventRuleMayFireOnKind(rule, sensorKind)) return false;
30251
+ if (!deviceEventRuleMayFireOnKind(rule, sensorKind, sensorRaised)) return false;
30252
+ const excluded = rule.conditions.devicesExclude;
30253
+ if (excluded !== void 0 && deviceScopeExcludes(excluded, {
30254
+ deviceId: sensorId,
30255
+ linkedSensorId: sensorId,
30256
+ rowCameraIds: rowShown
30257
+ })) return false;
29759
30258
  const devices = rule.conditions.devices;
29760
30259
  return devices === void 0 || devices.length === 0 || devices.some((id) => involved.includes(id));
29761
30260
  })) return void 0;
@@ -29877,6 +30376,8 @@ var NotificationCenter = class NotificationCenter {
29877
30376
  if (!(mode === "condition" ? rule.conditions.audio !== void 0 : rule.conditions.audio === void 0 && referencesAudioClass(rule.conditions.classes))) continue;
29878
30377
  const devices = rule.conditions.devices;
29879
30378
  if (devices !== void 0 && devices.length > 0 && !devices.includes(deviceId)) continue;
30379
+ const excluded = rule.conditions.devicesExclude;
30380
+ if (excluded !== void 0 && deviceScopeExcludes(excluded, { deviceId })) continue;
29880
30381
  return shelf.capture(deviceId, atMs, "audio");
29881
30382
  }
29882
30383
  }
@@ -29979,6 +30480,8 @@ var NotificationCenter = class NotificationCenter {
29979
30480
  if (occ === void 0 || !matchesOccupancy(occ, subject)) continue;
29980
30481
  const devices = rule.conditions.devices;
29981
30482
  if (devices !== void 0 && devices.length > 0 && !devices.includes(edge.deviceId)) continue;
30483
+ const excluded = rule.conditions.devicesExclude;
30484
+ if (excluded !== void 0 && deviceScopeExcludes(excluded, { deviceId: edge.deviceId })) continue;
29982
30485
  return shelf.capture(edge.deviceId, edge.timestamp, "occupancy");
29983
30486
  }
29984
30487
  }
@@ -30110,6 +30613,18 @@ var NotificationCenter = class NotificationCenter {
30110
30613
  getRule: async ({ ruleId }) => ({ rule: this.rules.get(ruleId) }),
30111
30614
  createRule: async ({ rule, caller }) => {
30112
30615
  assertRuleHasAddressee(rule.targets, rule.targetUsers);
30616
+ guardRuleFamily(rule, {
30617
+ logger: this.logger,
30618
+ by: caller.userId
30619
+ });
30620
+ guardRuleSensorTransition(rule, {
30621
+ logger: this.logger,
30622
+ by: caller.userId
30623
+ });
30624
+ guardRuleDevicesExclude(rule, {
30625
+ logger: this.logger,
30626
+ by: caller.userId
30627
+ });
30113
30628
  await this.validateTargetRefs(rule.targets.map((t) => t.targetId));
30114
30629
  const created = await this.rules.create(rule, caller.userId);
30115
30630
  this.logger.info("notification rule created", { meta: {
@@ -30121,7 +30636,23 @@ var NotificationCenter = class NotificationCenter {
30121
30636
  },
30122
30637
  updateRule: async ({ ruleId, patch, clear, caller }) => {
30123
30638
  if (patch.targets !== void 0) await this.validateTargetRefs(patch.targets.map((t) => t.targetId));
30124
- assertPatchKeepsAddressee(this.rules.get(ruleId), patch, clear ?? []);
30639
+ const existing = this.rules.get(ruleId);
30640
+ assertPatchKeepsAddressee(existing, patch, clear ?? []);
30641
+ guardPatchFamily(existing, patch, {
30642
+ logger: this.logger,
30643
+ by: caller.userId,
30644
+ ruleId
30645
+ });
30646
+ guardPatchSensorTransition(existing, patch, {
30647
+ logger: this.logger,
30648
+ by: caller.userId,
30649
+ ruleId
30650
+ });
30651
+ guardPatchDevicesExclude(existing, patch, {
30652
+ logger: this.logger,
30653
+ by: caller.userId,
30654
+ ruleId
30655
+ });
30125
30656
  const { disabledTargetIds: _optOut, ...safePatch } = patch;
30126
30657
  const updated = await this.rules.update(ruleId, safePatch, clear ?? []);
30127
30658
  this.logger.info("notification rule updated", { meta: {
@@ -30163,6 +30694,14 @@ var NotificationCenter = class NotificationCenter {
30163
30694
  }
30164
30695
  return { entries: mapped };
30165
30696
  },
30697
+ getRuleLastRuns: async () => {
30698
+ const now = this.now();
30699
+ const windowMs = (await this.deps.pruneWindows()).outboxMs;
30700
+ return summariseRuleLastRuns(await this.outbox.queryLastRunRows(windowMs !== null ? now - windowMs : null, NC_RULE_LAST_RUNS_SCAN_LIMIT), {
30701
+ now,
30702
+ windowMs
30703
+ });
30704
+ },
30166
30705
  resolveArtifactUrl: async ({ artifactId }) => ({ url: await this.deps.resolveArtifactUrl?.(artifactId) ?? null }),
30167
30706
  listDeviceMutes: async () => ({ mutedDeviceIds: this.deviceMutes.listMuted() }),
30168
30707
  setDeviceMuted: async ({ deviceId, muted }) => {
@@ -30494,6 +31033,7 @@ var NotificationCenter = class NotificationCenter {
30494
31033
  coverage: this.coverageFor(panel).map((c) => ({
30495
31034
  ...c,
30496
31035
  deviceIds: [...c.deviceIds],
31036
+ allDevicesExcept: [...c.allDevicesExcept],
30497
31037
  skippedDevices: c.skippedDevices.map((s) => ({ ...s }))
30498
31038
  }))
30499
31039
  };
@@ -31003,6 +31543,9 @@ var NotificationCenter = class NotificationCenter {
31003
31543
  failed: evaluation.failedCondition,
31004
31544
  classes: subject.classNames,
31005
31545
  ...subject.sensorEdge !== void 0 ? { sensorEdge: subject.sensorEdge } : {},
31546
+ ...subject.sensorRaised !== void 0 ? { sensorRaised: subject.sensorRaised } : {},
31547
+ ...subject.sourceDeviceId !== void 0 ? { sourceDeviceId: subject.sourceDeviceId } : {},
31548
+ ...subject.linkedSensorId !== void 0 ? { linkedSensorId: subject.linkedSensorId } : {},
31006
31549
  eventId: subject.recordId,
31007
31550
  ...subject.trackId !== void 0 ? { trackId: subject.trackId } : {}
31008
31551
  }
@@ -31112,7 +31655,11 @@ var NotificationCenter = class NotificationCenter {
31112
31655
  eventId: subject.recordId,
31113
31656
  ...subject.trackId !== void 0 ? { trackId: subject.trackId } : {},
31114
31657
  ...subject.confidence !== void 0 ? { confidence: subject.confidence } : {},
31115
- ...subject.zones.length > 0 ? { zones: subject.zones } : {}
31658
+ ...subject.zones.length > 0 ? { zones: subject.zones } : {},
31659
+ ...rule.conditions.sensorTransition !== void 0 ? {
31660
+ sensorTransition: rule.conditions.sensorTransition,
31661
+ sensorRaised: subject.sensorRaised ?? null
31662
+ } : {}
31116
31663
  }
31117
31664
  });
31118
31665
  const unresolvable = unresolvableButtons(rule.actions);
@@ -31719,7 +32266,8 @@ var NotificationCenter = class NotificationCenter {
31719
32266
  ruleId: rule.id,
31720
32267
  ruleName: rule.name,
31721
32268
  spec,
31722
- devices: rule.conditions.devices ?? []
32269
+ devices: rule.conditions.devices ?? [],
32270
+ excluded: rule.conditions.devicesExclude ?? []
31723
32271
  });
31724
32272
  }
31725
32273
  this.audioWatcher.setWatchedSpecs(audioSpecs);
@@ -31811,10 +32359,12 @@ var NotificationCenter = class NotificationCenter {
31811
32359
  * for at all"; the per-camera line answers "why is 617 not notifying" — and
31812
32360
  * that question is ALWAYS asked per camera, so the line carries
31813
32361
  * `tags.deviceId` and a rule scoped to no camera is reported as watching
31814
- * every one rather than silently omitted.
32362
+ * every one rather than silently omitted — every one EXCEPT its
32363
+ * `devicesExclude` cameras (D641), each of which gets its own NOT-watching
32364
+ * line on that camera.
31815
32365
  */
31816
32366
  reportAudioWatch(entries) {
31817
- const rendered = entries.map((e) => `${e.ruleId}=${renderAudioSpec(e.spec)}@${renderAudioScope(e.devices)}`).toSorted().join(",");
32367
+ const rendered = entries.map((e) => `${e.ruleId}=${renderAudioSpec(e.spec)}@${renderAudioScope(e.devices, e.excluded)}`).toSorted().join(",");
31818
32368
  if (rendered === this.lastAudioWatchReport) return;
31819
32369
  this.lastAudioWatchReport = rendered;
31820
32370
  this.logger.info("audio watched specs", { meta: {
@@ -31828,14 +32378,28 @@ var NotificationCenter = class NotificationCenter {
31828
32378
  mode: entry.spec.mode,
31829
32379
  spec: renderAudioSpec(entry.spec)
31830
32380
  };
32381
+ for (const deviceId of entry.excluded) this.logger.info("audio rule NOT watching camera", {
32382
+ tags: { deviceId },
32383
+ meta: {
32384
+ ...meta,
32385
+ reason: "devicesExclude"
32386
+ }
32387
+ });
31831
32388
  if (entry.devices.length === 0) {
31832
- this.logger.info("audio rule watching every camera", { meta });
32389
+ if (entry.excluded.length === 0) this.logger.info("audio rule watching every camera", { meta });
32390
+ else this.logger.info("audio rule watching every camera except the excluded", { meta: {
32391
+ ...meta,
32392
+ excluded: [...entry.excluded]
32393
+ } });
31833
32394
  continue;
31834
32395
  }
31835
- for (const deviceId of entry.devices) this.logger.info("audio rule watching camera", {
31836
- tags: { deviceId },
31837
- meta
31838
- });
32396
+ for (const deviceId of entry.devices) {
32397
+ if (entry.excluded.includes(deviceId)) continue;
32398
+ this.logger.info("audio rule watching camera", {
32399
+ tags: { deviceId },
32400
+ meta
32401
+ });
32402
+ }
31839
32403
  }
31840
32404
  }
31841
32405
  /** Boot reseed of confirmed occupancy edge-state (durability, constraint 4) —
@@ -89277,10 +89841,12 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
89277
89841
  startLinkedStills(data, timestamp, cameras) {
89278
89842
  const sensorKind = require_dist.EVENT_KIND_BY_CAP[data.capName]?.kind;
89279
89843
  if (sensorKind === void 0) return void 0;
89844
+ const sensorRaised = require_dist.sensorRaisedOf(sensorKind, sensorSliceRecord(data.slice));
89280
89845
  try {
89281
89846
  return this.notificationCenter?.beginLinkedStills({
89282
89847
  sensorId: data.deviceId,
89283
89848
  sensorKind,
89849
+ ...sensorRaised !== void 0 ? { sensorRaised } : {},
89284
89850
  cameraIds: cameras.mediaCameraIds,
89285
89851
  rowCameraIds: cameras.rowCameraIds,
89286
89852
  atMs: timestamp