@camstack/addon-post-analysis 1.2.26 → 1.2.28

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 readDeviceStateFrom, B as _enum, C as defineCustomActions, D as notificationRulesCapability, F as BaseAddon, G as object, H as boolean, I as DeviceType, K as string, L as createEvent, M as videoclipsCapability, N as zoneAnalyticsCapability, O as pipelineAnalyticsCapability, P as errMsg, R as hydrateSchema, S as customAction, T as faceGalleryCapability, U as literal, V as array, W as number, _ 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 subKindsOf, k as plateGalleryCapability, l as NcRulePatchSchema, m as NcTaxonomySchema, n as EVENT_KIND_BY_CAP, o as NC_TAXONOMY, p as NcSnoozeSuppressedSchema, q as EventCategory, 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 nodePin } from "../dist-O227Je-Q.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-DvGwl3d2.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,324 @@ 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
+ info: { fixed: true }
633
+ });
634
+ const existingDevice = await deps.findDeviceByStableId(NC_ALARM_STABLE_ID);
635
+ if (existingDevice !== null) return {
636
+ integrationId: integration.id,
637
+ deviceId: existingDevice.id,
638
+ created: false
639
+ };
640
+ const device = await deps.createDevice({
641
+ stableId: NC_ALARM_STABLE_ID,
642
+ integrationId: integration.id,
643
+ name: NC_ALARM_DEVICE_NAME
644
+ });
645
+ deps.logger.info("alarm panel created", {
646
+ tags: { deviceId: device.id },
647
+ meta: {
648
+ integrationId: integration.id,
649
+ stableId: NC_ALARM_STABLE_ID
650
+ }
651
+ });
652
+ return {
653
+ integrationId: integration.id,
654
+ deviceId: device.id,
655
+ created: true
656
+ };
657
+ }
658
+ /** The device class + type the caller passes to `kernel.devices.create`. */
659
+ var NC_ALARM_DEVICE_CLASS = NcAlarmPanelDevice;
660
+ var NC_ALARM_DEVICE_TYPE = DeviceType.AlarmPanel;
661
+ //#endregion
344
662
  //#region src/pipeline-analytics/videoclips-provider.ts
345
663
  var SOURCE = "analytics";
346
664
  function clipIdFor(eventId, startMs, endMs) {
@@ -6400,6 +6718,117 @@ function rowToEntry$1(id, data) {
6400
6718
  };
6401
6719
  }
6402
6720
  //#endregion
