@camstack/addon-post-analysis 1.2.25 → 1.2.27

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 { A as subKindsOf, B as array, C as defineCustomActions, D as notificationRulesCapability, F as DeviceType, G as string, H as literal, I as createEvent, K as EventCategory, L as hydrateSchema, M as zoneAnalyticsCapability, N as errMsg, O as pipelineAnalyticsCapability, P as BaseAddon, R as nodePin, S as customAction, T as faceGalleryCapability, U as number, V as boolean, W as object, _ as TimelapseRuleSchema, a as NC_CONDITION_CATALOG, b as buildEventKindDescriptor, c as NcRuleInputSchema, d as NcSnoozeInputSchema, f as NcSnoozeSchema, g as TimelapseRuleInputSchema, h as OpsLogEntrySchema, i as MACRO_LABELS, j as videoclipsCapability, k as plateGalleryCapability, l as NcRulePatchSchema, m as NcTaxonomySchema, n as EVENT_KIND_BY_CAP, o as NC_TAXONOMY, p as NcSnoozeSuppressedSchema, r as EVENT_PAD_MS, s as NcConditionDescriptorSchema, t as DEFAULT_EVENT_COLOR, u as NcRuleSchema, v as addonWidgetsSourceCapability, x as cosineSimilarity, y as audioMetricsCapability, z as _enum } from "../dist-DsCSHZ9N.mjs";
1
+ import { $ as EventCategory, A as notificationRulesCapability, B as createEvent, C as cosineSimilarity, D as faceGalleryCapability, F as videoclipsCapability, G as array, H as isDeviceScopedCap, I as zoneAnalyticsCapability, J as number, K as boolean, L as errMsg, M as plateGalleryCapability, N as readDeviceStateFrom, P as subKindsOf, Q as unknown, R as BaseAddon, S as buildEventKindDescriptor, T as defineCustomActions, U as nodePin, V as hydrateSchema, W as _enum, X as record, Y as object, Z as string, _ as TimelapseRuleInputSchema, a as MACRO_LABELS, b as alarmPanelCapability, c as NcConditionDescriptorSchema, d as NcRuleSchema, f as NcSnoozeInputSchema, g as OpsLogEntrySchema, h as NcTaxonomySchema, i as EVENT_PAD_MS, j as pipelineAnalyticsCapability, k as kebabToCamel, l as NcRuleInputSchema, m as NcSnoozeSuppressedSchema, n as DEFAULT_EVENT_COLOR, o as NC_CONDITION_CATALOG, p as NcSnoozeSchema, q as literal, r as EVENT_KIND_BY_CAP, s as NC_TAXONOMY, t as BaseDevice, u as NcRulePatchSchema, v as TimelapseRuleSchema, w as customAction, x as audioMetricsCapability, y as addonWidgetsSourceCapability, z as DeviceType } from "../dist-D7FtjvAJ.mjs";
2
2
  import { promises } from "node:fs";
3
3
  import path from "node:path";
4
4
  import { createHmac, randomUUID, timingSafeEqual } from "node:crypto";
@@ -341,6 +341,323 @@ function mimeFromExtension(file) {
341
341
  return "application/octet-stream";
342
342
  }
343
343
  //#endregion