6721
+ //#region src/notification-center/rule-actions.ts
6722
+ var NcRuleActionRunner = class {
6723
+ deps;
6724
+ /** Last run per `${ruleId}:${sequenceName}` — the throttle's memory. */
6725
+ lastRunAt = /* @__PURE__ */ new Map();
6726
+ now;
6727
+ sleep;
6728
+ constructor(deps) {
6729
+ this.deps = deps;
6730
+ this.now = deps.now ?? (() => Date.now());
6731
+ this.sleep = deps.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
6732
+ }
6733
+ /**
6734
+ * Run every enabled sequence, in order.
6735
+ *
6736
+ * Never throws: an actuation that fails must not cost the NOTIFICATION about
6737
+ * the very same event. The operator finds out from the log and from the
6738
+ * device not having moved — not from silence on both.
6739
+ */
6740
+ async run(input) {
6741
+ const out = [];
6742
+ for (const sequence of input.sequences) out.push(await this.runOne(input, sequence));
6743
+ return out;
6744
+ }
6745
+ async runOne(input, sequence) {
6746
+ if (!sequence.enabled) return {
6747
+ name: sequence.name,
6748
+ ran: false,
6749
+ stepsRun: 0,
6750
+ skippedReason: "disabled"
6751
+ };
6752
+ if (sequence.actions.length === 0) return {
6753
+ name: sequence.name,
6754
+ ran: false,
6755
+ stepsRun: 0,
6756
+ skippedReason: "empty"
6757
+ };
6758
+ const key = `${input.ruleId}:${sequence.name}`;
6759
+ const now = this.now();
6760
+ const minDelayMs = (sequence.minDelaySec ?? 0) * 1e3;
6761
+ const last = this.lastRunAt.get(key);
6762
+ if (minDelayMs > 0 && last !== void 0 && now - last < minDelayMs) {
6763
+ this.deps.logger.debug("rule action sequence throttled", {
6764
+ tags: { deviceId: input.deviceId },
6765
+ meta: {
6766
+ ruleId: input.ruleId,
6767
+ sequence: sequence.name,
6768
+ sinceLastMs: now - last
6769
+ }
6770
+ });
6771
+ return {
6772
+ name: sequence.name,
6773
+ ran: false,
6774
+ stepsRun: 0,
6775
+ skippedReason: "throttled"
6776
+ };
6777
+ }
6778
+ this.lastRunAt.set(key, now);
6779
+ let stepsRun = 0;
6780
+ for (const [index, action] of sequence.actions.entries()) try {
6781
+ await this.step(action);
6782
+ stepsRun += 1;
6783
+ } catch (err) {
6784
+ this.deps.logger.warn("rule action failed — stopping the sequence", {
6785
+ tags: { deviceId: input.deviceId },
6786
+ meta: {
6787
+ ruleId: input.ruleId,
6788
+ rule: input.ruleName,
6789
+ sequence: sequence.name,
6790
+ step: index,
6791
+ action: action.kind === "cap" ? `${action.cap}.${action.method}` : "wait",
6792
+ error: String(err)
6793
+ }
6794
+ });
6795
+ return {
6796
+ name: sequence.name,
6797
+ ran: true,
6798
+ stepsRun,
6799
+ failedAt: index
6800
+ };
6801
+ }
6802
+ this.deps.logger.info("rule actions ran", {
6803
+ tags: { deviceId: input.deviceId },
6804
+ meta: {
6805
+ ruleId: input.ruleId,
6806
+ rule: input.ruleName,
6807
+ sequence: sequence.name,
6808
+ steps: stepsRun
6809
+ }
6810
+ });
6811
+ return {
6812
+ name: sequence.name,
6813
+ ran: true,
6814
+ stepsRun
6815
+ };
6816
+ }
6817
+ async step(action) {
6818
+ if (action.kind === "wait") {
6819
+ await this.sleep(action.seconds * 1e3);
6820
+ return;
6821
+ }
6822
+ if (!this.deps.isDeviceScoped(action.cap)) throw new Error(`rule action refused: "${action.cap}" is not a device capability — only devices can be actuated`);
6823
+ await this.deps.actuate({
6824
+ deviceId: action.deviceId,
6825
+ cap: action.cap,
6826
+ method: action.method,
6827
+ args: action.args ?? {}
6828
+ });
6829
+ }
6830
+ };
6831
+ //#endregion
6403
6832
  //#region src/notification-center/rule-store.ts
6404
6833
  var NC_RULES_COLLECTION = "notification-center:rules";
6405
6834
  var NC_RULES_COLUMNS = [
@@ -7484,6 +7913,8 @@ var NotificationCenter = class NotificationCenter {
7484
7913
  /** States the `deviceState` rule gate reads. Refreshed on the reload tick;
7485
7914
  * read with no I/O on the persist hook. */
7486
7915
  deviceStates;
7916
+ /** Runs a matched rule's action sequences. Null when no actuator is wired. */
7917
+ actionRunner;
7487
7918
  /** True when ≥1 enabled `device-event` rule declares an occupancy condition —
7488
7919
  * the watcher is idle (zero per-frame cost) otherwise. */
7489
7920
  occupancyEnabled = false;
@@ -7516,6 +7947,12 @@ var NotificationCenter = class NotificationCenter {
7516
7947
  store: deps.store,
7517
7948
  logger: this.logger.child("occupancy")
7518
7949
  });
7950
+ this.actionRunner = deps.actuate !== void 0 && deps.isCapDeviceScoped !== void 0 ? new NcRuleActionRunner({
7951
+ logger: this.logger.child("actions"),
7952
+ actuate: deps.actuate,
7953
+ isDeviceScoped: deps.isCapDeviceScoped,
7954
+ ...deps.now !== void 0 ? { now: deps.now } : {}
7955
+ }) : null;
7519
7956
  this.deviceStates = new NcDeviceStateCache({
7520
7957
  logger: this.logger.child("device-state"),
7521
7958
  readStates: deps.readDeviceStates ?? (async () => /* @__PURE__ */ new Map()),
@@ -7891,6 +8328,13 @@ var NotificationCenter = class NotificationCenter {
7891
8328
  ...subject.zones.length > 0 ? { zones: subject.zones } : {}
7892
8329
  }
7893
8330
  });
8331
+ const sequences = rule.actions?.onTrigger;
8332
+ if (sequences !== void 0 && sequences.length > 0 && this.actionRunner !== null) this.actionRunner.run({
8333
+ ruleId: rule.id,
8334
+ ruleName: rule.name,
8335
+ deviceId: subject.deviceId,
8336
+ sequences
8337
+ });
7894
8338
  const userTargets = await this.resolveUserTargets(rule, subject.deviceId);
7895
8339
  if (await this.outbox.enqueue(this.buildEntries(rule, subject, kind, evaluation.matchedOn, userTargets)) > 0) this.lastFiredAt.set(key, now);
7896
8340
  anyMatched = true;
@@ -8296,10 +8740,16 @@ var NotificationCenter = class NotificationCenter {
8296
8740
  const tracks = await this.deps.listRecentTracks(since, TEST_RULE_MAX_RESULTS);
8297
8741
  for (const track of tracks) subjects.push(subjectFromTrack(track));
8298
8742
  }
8743
+ let lookup = (id) => this.deviceStates.get(id);
8744
+ const gated = rule.conditions.deviceState;
8745
+ if (gated !== void 0 && this.deps.readDeviceStates !== void 0) try {
8746
+ const fresh = await this.deps.readDeviceStates([gated.deviceId]);
8747
+ lookup = (id) => fresh.get(id) ?? this.deviceStates.get(id);
8748
+ } catch {}
8299
8749
  const results = [];
8300
8750
  for (const subject of subjects.sort((a, b) => b.timestamp - a.timestamp)) {
8301
8751
  if (results.length >= TEST_RULE_MAX_RESULTS) break;
8302
- const evaluation = evaluateRule(rule, subject, (id) => this.deviceStates.get(id));
8752
+ const evaluation = evaluateRule(rule, subject, lookup);
8303
8753
  results.push({
8304
8754
  recordId: subject.recordId,
8305
8755
  recordKind: subject.kind === "track-end" ? "track" : subject.kind === "audio-event" ? "object-event" : subject.kind === "occupancy-event" ? "device-event" : subject.kind,
@@ -21257,6 +21707,11 @@ async function resolveDefaultEventMedia(deps) {
21257
21707
  }
21258
21708
  //#endregion
21259
21709
  //#region src/pipeline-analytics/index.ts
21710
+ /** This addon's manifest id — the owner of the fixed Notification Center
21711
+ * integration and of the alarm device under it. */
21712
+ var PIPELINE_ANALYTICS_ADDON_ID = "pipeline-analytics";
21713
+ /** Operator-visible name of the fixed integration. */
21714
+ var NC_INTEGRATION_NAME = "Notification Center";
21260
21715
  /**
21261
21716
  * Pipeline Analytics addon — subscribes to `PipelineInferenceResult` +
21262
21717
  * `PipelineAudioInferenceResult` on the bus, refines raw detections
@@ -21783,6 +22238,7 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
21783
22238
  const api = this.ctx.api;
21784
22239
  if (!api) throw new Error("pipeline-analytics requires ctx.api (device-manager + settings-store)");
21785
22240
  await this.declareCollections(api);
22241
+ await this.ensureAlarmPanel(api);
21786
22242
  const logger = this.ctx.logger;
21787
22243
  const storage = await this.resolveMediaStorage(logger);
21788
22244
  const stores = await this.buildStores(api, logger, storage);
@@ -21870,6 +22326,40 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
21870
22326
  }
21871
22327
  /** Declare typed collections up-front so the first insert doesn't race with
21872
22328
  * a CREATE TABLE. Idempotent. */
22329
+ /**
22330
+ * Bring the Notification Center's alarm panel into existence, once.
22331
+ *
22332
+ * Both steps are get-or-create, because this runs on every boot and an
22333
+ * operator must never end up with a second panel after a restart. The
22334
+ * integration is FIXED: it exists because this addon says so, not because
22335
+ * anybody added it, and there is no add/delete flow for it.
22336
+ */
22337
+ async ensureAlarmPanel(api) {
22338
+ const devices = this.ctx.kernel.devices;
22339
+ try {
22340
+ const result = await ensureAlarmPanel({
22341
+ logger: this.ctx.logger.child("alarm"),
22342
+ addonId: PIPELINE_ANALYTICS_ADDON_ID,
22343
+ integrationName: NC_INTEGRATION_NAME,
22344
+ getIntegration: async (addonId) => api.integrations.getByAddonId.query({ addonId }),
22345
+ createIntegration: async (input) => api.integrations.create.mutate(input),
22346
+ findDeviceByStableId: async (stableId) => {
22347
+ const found = (await api.deviceManager.listAll.query({ addonId: PIPELINE_ANALYTICS_ADDON_ID })).find((d) => d.stableId === stableId);
22348
+ return found === void 0 ? null : { id: found.id };
22349
+ },
22350
+ createDevice: async ({ stableId, integrationId, name }) => {
22351
+ return { id: (await devices.create(stableId, NC_ALARM_DEVICE_CLASS, {}, null, {
22352
+ type: NC_ALARM_DEVICE_TYPE,
22353
+ name,
22354
+ integrationId
22355
+ })).id };
22356
+ }
22357
+ });
22358
+ if (result.created) this.ctx.logger.info("notification-center alarm panel ready", { tags: { deviceId: result.deviceId } });
22359
+ } catch (err) {
22360
+ this.ctx.logger.warn("alarm panel could not be ensured — rules still notify", { meta: { error: err instanceof Error ? err.message : String(err) } });
22361
+ }
22362
+ }
21873
22363
  async declareCollections(api) {
21874
22364
  await TrackStore.declare(api.settingsStore);
21875
22365
  await MediaStore.declare(api.settingsStore);
@@ -22251,6 +22741,19 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
22251
22741
  addonId: device.addonId
22252
22742
  };
22253
22743
  },
22744
+ actuate: async ({ deviceId, cap, method, args }) => {
22745
+ const router = api[kebabToCamel(cap)];
22746
+ if (router === null || typeof router !== "object") throw new Error(`rule action: no router for capability "${cap}"`);
22747
+ const proc = router[method];
22748
+ const call = proc !== null && typeof proc === "object" ? proc : null;
22749
+ const fn = call?.mutate ?? call?.query;
22750
+ if (typeof fn !== "function") throw new Error(`rule action: "${cap}.${method}" is not a callable cap method`);
22751
+ await fn({
22752
+ deviceId,
22753
+ ...args
22754
+ });
22755
+ },
22756
+ isCapDeviceScoped: isDeviceScopedCap,
22254
22757
  readDeviceStates: async (ids) => {
22255
22758
  const out = /* @__PURE__ */ new Map();
22256
22759
  for (const id of ids) try {
@@ -30,7 +30,7 @@ async function d(e) {
30
30
  }
31
31
  }
32
32
  async function f() {
33
- return l ||= d(() => import("./_virtual_mf-localSharedImportMap___mfe_internal__addon_pipeline_analytics_widgets-aG07ehD0.mjs")).catch((e) => {
33
+ return l ||= d(() => import("./_virtual_mf-localSharedImportMap___mfe_internal__addon_pipeline_analytics_widgets-B7qyOMi9.mjs")).catch((e) => {
34
34
  throw l = void 0, e;
35
35
  }), l;
36
36
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@camstack/addon-post-analysis",
3
- "version": "1.2.26",
3
+ "version": "1.2.28",
4
4
  "description": "Post-Analysis bundle — enrichment, embedding-encoder, pipeline-analytics. Multi-entry npm package shipping addons that consume pipeline output.",
5
5
  "keywords": [
6
6
  "camstack",