344
+ //#region src/notification-center/alarm/alarm-state-machine.ts
345
+ var DISARMED = {
346
+ target: "disarmed",
347
+ fired: false,
348
+ changedAt: 0
349
+ };
350
+ function armedState(mode) {
351
+ return `armed_${mode}`;
352
+ }
353
+ /**
354
+ * The state to report at `now`.
355
+ *
356
+ * A comparison, not a job: nothing has to run for the exit delay to end. A
357
+ * caller that stops ticking stops NOTIFYING about the change, but the panel's
358
+ * answer is still correct the moment anyone asks.
359
+ */
360
+ function stateAt(m, now) {
361
+ if (m.fired) return "triggered";
362
+ if (m.triggerAt !== void 0) return now >= m.triggerAt ? "triggered" : "pending";
363
+ if (m.armedAt !== void 0) return now >= m.armedAt ? m.target : "arming";
364
+ return m.target;
365
+ }
366
+ /** Whether the panel is armed enough for a trigger to matter. */
367
+ function isArmed(m, now) {
368
+ const s = stateAt(m, now);
369
+ return s.startsWith("armed_") || s === "pending" || s === "triggered";
370
+ }
371
+ /**
372
+ * Arm into `mode`. An exit delay of 0 arms immediately — some modes (`home`)
373
+ * legitimately have none, because nobody is leaving.
374
+ */
375
+ function arm(mode, delays, now) {
376
+ const target = armedState(mode);
377
+ if (delays.exitDelaySec <= 0) return {
378
+ target,
379
+ fired: false,
380
+ changedAt: now
381
+ };
382
+ return {
383
+ target,
384
+ armedAt: now + delays.exitDelaySec * 1e3,
385
+ fired: false,
386
+ changedAt: now
387
+ };
388
+ }
389
+ /**
390
+ * Disarm, from ANY state — including mid-arming and mid-pending.
391
+ *
392
+ * Cancelling a pending entry is the single most-used transition in a real
393
+ * alarm (walking in and typing the code), and it must CANCEL rather than
394
+ * queue: a disarm that let the pending timer still fire would set the alarm off
395
+ * behind the operator.
396
+ */
397
+ function disarm(now) {
398
+ return {
399
+ ...DISARMED,
400
+ changedAt: now
401
+ };
402
+ }
403
+ /**
404
+ * Something matched while armed.
405
+ *
406
+ * Starts the entry delay, or fires at once when there is none. A trigger while
407
+ * DISARMED is ignored — the panel is the gate, and a rule that fires while
408
+ * disarmed is a notification, not a break-in.
409
+ *
410
+ * A second trigger during `pending` does NOT restart the countdown: an
411
+ * intruder walking past two sensors would otherwise postpone the alarm.
412
+ */
413
+ function trigger(m, delays, now) {
414
+ if (!isArmed(m, now)) return m;
415
+ if (m.fired || m.triggerAt !== void 0) return m;
416
+ if (delays.entryDelaySec <= 0) return {
417
+ ...m,
418
+ fired: true,
419
+ changedAt: now
420
+ };
421
+ return {
422
+ ...m,
423
+ triggerAt: now + delays.entryDelaySec * 1e3,
424
+ changedAt: now
425
+ };
426
+ }
427
+ /**
428
+ * Settle the machine so a stored value never depends on when it is read.
429
+ *
430
+ * `stateAt` already answers correctly from an unsettled value; this is what a
431
+ * caller persists after a tick, so `fired` becomes true in the record rather
432
+ * than only in the reading of it.
433
+ */
434
+ function settle(m, now) {
435
+ if (m.triggerAt !== void 0 && now >= m.triggerAt) return {
436
+ target: m.target,
437
+ fired: true,
438
+ changedAt: m.triggerAt
439
+ };
440
+ if (m.armedAt !== void 0 && now >= m.armedAt) return {
441
+ target: m.target,
442
+ fired: m.fired,
443
+ changedAt: m.armedAt
444
+ };
445
+ return m;
446
+ }
447
+ //#endregion
448
+ //#region src/notification-center/alarm/alarm-panel-device.ts
449
+ /**
450
+ * The Notification Center's own alarm panel.
451
+ *
452
+ * A REAL device, not a special case: it carries `alarm-panel` natively, so the
453
+ * `deviceState` rule gate reads it through `loadRuntimeState` exactly like a
454
+ * switch or a contact, and both exporters see it through their ordinary
455
+ * device mapping. Nothing had to learn about alarms.
456
+ *
457
+ * It needed no new addon and no base-class change either — the wiring already
458
+ * existed and was simply never used this way: `ctx.kernel.devices.create()` is
459
+ * on EVERY addon's kernel services, not only a `BaseDeviceProvider`'s, and
460
+ * `integrations.create` gives it a fixed home to hang from.
461
+ *
462
+ * The lifecycle lives in {@link ../alarm-state-machine.js} as pure functions.
463
+ * This class is the thin part: persist the machine, publish the derived state,
464
+ * and re-publish when a delay elapses.
465
+ */
466
+ /** Every arm mode a camstack-owned panel offers. */
467
+ var MODES = [
468
+ "home",
469
+ "away",
470
+ "night"
471
+ ];
472
+ /**
473
+ * Defaults an operator can change per panel. `home` has no exit delay on
474
+ * purpose — nobody is leaving — but the shared value is applied to every mode
475
+ * until per-mode delays are asked for.
476
+ */
477
+ var DEFAULT_DELAYS = {
478
+ exitDelaySec: 30,
479
+ entryDelaySec: 20
480
+ };
481
+ /**
482
+ * How often the panel re-publishes while a delay is running.
483
+ *
484
+ * The state is computed from an INSTANT, so this tick only decides how quickly
485
+ * the change is ANNOUNCED — a missed tick can never leave the panel stuck in
486
+ * `arming`. One second is what makes a countdown look like a countdown.
487
+ */
488
+ var TICK_MS = 1e3;
489
+ /**
490
+ * The panel's persisted config: the delays an operator tunes, and the machine
491
+ * itself. The machine is persisted because the alternative is an alarm that
492
+ * disarms itself on every deploy.
493
+ */
494
+ var ncAlarmPanelSchema = object({
495
+ exitDelaySec: number().int().min(0).max(600).default(DEFAULT_DELAYS.exitDelaySec),
496
+ entryDelaySec: number().int().min(0).max(600).default(DEFAULT_DELAYS.entryDelaySec),
497
+ machine: record(string(), unknown()).optional()
498
+ });
499
+ var NcAlarmPanelDevice = class extends BaseDevice {
500
+ features = [];
501
+ machine = DISARMED;
502
+ delays = DEFAULT_DELAYS;
503
+ ticker = null;
504
+ constructor(ctx) {
505
+ super(ctx, ncAlarmPanelSchema, { type: ctx.deviceMeta.type });
506
+ this.delays = {
507
+ exitDelaySec: this.config.get("exitDelaySec"),
508
+ entryDelaySec: this.config.get("entryDelaySec")
509
+ };
510
+ this.restore();
511
+ this.registerAlarmCap();
512
+ this.publish();
513
+ }
514
+ /** Fire a trigger at the panel. Called by the notification center when a rule
515
+ * that arms the alarm matches. Ignored unless the panel is armed. */
516
+ onRuleTriggered(now = Date.now()) {
517
+ const next = trigger(this.machine, this.delays, now);
518
+ if (next === this.machine) return;
519
+ this.machine = next;
520
+ this.publish(now);
521
+ }
522
+ /** Current lifecycle word — what the `deviceState` gate compares against. */
523
+ currentState(now = Date.now()) {
524
+ return stateAt(this.machine, now);
525
+ }
526
+ /** Stop the countdown when the device goes away. The state itself is
527
+ * persisted, so nothing is lost — only the announcements stop. */
528
+ stopCountdown() {
529
+ this.stopTicking();
530
+ }
531
+ registerAlarmCap() {
532
+ this.ctx.registerNativeCap(alarmPanelCapability, {
533
+ getStatus: async () => this.status(),
534
+ arm: async ({ mode }) => {
535
+ this.machine = arm(mode, this.delays, Date.now());
536
+ this.publish();
537
+ },
538
+ disarm: async () => {
539
+ this.machine = disarm(Date.now());
540
+ this.publish();
541
+ },
542
+ trigger: async () => {
543
+ this.machine = {
544
+ ...this.machine,
545
+ fired: true,
546
+ changedAt: Date.now()
547
+ };
548
+ this.publish();
549
+ }
550
+ });
551
+ }
552
+ status(now = Date.now()) {
553
+ return {
554
+ state: stateAt(this.machine, now),
555
+ availableModes: [...MODES],
556
+ requiresCode: false,
557
+ lastChangedAt: this.machine.changedAt
558
+ };
559
+ }
560
+ /**
561
+ * Write the slice and keep ticking while a delay is running.
562
+ *
563
+ * `settle` is applied first so the PERSISTED machine does not depend on when
564
+ * it is next read — a hub restarted mid-`pending` comes back already fired,
565
+ * rather than fired only once somebody looks.
566
+ */
567
+ publish(now = Date.now()) {
568
+ this.machine = settle(this.machine, now);
569
+ this.runtimeState.setCapState("alarm-panel", this.status(now));
570
+ this.persist();
571
+ if (this.machine.armedAt !== void 0 || this.machine.triggerAt !== void 0) this.startTicking();
572
+ else this.stopTicking();
573
+ }
574
+ startTicking() {
575
+ if (this.ticker !== null) return;
576
+ this.ticker = setInterval(() => this.publish(), TICK_MS);
577
+ this.ticker.unref?.();
578
+ }
579
+ stopTicking() {
580
+ if (this.ticker === null) return;
581
+ clearInterval(this.ticker);
582
+ this.ticker = null;
583
+ }
584
+ /** The machine rides the device's own config blob — it is small, and it must
585
+ * survive a restart or the alarm disarms itself on every deploy. */
586
+ persist() {
587
+ this.config.set("machine", { ...this.machine });
588
+ }
589
+ restore() {
590
+ const raw = this.config.get("machine");
591
+ if (raw === null || typeof raw !== "object") return;
592
+ const m = raw;
593
+ if (typeof m.target !== "string" || typeof m.changedAt !== "number") return;
594
+ this.machine = {
595
+ target: m.target,
596
+ ...typeof m.armedAt === "number" ? { armedAt: m.armedAt } : {},
597
+ ...typeof m.triggerAt === "number" ? { triggerAt: m.triggerAt } : {},
598
+ fired: m.fired === true,
599
+ changedAt: m.changedAt
600
+ };
601
+ }
602
+ };
603
+ //#endregion
604
+ //#region src/notification-center/alarm/ensure-alarm-panel.ts
605
+ /**
606
+ * Bring the Notification Center's alarm panel into existence, once.
607
+ *
608
+ * Two idempotent steps — a fixed integration to hang from, and one device under
609
+ * it. Both are `get-or-create`, because this runs on every boot and an operator
610
+ * must never end up with a second panel after a restart.
611
+ *
612
+ * The integration is FIXED: it exists because the addon says so, not because an
613
+ * operator added it, and there is no add/delete flow for it. That is the whole
614
+ * meaning of "a system integration" here — the same `integrations` row every
615
+ * provider uses, created by its owner instead of by a form.
616
+ */
617
+ /** Stable id of the one panel. Never derived from a name — a rename must not
618
+ * mint a second device. */
619
+ var NC_ALARM_STABLE_ID = "nc-alarm-panel";
620
+ var NC_ALARM_DEVICE_NAME = "Alarm";
621
+ /**
622
+ * Idempotent. Returns the ids either way so the caller can hold onto the panel.
623
+ *
624
+ * Failures are the CALLER's to handle: a hub whose alarm could not be created
625
+ * should say so loudly and keep notifying, not fail to boot — but that decision
626
+ * does not belong in here.
627
+ */
628
+ async function ensureAlarmPanel(deps) {
629
+ const integration = await deps.getIntegration(deps.addonId) ?? await deps.createIntegration({
630
+ addonId: deps.addonId,
631
+ name: deps.integrationName
632
+ });
633
+ const existingDevice = await deps.findDeviceByStableId(NC_ALARM_STABLE_ID);
634
+ if (existingDevice !== null) return {
635
+ integrationId: integration.id,
636
+ deviceId: existingDevice.id,
637
+ created: false
638
+ };
639
+ const device = await deps.createDevice({
640
+ stableId: NC_ALARM_STABLE_ID,
641
+ integrationId: integration.id,
642
+ name: NC_ALARM_DEVICE_NAME
643
+ });
644
+ deps.logger.info("alarm panel created", {
645
+ tags: { deviceId: device.id },
646
+ meta: {
647
+ integrationId: integration.id,
648
+ stableId: NC_ALARM_STABLE_ID
649
+ }
650
+ });
651
+ return {
652
+ integrationId: integration.id,
653
+ deviceId: device.id,
654
+ created: true
655
+ };
656
+ }
657
+ /** The device class + type the caller passes to `kernel.devices.create`. */
658
+ var NC_ALARM_DEVICE_CLASS = NcAlarmPanelDevice;
659
+ var NC_ALARM_DEVICE_TYPE = DeviceType.AlarmPanel;
660
+ //#endregion
344
661
  //#region src/pipeline-analytics/videoclips-provider.ts
345
662
  var SOURCE = "analytics";
346
663
  function clipIdFor(eventId, startMs, endMs) {
@@ -4752,17 +5069,7 @@ function referencesAudioClass(classes) {
4752
5069
  if (classes === void 0) return false;
4753
5070
  return classes.some((c) => c.trim().toLowerCase().startsWith("audio-"));
4754
5071
  }
4755
- /**
4756
- * Evaluate one rule against one subject. The rule's `delivery` must match
4757
- * the subject kind (`immediate` ↔ `object-event`, `track-end` ↔
4758
- * `track-end`) — a mismatch fails with `'delivery'`. Schedule is evaluated
4759
- * at the SUBJECT timestamp (the persist moment), so boot-time reconcile of
4760
- * missed records applies the schedule the record lived under.
4761
- *
4762
- * Throttle/cooldown is deliberately NOT part of this function — cooldown
4763
- * is stateful; see {@link cooldownKey} / {@link isCoolingDown}.
4764
- */
4765
- function evaluateRule(rule, subject) {
5072
+ function evaluateRule(rule, subject, deviceState) {
4766
5073
  if (subject.kind === "occupancy-event") {
4767
5074
  if (rule.delivery !== "device-event") return fail("delivery");
4768
5075
  const occ = rule.conditions.occupancy;
@@ -4777,6 +5084,11 @@ function evaluateRule(rule, subject) {
4777
5084
  if (rule.conditions.occupancy !== void 0) return fail("occupancy");
4778
5085
  }
4779
5086
  const c = rule.conditions;
5087
+ if (c.deviceState !== void 0) {
5088
+ const current = deviceState?.(c.deviceState.deviceId);
5089
+ if (current === void 0) return fail("deviceState");
5090
+ if (!toLowerSet(c.deviceState.states).has(current.trim().toLowerCase())) return fail("deviceState");
5091
+ }
4780
5092
  if (c.devices !== void 0 && c.devices.length > 0 && !c.devices.includes(subject.deviceId)) return fail("devices");
4781
5093
  if (c.source !== void 0 && c.source !== "any") {
4782
5094
  if ((subject.source ?? "pipeline") !== c.source) return fail("source");
@@ -6405,6 +6717,117 @@ function rowToEntry$1(id, data) {
6405
6717
  };
6406
6718
  }
6407
6719
  //#endregion
6720
+ //#region src/notification-center/rule-actions.ts
6721
+ var NcRuleActionRunner = class {
6722
+ deps;
6723
+ /** Last run per `${ruleId}:${sequenceName}` — the throttle's memory. */
6724
+ lastRunAt = /* @__PURE__ */ new Map();
6725
+ now;
6726
+ sleep;
6727
+ constructor(deps) {
6728
+ this.deps = deps;
6729
+ this.now = deps.now ?? (() => Date.now());
6730
+ this.sleep = deps.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
6731
+ }
6732
+ /**
6733
+ * Run every enabled sequence, in order.
6734
+ *
6735
+ * Never throws: an actuation that fails must not cost the NOTIFICATION about
6736
+ * the very same event. The operator finds out from the log and from the
6737
+ * device not having moved — not from silence on both.
6738
+ */
6739
+ async run(input) {
6740
+ const out = [];
6741
+ for (const sequence of input.sequences) out.push(await this.runOne(input, sequence));
6742
+ return out;
6743
+ }
6744
+ async runOne(input, sequence) {
6745
+ if (!sequence.enabled) return {
6746
+ name: sequence.name,
6747
+ ran: false,
6748
+ stepsRun: 0,
6749
+ skippedReason: "disabled"
6750
+ };
6751
+ if (sequence.actions.length === 0) return {
6752
+ name: sequence.name,
6753
+ ran: false,
6754
+ stepsRun: 0,
6755
+ skippedReason: "empty"
6756
+ };
6757
+ const key = `${input.ruleId}:${sequence.name}`;
6758
+ const now = this.now();
6759
+ const minDelayMs = (sequence.minDelaySec ?? 0) * 1e3;
6760
+ const last = this.lastRunAt.get(key);
6761
+ if (minDelayMs > 0 && last !== void 0 && now - last < minDelayMs) {
6762
+ this.deps.logger.debug("rule action sequence throttled", {
6763
+ tags: { deviceId: input.deviceId },
6764
+ meta: {
6765
+ ruleId: input.ruleId,
6766
+ sequence: sequence.name,
6767
+ sinceLastMs: now - last
6768
+ }
6769
+ });
6770
+ return {
6771
+ name: sequence.name,
6772
+ ran: false,
6773
+ stepsRun: 0,
6774
+ skippedReason: "throttled"
6775
+ };
6776
+ }
6777
+ this.lastRunAt.set(key, now);
6778
+ let stepsRun = 0;
6779
+ for (const [index, action] of sequence.actions.entries()) try {
6780
+ await this.step(action);
6781
+ stepsRun += 1;
6782
+ } catch (err) {
6783
+ this.deps.logger.warn("rule action failed — stopping the sequence", {
6784
+ tags: { deviceId: input.deviceId },
6785
+ meta: {
6786
+ ruleId: input.ruleId,
6787
+ rule: input.ruleName,
6788
+ sequence: sequence.name,
6789
+ step: index,
6790
+ action: action.kind === "cap" ? `${action.cap}.${action.method}` : "wait",
6791
+ error: String(err)
6792
+ }
6793
+ });
6794
+ return {
6795
+ name: sequence.name,
6796
+ ran: true,
6797
+ stepsRun,
6798
+ failedAt: index
6799
+ };
6800
+ }
6801
+ this.deps.logger.info("rule actions ran", {
6802
+ tags: { deviceId: input.deviceId },
6803
+ meta: {
6804
+ ruleId: input.ruleId,
6805
+ rule: input.ruleName,
6806
+ sequence: sequence.name,
6807
+ steps: stepsRun
6808
+ }
6809
+ });
6810
+ return {
6811
+ name: sequence.name,
6812
+ ran: true,
6813
+ stepsRun
6814
+ };
6815
+ }
6816
+ async step(action) {
6817
+ if (action.kind === "wait") {
6818
+ await this.sleep(action.seconds * 1e3);
6819
+ return;
6820
+ }
6821
+ if (!this.deps.isDeviceScoped(action.cap)) throw new Error(`rule action refused: "${action.cap}" is not a device capability — only devices can be actuated`);
6822
+ await this.deps.actuate({
6823
+ deviceId: action.deviceId,
6824
+ cap: action.cap,
6825
+ method: action.method,
6826
+ args: action.args ?? {}
6827
+ });
6828
+ }
6829
+ };
6830
+ //#endregion
6408
6831
  //#region src/notification-center/rule-store.ts
6409
6832
  var NC_RULES_COLLECTION = "notification-center:rules";
6410
6833
  var NC_RULES_COLUMNS = [
@@ -6606,6 +7029,53 @@ var NcRuleStore = class {
6606
7029
  });
6607
7030
  }
6608
7031
  };
7032
+ var NcDeviceStateCache = class {
7033
+ deps;
7034
+ states = /* @__PURE__ */ new Map();
7035
+ lastOkAt = 0;
7036
+ ids = [];
7037
+ now;
7038
+ constructor(deps) {
7039
+ this.deps = deps;
7040
+ this.now = deps.now ?? (() => Date.now());
7041
+ }
7042
+ /**
7043
+ * The device ids worth reading — recomputed from the rule set, so a cluster
7044
+ * with no gated rule does no work at all.
7045
+ */
7046
+ setWatched(ids) {
7047
+ this.ids = [...new Set(ids)].sort((a, b) => a - b);
7048
+ }
7049
+ watched() {
7050
+ return this.ids;
7051
+ }
7052
+ /** Hot-path read. No I/O, no await — this is called per event. */
7053
+ get(deviceId) {
7054
+ if (this.now() - this.lastOkAt > 3e5) return void 0;
7055
+ return this.states.get(deviceId);
7056
+ }
7057
+ /**
7058
+ * Re-read every watched state. Never throws: a gate that could fail the
7059
+ * evaluation tick would take the notification pipeline with it.
7060
+ */
7061
+ async refresh() {
7062
+ if (this.ids.length === 0) {
7063
+ this.states = /* @__PURE__ */ new Map();
7064
+ return;
7065
+ }
7066
+ try {
7067
+ const next = await this.deps.readStates(this.ids);
7068
+ this.states = new Map(next);
7069
+ this.lastOkAt = this.now();
7070
+ for (const id of this.ids) if (!next.has(id)) this.deps.logger.info("device-state gate: no state for a watched device", { tags: { deviceId: id } });
7071
+ } catch (err) {
7072
+ this.deps.logger.warn("device-state refresh failed — keeping the previous states until they age out", { meta: {
7073
+ watched: this.ids.length,
7074
+ error: String(err)
7075
+ } });
7076
+ }
7077
+ }
7078
+ };
6609
7079
  //#endregion
6610
7080
  //#region src/notification-center/snooze-digest.ts
6611
7081
  /** `14:03` in the node's local zone — a digest is read by a person, at home. */
@@ -7439,6 +7909,11 @@ var NotificationCenter = class NotificationCenter {
7439
7909
  /** Snooze windows + what they hid. Mirrored into RAM; the dispatcher's gate
7440
7910
  * reads it with no I/O, the drain tick refreshes it. */
7441
7911
  snoozes;
7912
+ /** States the `deviceState` rule gate reads. Refreshed on the reload tick;
7913
+ * read with no I/O on the persist hook. */
7914
+ deviceStates;
7915
+ /** Runs a matched rule's action sequences. Null when no actuator is wired. */
7916
+ actionRunner;
7442
7917
  /** True when ≥1 enabled `device-event` rule declares an occupancy condition —
7443
7918
  * the watcher is idle (zero per-frame cost) otherwise. */
7444
7919
  occupancyEnabled = false;
@@ -7471,6 +7946,17 @@ var NotificationCenter = class NotificationCenter {
7471
7946
  store: deps.store,
7472
7947
  logger: this.logger.child("occupancy")
7473
7948
  });
7949
+ this.actionRunner = deps.actuate !== void 0 && deps.isCapDeviceScoped !== void 0 ? new NcRuleActionRunner({
7950
+ logger: this.logger.child("actions"),
7951
+ actuate: deps.actuate,
7952
+ isDeviceScoped: deps.isCapDeviceScoped,
7953
+ ...deps.now !== void 0 ? { now: deps.now } : {}
7954
+ }) : null;
7955
+ this.deviceStates = new NcDeviceStateCache({
7956
+ logger: this.logger.child("device-state"),
7957
+ readStates: deps.readDeviceStates ?? (async () => /* @__PURE__ */ new Map()),
7958
+ ...deps.now !== void 0 ? { now: deps.now } : {}
7959
+ });
7474
7960
  this.snoozes = new NcSnoozeStore({
7475
7961
  store: deps.store,
7476
7962
  logger: this.logger.child("snooze"),
@@ -7525,6 +8011,7 @@ var NotificationCenter = class NotificationCenter {
7525
8011
  await this.snoozes.load();
7526
8012
  this.refreshOccupancyWatch();
7527
8013
  if (!opts.evaluation) return;
8014
+ await this.deviceStates.refresh();
7528
8015
  this.evaluationActive = true;
7529
8016
  await this.outbox.load();
7530
8017
  await this.seedCooldowns();
@@ -7796,7 +8283,7 @@ var NotificationCenter = class NotificationCenter {
7796
8283
  let anyMatched = false;
7797
8284
  const rejections = [];
7798
8285
  for (const rule of candidates) {
7799
- const evaluation = evaluateRule(rule, subject);
8286
+ const evaluation = evaluateRule(rule, subject, (id) => this.deviceStates.get(id));
7800
8287
  if (!evaluation.matched) {
7801
8288
  rejections.push(`${rule.name}:${evaluation.failedCondition ?? "unknown"}`);
7802
8289
  this.logger.debug("rule did not match", {
@@ -7840,6 +8327,13 @@ var NotificationCenter = class NotificationCenter {
7840
8327
  ...subject.zones.length > 0 ? { zones: subject.zones } : {}
7841
8328
  }
7842
8329
  });
8330
+ const sequences = rule.actions?.onTrigger;
8331
+ if (sequences !== void 0 && sequences.length > 0 && this.actionRunner !== null) this.actionRunner.run({
8332
+ ruleId: rule.id,
8333
+ ruleName: rule.name,
8334
+ deviceId: subject.deviceId,
8335
+ sequences
8336
+ });
7843
8337
  const userTargets = await this.resolveUserTargets(rule, subject.deviceId);
7844
8338
  if (await this.outbox.enqueue(this.buildEntries(rule, subject, kind, evaluation.matchedOn, userTargets)) > 0) this.lastFiredAt.set(key, now);
7845
8339
  anyMatched = true;
@@ -7997,6 +8491,13 @@ var NotificationCenter = class NotificationCenter {
7997
8491
  }
7998
8492
  this.occupancyWatcher.setWatchedKeys(specs);
7999
8493
  this.occupancyEnabled = specs.length > 0;
8494
+ const gated = [];
8495
+ for (const rule of this.rules.list()) {
8496
+ if (!rule.enabled) continue;
8497
+ const ds = rule.conditions.deviceState;
8498
+ if (ds !== void 0) gated.push(ds.deviceId);
8499
+ }
8500
+ this.deviceStates.setWatched(gated);
8000
8501
  }
8001
8502
  /** Boot reseed of confirmed occupancy edge-state (durability, constraint 4) —
8002
8503
  * hydrate the watcher from the store, then prune orphaned durable rows to the
@@ -8013,6 +8514,7 @@ var NotificationCenter = class NotificationCenter {
8013
8514
  await this.rules.load();
8014
8515
  this.refreshOccupancyWatch();
8015
8516
  if (!this.evaluationActive) return;
8517
+ await this.deviceStates.refresh();
8016
8518
  await this.occupancyStore.pruneExcept(this.activeOccupancyKeys());
8017
8519
  }
8018
8520
  /** The full-key set the watcher currently tracks (post prune) — the bound for
@@ -8237,10 +8739,16 @@ var NotificationCenter = class NotificationCenter {
8237
8739
  const tracks = await this.deps.listRecentTracks(since, TEST_RULE_MAX_RESULTS);
8238
8740
  for (const track of tracks) subjects.push(subjectFromTrack(track));
8239
8741
  }
8742
+ let lookup = (id) => this.deviceStates.get(id);
8743
+ const gated = rule.conditions.deviceState;
8744
+ if (gated !== void 0 && this.deps.readDeviceStates !== void 0) try {
8745
+ const fresh = await this.deps.readDeviceStates([gated.deviceId]);
8746
+ lookup = (id) => fresh.get(id) ?? this.deviceStates.get(id);
8747
+ } catch {}
8240
8748
  const results = [];
8241
8749
  for (const subject of subjects.sort((a, b) => b.timestamp - a.timestamp)) {
8242
8750
  if (results.length >= TEST_RULE_MAX_RESULTS) break;
8243
- const evaluation = evaluateRule(rule, subject);
8751
+ const evaluation = evaluateRule(rule, subject, lookup);
8244
8752
  results.push({
8245
8753
  recordId: subject.recordId,
8246
8754
  recordKind: subject.kind === "track-end" ? "track" : subject.kind === "audio-event" ? "object-event" : subject.kind === "occupancy-event" ? "device-event" : subject.kind,
@@ -21198,6 +21706,11 @@ async function resolveDefaultEventMedia(deps) {
21198
21706
  }
21199
21707
  //#endregion
21200
21708
  //#region src/pipeline-analytics/index.ts
21709
+ /** This addon's manifest id — the owner of the fixed Notification Center
21710
+ * integration and of the alarm device under it. */
21711
+ var PIPELINE_ANALYTICS_ADDON_ID = "pipeline-analytics";
21712
+ /** Operator-visible name of the fixed integration. */
21713
+ var NC_INTEGRATION_NAME = "Notification Center";
21201
21714
  /**
21202
21715
  * Pipeline Analytics addon — subscribes to `PipelineInferenceResult` +
21203
21716
  * `PipelineAudioInferenceResult` on the bus, refines raw detections
@@ -21724,6 +22237,7 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
21724
22237
  const api = this.ctx.api;
21725
22238
  if (!api) throw new Error("pipeline-analytics requires ctx.api (device-manager + settings-store)");
21726
22239
  await this.declareCollections(api);
22240
+ await this.ensureAlarmPanel(api);
21727
22241
  const logger = this.ctx.logger;
21728
22242
  const storage = await this.resolveMediaStorage(logger);
21729
22243
  const stores = await this.buildStores(api, logger, storage);
@@ -21811,6 +22325,40 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
21811
22325
  }
21812
22326
  /** Declare typed collections up-front so the first insert doesn't race with
21813
22327
  * a CREATE TABLE. Idempotent. */
22328
+ /**
22329
+ * Bring the Notification Center's alarm panel into existence, once.
22330
+ *
22331
+ * Both steps are get-or-create, because this runs on every boot and an
22332
+ * operator must never end up with a second panel after a restart. The
22333
+ * integration is FIXED: it exists because this addon says so, not because
22334
+ * anybody added it, and there is no add/delete flow for it.
22335
+ */
22336
+ async ensureAlarmPanel(api) {
22337
+ const devices = this.ctx.kernel.devices;
22338
+ try {
22339
+ const result = await ensureAlarmPanel({
22340
+ logger: this.ctx.logger.child("alarm"),
22341
+ addonId: PIPELINE_ANALYTICS_ADDON_ID,
22342
+ integrationName: NC_INTEGRATION_NAME,
22343
+ getIntegration: async (addonId) => api.integrations.getByAddonId.query({ addonId }),
22344
+ createIntegration: async (input) => api.integrations.create.mutate(input),
22345
+ findDeviceByStableId: async (stableId) => {
22346
+ const found = (await api.deviceManager.listAll.query({ addonId: PIPELINE_ANALYTICS_ADDON_ID })).find((d) => d.stableId === stableId);
22347
+ return found === void 0 ? null : { id: found.id };
22348
+ },
22349
+ createDevice: async ({ stableId, integrationId, name }) => {
22350
+ return { id: (await devices.create(stableId, NC_ALARM_DEVICE_CLASS, {}, null, {
22351
+ type: NC_ALARM_DEVICE_TYPE,
22352
+ name,
22353
+ integrationId
22354
+ })).id };
22355
+ }
22356
+ });
22357
+ if (result.created) this.ctx.logger.info("notification-center alarm panel ready", { tags: { deviceId: result.deviceId } });
22358
+ } catch (err) {
22359
+ this.ctx.logger.warn("alarm panel could not be ensured — rules still notify", { meta: { error: err instanceof Error ? err.message : String(err) } });
22360
+ }
22361
+ }
21814
22362
  async declareCollections(api) {
21815
22363
  await TrackStore.declare(api.settingsStore);
21816
22364
  await MediaStore.declare(api.settingsStore);
@@ -22192,6 +22740,32 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
22192
22740
  addonId: device.addonId
22193
22741
  };
22194
22742
  },
22743
+ actuate: async ({ deviceId, cap, method, args }) => {
22744
+ const router = api[kebabToCamel(cap)];
22745
+ if (router === null || typeof router !== "object") throw new Error(`rule action: no router for capability "${cap}"`);
22746
+ const proc = router[method];
22747
+ const call = proc !== null && typeof proc === "object" ? proc : null;
22748
+ const fn = call?.mutate ?? call?.query;
22749
+ if (typeof fn !== "function") throw new Error(`rule action: "${cap}.${method}" is not a callable cap method`);
22750
+ await fn({
22751
+ deviceId,
22752
+ ...args
22753
+ });
22754
+ },
22755
+ isCapDeviceScoped: isDeviceScopedCap,
22756
+ readDeviceStates: async (ids) => {
22757
+ const out = /* @__PURE__ */ new Map();
22758
+ for (const id of ids) try {
22759
+ const state = readDeviceStateFrom(await api.deviceManager.loadRuntimeState.query({ deviceId: id }));
22760
+ if (state !== void 0) out.set(id, state);
22761
+ } catch (err) {
22762
+ this.ctx.logger.debug("device-state read failed", {
22763
+ tags: { deviceId: id },
22764
+ meta: { error: String(err) }
22765
+ });
22766
+ }
22767
+ return out;
22768
+ },
22195
22769
  dispatcher: {
22196
22770
  getZoneNames: async (deviceId) => {
22197
22771
  return (await api.zones.listZones.query({ deviceId })).map((z) => ({