@camstack/addon-post-analysis 1.2.322 → 1.2.324

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.
Files changed (15) hide show
  1. package/dist/{clip-model-registry-BFbP9U7z.js → clip-model-registry-BFLeI0dr.js} +5683 -5032
  2. package/dist/{clip-model-registry-CwK2SLQD.mjs → clip-model-registry-CshjlVTF.mjs} +5450 -4835
  3. package/dist/embedding-encoder/index.js +1 -1
  4. package/dist/embedding-encoder/index.mjs +1 -1
  5. package/dist/pipeline-analytics/_stub.js +2 -2
  6. package/dist/pipeline-analytics/{_virtual_mf-localSharedImportMap___mfe_internal__addon_pipeline_analytics_widgets-Dmt5eh93.mjs → _virtual_mf-localSharedImportMap___mfe_internal__addon_pipeline_analytics_widgets-BiD7k7AM.mjs} +2 -2
  7. package/dist/pipeline-analytics/_virtual_mf___mfe_internal__addon_pipeline_analytics_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-CBG_lEGV.mjs +26 -0
  8. package/dist/pipeline-analytics/_virtual_mf___mfe_internal__addon_pipeline_analytics_widgets__loadShare___mf_0_camstack_mf_1_ui_mf_2_library__loadShare__.js-Bnb-yu_h.mjs +26 -0
  9. package/dist/pipeline-analytics/{hostInit-DtKO_7UT.mjs → hostInit-D0L1yv82.mjs} +2 -2
  10. package/dist/pipeline-analytics/index.js +745 -137
  11. package/dist/pipeline-analytics/index.mjs +745 -137
  12. package/dist/pipeline-analytics/remoteEntry.js +1 -1
  13. package/package.json +1 -1
  14. package/dist/pipeline-analytics/_virtual_mf___mfe_internal__addon_pipeline_analytics_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-mzYK6V9S.mjs +0 -26
  15. package/dist/pipeline-analytics/_virtual_mf___mfe_internal__addon_pipeline_analytics_widgets__loadShare___mf_0_camstack_mf_1_ui_mf_2_library__loadShare__.js-CuOcvcK7.mjs +0 -26
@@ -1,4 +1,4 @@
1
- import { $ as NC_ALARM_DELAY_MAX_SEC, $n as isEventOwnerType, $r as defineCustomActions, $t as SCENE_DEFAULT_QUIET_SECONDS, A as EMBEDDING_REQUIREMENTS_FAILED, An as consumableLowPercentOf, Ar as servedTemplateVars, At as NcRuleUpdateInputSchema, B as ListTrackRelationsInputSchema, Br as templateVarsFor, Bt as NcTemplateVarDescriptorSchema, C as DEFAULT_EVENT_COLOR, Cn as applyNonBlockingEdit, Cr as ruleBelongsToAlarm$1, Ct as NcAlarmSettingsPatchSchema, D as DeclaredDevices, Dn as audioModeOf, Dr as sceneMonitorCapability, Dt as NcRulePatchSchema, E as DETECTION_PIPELINE_CAP_NAME, En as audioMetricsCapability, Er as ruleKindOf, Et as NcRuleInputSchema, F as EXPORT_DENSE_MAX_RANGES, Fn as describeSystemEventDeliveryProblem, Fr as systemEventFamilyProblems, Ft as NcStateSummarySectionIdSchema, G as MAX_BIRTH_LATENCY_PROXY_MS, Gn as faceGalleryCapability, Gr as evaluateSensorEdge, Gt as RECORDING_SOURCE_CAMSTACK_ADDON, H as LockStateSchema, Hn as encodeVectorBase64, Hr as zoneAnalyticsCapability, Ht as OpsLogEntrySchema, I as FIRST_LEVEL_MACRO_CLASSES, In as describeSystemEventFamilyProblem, Ir as systemEventFilterApplies, It as NcSystemEventKindSchema, J as MOTION_CLOSE_AFTER_MS, Jn as gifFromMp4Args, Jr as errMsg, Jt as SCENE_CONFIRM_DEFAULT_MAX_IMAGE_PX, K as MAX_TRACK_PURITIES, Kn as failureContributionCapability, Kr as isSourceCap, Kt as RebuildObjectEmbeddingsRefusalSchema, L as FULL_IMAGE_BBOX, Lr as systemEventSummaryScheduleOf, Lt as NcTaxonomySchema, M as EVENT_DENSITY_BATCH_MAX, Mn as createAlarmProfile, Mr as subKindsOf, Mt as NcSnoozeInputSchema, N as EVENT_KIND_BY_CAP, Nn as deleteAlarmProfile, Nr as syncActiveAlarmProfileSettings, Nt as NcSnoozeSchema, O as DetectionSourceSchema, On as buildEventKindDescriptor, Or as secureActuationFor, Ot as NcRuleSchema, P as EVENT_OWNER_TYPES, Pn as deriveRecordingMode, Pr as systemEventDeliveryProblems, Pt as NcSnoozeSuppressedSchema, Q as NC_ALARM_DELAY_FIELDS, Qn as isDetectionMacroClass, Qr as customAction, Qt as SCENE_DEFAULT_OBSERVATION_SPACING_SEC, R as FailureCounters, Rr as systemEventSummarySplitOf, Rt as NcTemplatePreviewInputSchema, S as CoverStateSchema, Si as EventCategory, Sn as applyModeDelaysEdits, St as NcAlarmSensorsSchema, T as DETECTION_MACRO_CLASSES, Tn as assertTimelapseCadences, Tr as ruleGroupingApplies, Tt as NcConditionsSchema, U as MAX_ARCHIVED_DEBUG_NOTES, Un as ensureAlarmProfiles, Ur as DEFAULT_FIRST_SIGHTING_FRESHNESS_MS, V as ListTrackRelationsResultSchema, Vr as vectorDimFromBase64, Vt as OPS_LOG_DEFAULT_LIMIT, W as MAX_BIRTH_DECISION_RECORDS, Wn as evictionPolicyOfLocation, Wr as canonicalHash, Wt as RECORDING_EXPORT_MAX_READ_BYTES, X as MotionTriggeredBySchema, Xn as isAudioClassName, Xr as CamProfileSchema, Xt as SCENE_DEFAULT_ANCHOR_THRESHOLD, Y as MediaFileKindEnum, Yn as isAlarmControllableCap, Yr as BaseAddon, Yt as SCENE_CONFIRM_DEFAULT_TIMEOUT_MS, Z as NC_ALARM_DEFAULT_PROFILE_ID, Zn as isDefaultAlarmProfileId, Zr as createEvent, Zt as SCENE_DEFAULT_CHECK_INTERVAL_SEC, _ as BaseDevice, _i as string, _n as activateAlarmProfile, _r as readTimelapseGeneratedAt, _t as NC_TANK_SYSTEM_EVENT_KINDS, a as CLIP_EMBEDDING_STEP_ID, ai as ZodObject, an as SetTrackPurityResultSchema, ar as isSystemEventSummaryRule, at as NC_CLIP_PRE_ROLL_DEFAULT_SEC, b as ClearTrackRelationInputSchema, bi as union, bn as alarmPanelCapability, br as resolveLocationMode, bt as NcAlarmConfigSchema, c as resolveClusterModelPin, ci as boolean, cn as TRACK_RELATIONS_DEFAULT_LIMIT, cr as mayWriteToLocation, ct as NC_CONSUMABLE_LOW_PERCENT_DEFAULT, d as APPEARANCE_RESOLVES_AMBIGUITY_DEFAULT, di as looseObject, dn as TimelapseRuleSchema, dr as occupancyScope, dt as NC_GROUPING_DEFAULT_SEC, ei as hydrateSchema, en as SCENE_DEFAULT_UNCOVERED_POLICY, er as isKnownTemplateVar, et as NC_ALARM_SYSTEM_EVENT_KINDS, f as ARCHIVED_DEBUG_NOTES_DEFAULT_LIMIT, fi as number, fn as TrackPuritySchema, ft as NC_GROUPING_MAX_SEC, g as BIRTH_DECISION_DEFAULT_LIMIT, gi as strictObject, gn as TrackSourceSchema, gr as readMotionTriggeredBy, gt as NC_SYSTEM_EVENT_KIND_GROUP_SPECS, h as ArchivedDebugNoteSchema, hi as record, hn as TrackSideSchema, hr as readDeviceStateFrom, ht as NC_SNOOZE_MAX_MINUTES, ii as sleep$1, in as SetTrackPurityInputSchema, ir as isSystemDelivery, it as NC_CLIP_POST_ROLL_DEFAULT_SEC, j as EMBEDDING_REQUIREMENTS_UNAVAILABLE, jn as cosineSimilarity$1, jr as storageOccupancyCapability, jt as NcScheduleSchema, k as EMBEDDING_MODEL_FAILED, kn as cloneAlarmSettings, kr as sensorRaisedOf, kt as NcRuleTargetSchema, l as APPEARANCE_BIN_COUNT, li as discriminatedUnion, ln as TimelapseRuleInputSchema, lr as memoizePinnedFfmpeg, lt as NC_CONSUMABLE_SYSTEM_EVENT_KINDS, m as AlarmArmModeSchema, mi as partialRecord, mn as TrackRelationSchema, mr as plateGalleryCapability, mt as NC_SENSOR_TRANSITION_KINDS, ni as nodePin, nn as SCENE_RESET_RECAPTURES, nr as isRetiredClearKey, nt as NC_AUDIO_CONFIRM_HITS_DEFAULT, o as FACE_EMBEDDING_STEP_ID, oi as _enum, on as SetTrackRelationInputSchema, or as isVectorIndexDimConflict, ot as NC_CLIP_RETENTION_MAX_SEC, p as AUDIO_MACRO_LABELS, pi as object, pn as TrackRelationKindSchema, pr as pipelineAnalyticsCapability, pt as NC_GROUPING_MIN_SEC, q as MAX_TRACK_RELATIONS, qn as gifDeriveSpeed, qr as DeviceType, qt as RetrainStatusSchema, rn as SceneMonitorSchema, rr as isScheduleActive, rt as NC_AUDIO_CONFIRM_WINDOW_SEC_DEFAULT, s as readClusterModelPinStrict, si as array, sn as TIMELAPSE_DENSE_FLOOR_SEC, sr as kebabToCamel, st as NC_CONDITION_CATALOG, t as BUILTIN_CLIP_MODELS, ti as isDeviceScopedCap, tn as SCENE_DIVERGED, tr as isMediaOwnerType, tt as NC_ALARM_TRIGGERED_MAX_SEC, u as APPEARANCE_BLOCKS, ui as literal, un as TimelapseRulePatchSchema, ur as notificationRulesCapability, ut as NC_DEFAULT_SNOOZE_MINUTES, v as BirthDecisionRecordSchema, vi as templateLiteral, vn as addonWidgetsSourceCapability, vr as renameAlarmProfile, vt as NC_TAXONOMY, w as DEFAULT_TIMELAPSE_PREVIEW_TEXT, wn as applySecureOnArmEdit, wr as ruleDeviceCompatCapQueries, wt as NcConditionDescriptorSchema, x as ClearTrackRelationResultSchema, xi as unknown, xn as applyBlockingEdit, xr as resolveModeDelays, xt as NcAlarmSensorsInputSchema, y as COCO_TO_MACRO, yi as tuple, yn as alarmDeviceSecureOnArm, yr as resolveClipSearchMinScore, yt as NC_TEMPLATE_VARS, z as LabelAttributionSchema, zn as embeddingRefusalOf, zr as templateCarriesSummary, zt as NcTemplatePreviewSchema } from "../clip-model-registry-CwK2SLQD.mjs";
1
+ import { $ as NC_ALARM_DELAY_MAX_SEC, $n as gifFromMp4Args, $r as DeviceType, $t as SCENE_CONFIRM_DEFAULT_TIMEOUT_MS, A as EMBEDDING_REQUIREMENTS_FAILED, An as audioMetricsCapability, Ar as ruleDeviceCompatCapQueries, At as NcRulePatchSchema, B as ListTrackRelationsInputSchema, Bn as describeSystemEventFamilyProblem, Br as systemEventDeliveryProblems, Bt as NcSystemEventKindSchema, C as DEFAULT_EVENT_COLOR, Ci as string, Cn as alarmDeviceSecureOnArm, Cr as readTimelapseGeneratedAt, Ct as NcAlarmSettingsPatchSchema, D as DeclaredDevices, Di as unknown, Dn as applyNonBlockingEdit, Dr as resolveModeDelays, Dt as NcRuleActionSchema, E as DETECTION_PIPELINE_CAP_NAME, Ei as union, En as applyModeDelaysEdits, Er as resolveLocationMode, Et as NcLegacyRuleActionSequenceSchema, F as EXPORT_DENSE_MAX_RANGES, Fn as cosineSimilarity$1, Fr as sensorRaisedOf, Ft as NcSharedSequenceSchema, G as MAX_BIRTH_LATENCY_PROXY_MS, Gr as templateCarriesSummary, Gt as OPS_LOG_DEFAULT_LIMIT, H as LockStateSchema, Hr as systemEventFilterApplies, Ht as NcTemplatePreviewInputSchema, I as FIRST_LEVEL_MACRO_CLASSES, In as createAlarmProfile, Ir as servedTemplateVars, It as NcSnoozeInputSchema, J as MOTION_CLOSE_AFTER_MS, Jn as eventKindLabel, Jr as zoneAnalyticsCapability, Jt as RECORDING_EXPORT_MAX_READ_BYTES, K as MAX_TRACK_PURITIES, Kn as encodeVectorBase64, Kr as templateVarsFor, Kt as OpsLogEntrySchema, L as FULL_IMAGE_BBOX, Ln as deleteAlarmProfile, Lr as storageOccupancyCapability, Lt as NcSnoozeSchema, M as EVENT_DENSITY_BATCH_MAX, Mn as buildEventKindDescriptor, Mr as ruleKindOf, Mt as NcRuleTargetSchema, N as EVENT_KIND_BY_CAP, Nn as cloneAlarmSettings, Nr as sceneMonitorCapability, Nt as NcRuleUpdateInputSchema, O as DetectionSourceSchema, Oi as EventCategory, On as applySecureOnArmEdit, Ot as NcRuleActionsSchema, P as EVENT_OWNER_TYPES, Pn as consumableLowPercentOf, Pr as secureActuationFor, Pt as NcScheduleSchema, Q as NC_ALARM_DELAY_FIELDS, Qn as gifDeriveSpeed, Qr as isSourceCap, Qt as SCENE_CONFIRM_DEFAULT_MAX_IMAGE_PX, R as FailureCounters, Rn as deriveRecordingMode, Rr as subKindsOf, Rt as NcSnoozeSuppressedSchema, S as CoverStateSchema, Si as strictObject, Sn as addonWidgetsSourceCapability, Sr as readMotionTriggeredBy, St as NcAlarmSensorsSchema, T as DETECTION_MACRO_CLASSES, Ti as tuple, Tn as applyBlockingEdit, Tr as resolveClipSearchMinScore, Tt as NcConditionsSchema, U as MAX_ARCHIVED_DEBUG_NOTES, Un as embeddingRefusalOf, Ur as systemEventSummaryScheduleOf, Ut as NcTemplatePreviewSchema, V as ListTrackRelationsResultSchema, Vr as systemEventFamilyProblems, Vt as NcTaxonomySchema, W as MAX_BIRTH_DECISION_RECORDS, Wr as systemEventSummarySplitOf, Wt as NcTemplateVarDescriptorSchema, X as MotionTriggeredBySchema, Xn as faceGalleryCapability, Xr as canonicalHash, Xt as RebuildObjectEmbeddingsRefusalSchema, Y as MediaFileKindEnum, Yn as evictionPolicyOfLocation, Yr as DEFAULT_FIRST_SIGHTING_FRESHNESS_MS, Yt as RECORDING_SOURCE_CAMSTACK_ADDON, Z as NC_ALARM_DEFAULT_PROFILE_ID, Zn as failureContributionCapability, Zr as evaluateSensorEdge, Zt as RetrainStatusSchema, _ as BaseDevice, _i as looseObject, _n as TrackRelationKindSchema, _r as occupancyScope, _t as NC_TANK_SYSTEM_EVENT_KINDS, a as CLIP_EMBEDDING_STEP_ID, ai as defineCustomActions, an as SCENE_DIVERGED, ar as isEventOwnerType, at as NC_CLIP_PRE_ROLL_DEFAULT_SEC, b as ClearTrackRelationInputSchema, bi as partialRecord, bn as TrackSourceSchema, br as plateGalleryCapability, bt as NcAlarmConfigSchema, c as resolveClusterModelPin, ci as nodePin, cn as SetTrackPurityInputSchema, cr as isRetiredClearKey, ct as NC_CONSUMABLE_LOW_PERCENT_DEFAULT, d as APPEARANCE_RESOLVES_AMBIGUITY_DEFAULT, di as ZodObject, dn as TIMELAPSE_DENSE_FLOOR_SEC, dr as isSystemEventSummaryRule, dt as NC_GROUPING_DEFAULT_SEC, ei as errMsg, en as SCENE_DEFAULT_ANCHOR_THRESHOLD, er as isAlarmControllableCap, et as NC_ALARM_SYSTEM_EVENT_KINDS, f as ARCHIVED_DEBUG_NOTES_DEFAULT_LIMIT, fi as _enum, fn as TRACK_RELATIONS_DEFAULT_LIMIT, fr as isVectorIndexDimConflict, ft as NC_GROUPING_MAX_SEC, g as BIRTH_DECISION_DEFAULT_LIMIT, gi as literal, gn as TrackPuritySchema, gr as notificationRulesCapability, gt as NC_SYSTEM_EVENT_KIND_GROUP_SPECS, h as ArchivedDebugNoteSchema, hi as discriminatedUnion, hn as TimelapseRuleSchema, hr as memoizePinnedFfmpeg, ht as NC_SNOOZE_MAX_MINUTES, ii as customAction, in as SCENE_DEFAULT_UNCOVERED_POLICY, ir as isEventKindLabelKey, it as NC_CLIP_POST_ROLL_DEFAULT_SEC, j as EMBEDDING_REQUIREMENTS_UNAVAILABLE, jn as audioModeOf, jr as ruleGroupingApplies, jt as NcRuleSchema, k as EMBEDDING_MODEL_FAILED, kn as assertTimelapseCadences, kr as ruleBelongsToAlarm$1, kt as NcRuleInputSchema, l as APPEARANCE_BIN_COUNT, ln as SetTrackPurityResultSchema, lr as isScheduleActive, lt as NC_CONSUMABLE_SYSTEM_EVENT_KINDS, m as AlarmArmModeSchema, mi as boolean, mn as TimelapseRulePatchSchema, mr as mayWriteToLocation, mt as NC_SENSOR_TRANSITION_KINDS, ni as CamProfileSchema, nn as SCENE_DEFAULT_OBSERVATION_SPACING_SEC, nr as isDefaultAlarmProfileId, nt as NC_AUDIO_CONFIRM_HITS_DEFAULT, o as FACE_EMBEDDING_STEP_ID, oi as hydrateSchema, on as SCENE_RESET_RECAPTURES, or as isKnownTemplateVar, ot as NC_CLIP_RETENTION_MAX_SEC, p as AUDIO_MACRO_LABELS, pi as array, pn as TimelapseRuleInputSchema, pr as kebabToCamel, pt as NC_GROUPING_MIN_SEC, q as MAX_TRACK_RELATIONS, qn as ensureAlarmProfiles, qr as vectorDimFromBase64, ri as createEvent, rn as SCENE_DEFAULT_QUIET_SECONDS, rr as isDetectionMacroClass, rt as NC_AUDIO_CONFIRM_WINDOW_SEC_DEFAULT, s as readClusterModelPinStrict, si as isDeviceScopedCap, sn as SceneMonitorSchema, sr as isMediaOwnerType, st as NC_CONDITION_CATALOG, t as BUILTIN_CLIP_MODELS, ti as BaseAddon, tn as SCENE_DEFAULT_CHECK_INTERVAL_SEC, tr as isAudioClassName, tt as NC_ALARM_TRIGGERED_MAX_SEC, u as APPEARANCE_BLOCKS, ui as sleep$1, un as SetTrackRelationInputSchema, ur as isSystemDelivery, ut as NC_DEFAULT_SNOOZE_MINUTES, v as BirthDecisionRecordSchema, vi as number, vn as TrackRelationSchema, vt as NC_TAXONOMY, w as DEFAULT_TIMELAPSE_PREVIEW_TEXT, wi as templateLiteral, wn as alarmPanelCapability, wr as renameAlarmProfile, wt as NcConditionDescriptorSchema, x as ClearTrackRelationResultSchema, xi as record, xn as activateAlarmProfile, xr as readDeviceStateFrom, xt as NcAlarmSensorsInputSchema, y as COCO_TO_MACRO, yi as object, yn as TrackSideSchema, yr as pipelineAnalyticsCapability, yt as NC_TEMPLATE_VARS, z as LabelAttributionSchema, zn as describeSystemEventDeliveryProblem, zr as syncActiveAlarmProfileSettings, zt as NcStateSummarySectionIdSchema } from "../clip-model-registry-CshjlVTF.mjs";
2
2
  import { t as __exportAll } from "../embedding-encoder/index.mjs";
3
3
  import * as fs$1 from "node:fs";
4
4
  import { createWriteStream, promises } from "node:fs";
@@ -477,26 +477,37 @@ function buildArmAnnouncement(coverage, deviceName, bypassed = []) {
477
477
  };
478
478
  }
479
479
  /**
480
- * "Attivi: 2 — Bypassati: Finestra veranda."
480
+ * "Attivi: Porta retro, Cancello — Bypassati: Finestra veranda."
481
481
  *
482
- * The bypassed sensors are named and the armed ones are COUNTED, not listed:
483
- * this sentence's subject is the exception, and burying it under thirteen
484
- * camera names is how an operator stops reading the arm notification. The
485
- * count pluralises on what IS armed, like every other arm variant.
482
+ * Both halves are named: the bypassed sensors are why the operator pressed
483
+ * Arm anyway, and the remaining ones are what is still promised. An
484
+ * all-devices mode has no finite list to name — it uses a dedicated key.
486
485
  */
487
486
  function armedWithBypass(coverage, bypassed, deviceName) {
488
487
  const skipped = new Set(coverage.skippedDevices.map((s) => s.deviceId));
489
488
  const excluded = new Set(bypassed);
490
- const armed = coverage.allDevices ? coverage.ruleCount : coverage.deviceIds.filter((id) => !skipped.has(id) && !excluded.has(id)).length;
489
+ const bypassedNames = bypassed.map(deviceName).join(", ");
490
+ if (coverage.allDevices) return {
491
+ bodyKey: "alarm.arm.body.bypassedAll",
492
+ textParams: {
493
+ rules: `${coverage.ruleCount}`,
494
+ mode: coverage.mode,
495
+ bypassedNames
496
+ },
497
+ textCount: coverage.ruleCount
498
+ };
499
+ const armedIds = coverage.deviceIds.filter((id) => !skipped.has(id) && !excluded.has(id));
500
+ const names = armedIds.map(deviceName).join(", ");
491
501
  return {
492
502
  bodyKey: "alarm.arm.body.bypassed",
493
503
  textParams: {
494
504
  rules: `${coverage.ruleCount}`,
495
505
  mode: coverage.mode,
496
- devices: `${armed}`,
497
- bypassedNames: bypassed.map(deviceName).join(", ")
506
+ devices: `${armedIds.length}`,
507
+ names,
508
+ bypassedNames
498
509
  },
499
- textCount: armed
510
+ textCount: armedIds.length
500
511
  };
501
512
  }
502
513
  /**
@@ -938,7 +949,7 @@ function grantMeta(grant) {
938
949
  ...common,
939
950
  ruleId: grant.ruleId,
940
951
  rule: grant.ruleName,
941
- sequence: grant.sequence
952
+ sequenceId: grant.sequenceId
942
953
  };
943
954
  case "snooze": return {
944
955
  ...common,
@@ -1091,10 +1102,11 @@ var NcActionPlane = class {
1091
1102
  * Anything short of fully secure is open: a cover stopped half-way, a lock that
1092
1103
  * jammed, a cover still closing when the arm is decided, a switch that is on.
1093
1104
  *
1094
- * PRECEDENCE, when one device carries more than one opening slice: `contact`
1095
- * first, then `cover`, then `lock-control`, then `switch`. The contact is the
1096
- * physical truth about the opening — a reed switch on the frame says the window
1097
- * is open even when the motor's last reported word says `closed`.
1105
+ * PRECEDENCE, when one device carries more than one opening slice: any OPEN
1106
+ * reading wins over a secure one (a reed that says open beats a motor that
1107
+ * still says `closed`). Among opens, a controllable cap (`cover` / `lock-control`
1108
+ * / `switch`) wins so secure-on-arm knows what to actuate; among secures the
1109
+ * contact wins as the physical "closed" when the motor has not moved yet.
1098
1110
  */
1099
1111
  /** The one state word, per cap, that counts as secure. Everything else is open. */
1100
1112
  var COVER_SECURE = "closed";
@@ -1207,6 +1219,11 @@ function readSwitch(runtime) {
1207
1219
  }
1208
1220
  /** D630's table over one device's runtime slices — see the header for the three answers. */
1209
1221
  function alarmOpeningFrom(runtime) {
1222
+ let openControllable = null;
1223
+ let openAny = null;
1224
+ let secureContact = null;
1225
+ let secureControllable = null;
1226
+ let secureAny = null;
1210
1227
  let firstUnknown = null;
1211
1228
  for (const read of [
1212
1229
  readContact(runtime),
@@ -1214,10 +1231,21 @@ function alarmOpeningFrom(runtime) {
1214
1231
  readLock(runtime),
1215
1232
  readSwitch(runtime)
1216
1233
  ]) {
1217
- if (read.kind === "reading") return read;
1218
- if (read.kind === "unknown" && firstUnknown === null) firstUnknown = read;
1234
+ if (read.kind === "unknown") {
1235
+ if (firstUnknown === null) firstUnknown = read;
1236
+ continue;
1237
+ }
1238
+ if (read.kind !== "reading") continue;
1239
+ if (read.reading === "open") {
1240
+ if (isAlarmControllableCap(read.cap) && openControllable === null) openControllable = read;
1241
+ if (openAny === null) openAny = read;
1242
+ continue;
1243
+ }
1244
+ if (read.cap === "contact" && secureContact === null) secureContact = read;
1245
+ if (isAlarmControllableCap(read.cap) && secureControllable === null) secureControllable = read;
1246
+ if (secureAny === null) secureAny = read;
1219
1247
  }
1220
- return firstUnknown ?? NOT_OPENING;
1248
+ return openControllable ?? openAny ?? secureContact ?? secureControllable ?? secureAny ?? firstUnknown ?? NOT_OPENING;
1221
1249
  }
1222
1250
  /**
1223
1251
  * The sensor-event kind (`CAP_TO_KIND` in `@camstack/types`) → the cap whose
@@ -2743,12 +2771,6 @@ var NcAlarmPanelDevice = class extends BaseDevice {
2743
2771
  error: String(err)
2744
2772
  };
2745
2773
  }
2746
- const toSecure = openings.open.filter((d) => alarmDeviceSecureOnArm({
2747
- controllable: d.controllable,
2748
- deviceId: d.deviceId,
2749
- secureOnArmOff: this.secureOnArmOffFor(mode)
2750
- }));
2751
- if (toSecure.length > 0) this.armGuard?.secureOnArm(toSecure);
2752
2774
  const verdict = decideArm({
2753
2775
  mode,
2754
2776
  open: openings.open,
@@ -2756,6 +2778,14 @@ var NcAlarmPanelDevice = class extends BaseDevice {
2756
2778
  blocking: this.blockingFor(mode),
2757
2779
  ...acknowledged !== void 0 ? { acknowledged } : {}
2758
2780
  });
2781
+ if (verdict.kind === "arm") {
2782
+ const toSecure = openings.open.filter((d) => alarmDeviceSecureOnArm({
2783
+ controllable: d.controllable,
2784
+ deviceId: d.deviceId,
2785
+ secureOnArmOff: this.secureOnArmOffFor(mode)
2786
+ }));
2787
+ if (toSecure.length > 0) this.armGuard?.secureOnArm(toSecure);
2788
+ }
2759
2789
  return {
2760
2790
  kind: "read",
2761
2791
  openings,
@@ -2964,6 +2994,7 @@ var NcAlarmPanelDevice = class extends BaseDevice {
2964
2994
  status(now = Date.now()) {
2965
2995
  return {
2966
2996
  state: stateAt(this.machine, now),
2997
+ targetMode: armModeOf(this.machine.target, NC_ALARM_MODES),
2967
2998
  availableModes: [...NC_ALARM_MODES],
2968
2999
  requiresCode: false,
2969
3000
  lastChangedAt: this.machine.changedAt
@@ -5060,6 +5091,27 @@ function planDenseRanges(input) {
5060
5091
  }
5061
5092
  //#endregion
5062
5093
  //#region src/notification-center/text-compose.ts
5094
+ /**
5095
+ * The COMPOSITE variables — phrases assembled from catalog fragments before
5096
+ * the one and only render pass.
5097
+ *
5098
+ * A notification body is not a sentence with slots. "person (John) on Front
5099
+ * door in gate" contains three sub-phrases whose SHAPE differs per language
5100
+ * and whose presence is conditional: the label parentheses, the zone clause,
5101
+ * the plural noun. Expressing that with `{{class}}{{label}}{{zones}}` and
5102
+ * three scalars pushes the grammar into the template, where a translator
5103
+ * cannot fix it. So each becomes ONE variable — `{{subject}}`, `{{inZones}}`,
5104
+ * `{{op}}`, `{{detectionSummary}}` — composed here from its own keys and handed
5105
+ * to {@link renderTemplate} as a finished string.
5106
+ *
5107
+ * **Fragments see the SCALARS only.** Never the composites. That is what makes
5108
+ * the depth exactly one and not "one so far": a fragment key edited to say
5109
+ * `{{inZones}}` renders empty rather than starting a second round, and
5110
+ * `renderTemplate` never re-scans what it inserted, so a composite's own value
5111
+ * is printed verbatim whatever it contains.
5112
+ *
5113
+ * Pure: a catalog in, a string out. No clock, no store, no I/O.
5114
+ */
5063
5115
  /** One normalisation, used on both sides of every class lookup — a pipeline
5064
5116
  * that emitted `Person` must find the key written `class.person`. */
5065
5117
  function normaliseClass(className) {
@@ -5068,24 +5120,35 @@ function normaliseClass(className) {
5068
5120
  /**
5069
5121
  * The noun for a class, in the count's plural category.
5070
5122
  *
5071
- * A class no locale names falls through to `class.other`, which prints the RAW
5072
- * name: a custom model's class must appear in the sentence rather than vanish
5073
- * out of it, and a missing translation is a smaller defect than a missing
5074
- * detection.
5123
+ * Lookup order: the NC text catalog (`class.*`, plural-aware) → the shared
5124
+ * {@link eventKindLabel} map (viewer filters / EVENT_TAXONOMY) → `class.other`
5125
+ * with the RAW wire id. A custom model class must still appear rather than
5126
+ * vanish; a missing translation is a smaller defect than a missing detection.
5075
5127
  */
5076
5128
  function ncClassNoun(texts, className, count) {
5077
- const key = `class.${normaliseClass(className)}`;
5129
+ const normalised = normaliseClass(className);
5130
+ const key = `class.${normalised}`;
5078
5131
  if (texts.has(key)) return texts.text({
5079
5132
  key,
5080
5133
  count,
5081
5134
  vars: { name: className }
5082
5135
  });
5136
+ if (isEventKindLabelKey(normalised)) return eventKindLabel(normalised, texts.language);
5083
5137
  return texts.text({
5084
5138
  key: "class.other",
5085
5139
  count,
5086
5140
  vars: { name: className }
5087
5141
  });
5088
5142
  }
5143
+ /**
5144
+ * The parenthetical half of a labelled subject: a recognised person or plate
5145
+ * is printed verbatim; a classification (species, COCO sub, …) goes through
5146
+ * the shared event-kind map so "animal (cat)" becomes "animale (Gatto)".
5147
+ */
5148
+ function ncSubjectLabelText(texts, label, labelKind) {
5149
+ if (labelKind === "identity" || labelKind === "plate") return label;
5150
+ return eventKindLabel(label, texts.language);
5151
+ }
5089
5152
  /** `person (John)` / `persona (John)` — the thing the notification is about. */
5090
5153
  function ncSubjectText(texts, input) {
5091
5154
  const classNoun = ncClassNoun(texts, input.className, 1);
@@ -5097,7 +5160,7 @@ function ncSubjectText(texts, input) {
5097
5160
  key: subjectLabelKey(input.labelKind),
5098
5161
  vars: {
5099
5162
  classNoun,
5100
- label: input.label
5163
+ label: ncSubjectLabelText(texts, input.label, input.labelKind)
5101
5164
  }
5102
5165
  });
5103
5166
  }
@@ -8667,31 +8730,48 @@ function replaceConditionReferences(monitor, condition, reference) {
8667
8730
  /** The cap whose presence in a sequence makes the rule the alarm's trigger. */
8668
8731
  var ALARM_PANEL_CAP$1 = "alarm-panel";
8669
8732
  var ALARM_KINDS = new Set(NC_ALARM_SYSTEM_EVENT_KINDS);
8670
- function actuatesAlarmPanel(sequences) {
8671
- for (const sequence of sequences) for (const step of sequence.actions) if (step.kind === "cap" && step.cap === ALARM_PANEL_CAP$1) return true;
8733
+ function stepActuatesAlarm(step) {
8734
+ return step.kind === "cap" && step.cap === ALARM_PANEL_CAP$1;
8735
+ }
8736
+ function bindingActuatesAlarm(binding, sequenceActionsOf) {
8737
+ if (binding.kind === "do") return stepActuatesAlarm(binding.action);
8738
+ const steps = sequenceActionsOf?.(binding.sequenceId);
8739
+ if (steps === void 0) return false;
8740
+ return steps.some(stepActuatesAlarm);
8741
+ }
8742
+ function actionsActuateAlarm(rule, sequenceActionsOf) {
8743
+ const actions = rule.actions;
8744
+ if (actions === void 0) return false;
8745
+ const hooks = [
8746
+ actions.onActivation,
8747
+ actions.onDeactivation,
8748
+ actions.onTrigger,
8749
+ actions.onTriggerOff
8750
+ ];
8751
+ for (const bindings of hooks) for (const binding of bindings ?? []) if (bindingActuatesAlarm(binding, sequenceActionsOf)) return true;
8752
+ for (const button of actions.buttons ?? []) {
8753
+ const steps = sequenceActionsOf?.(button.sequenceId);
8754
+ if (steps !== void 0 && steps.some(stepActuatesAlarm)) return true;
8755
+ }
8672
8756
  return false;
8673
8757
  }
8674
8758
  /**
8675
8759
  * May a snooze window silence this rule?
8676
8760
  *
8677
- * `true` = never. Deliberately answered from the rule alone: the gate reads it
8678
- * on the delivery path, where a fallible lookup would be a read that can
8679
- * DESTROY the exemption (D49) — and the failure direction of this one is
8680
- * silencing an alarm.
8761
+ * `true` = never. Deliberately answered from the rule alone (plus an optional
8762
+ * sequence lookup): the gate reads it on the delivery path, where a fallible
8763
+ * lookup would be a read that can DESTROY the exemption (D49) — and the
8764
+ * failure direction of this one is silencing an alarm. A missing sequence
8765
+ * lookup means sequence-ref bindings cannot prove exemption; Do bindings and
8766
+ * system-event kinds still can.
8681
8767
  */
8682
- function isSnoozeExempt(rule) {
8768
+ function isSnoozeExempt(rule, sequenceActionsOf) {
8683
8769
  const kinds = rule.conditions.systemEvent?.kinds ?? [];
8684
8770
  for (const kind of kinds) if (ALARM_KINDS.has(kind)) return true;
8685
- return actuatesAlarmPanel(rule.actions?.onTrigger ?? []);
8771
+ return actionsActuateAlarm(rule, sequenceActionsOf);
8686
8772
  }
8687
8773
  //#endregion
8688
8774
  //#region src/notification-center/action-buttons.ts
8689
- /** Sequence names the rule actually declares. A button may only name one. */
8690
- function declaredSequences(actions) {
8691
- const names = /* @__PURE__ */ new Set();
8692
- for (const sequence of actions.onTrigger ?? []) names.add(sequence.name);
8693
- return names;
8694
- }
8695
8775
  /**
8696
8776
  * Build the notification's buttons.
8697
8777
  *
@@ -8704,18 +8784,18 @@ function buildActionButtons(input) {
8704
8784
  if (actions === void 0) return [];
8705
8785
  const buttons = actions.buttons ?? [];
8706
8786
  if (buttons.length === 0) return [];
8707
- const known = declaredSequences(actions);
8787
+ const known = input.knownSequenceIds;
8708
8788
  const seen = /* @__PURE__ */ new Set();
8709
8789
  const out = [];
8710
8790
  for (const button of buttons) {
8711
- if (!known.has(button.sequence)) continue;
8791
+ if (!known.has(button.sequenceId)) continue;
8712
8792
  if (seen.has(button.id)) continue;
8713
8793
  seen.add(button.id);
8714
8794
  out.push({
8715
8795
  id: button.id,
8716
8796
  label: button.label,
8717
8797
  url: input.mintUrl({
8718
- sequence: button.sequence,
8798
+ sequenceId: button.sequenceId,
8719
8799
  actionId: button.id
8720
8800
  }),
8721
8801
  ...button.icon !== void 0 ? { icon: button.icon } : {},
@@ -8725,18 +8805,17 @@ function buildActionButtons(input) {
8725
8805
  return out;
8726
8806
  }
8727
8807
  /**
8728
- * Which of a rule's buttons name a sequence it does not have.
8808
+ * Which of a rule's buttons name a sequence that is not known.
8729
8809
  *
8730
8810
  * Exported so the caller can LOG the drop. A button silently missing from a
8731
8811
  * notification is the exact shape of failure this repo keeps paying for — the
8732
8812
  * operator authored it, it never appeared, and nothing said why.
8733
8813
  */
8734
- function unresolvableButtons(actions) {
8814
+ function unresolvableButtons(actions, knownSequenceIds) {
8735
8815
  if (actions === void 0) return [];
8736
8816
  const buttons = actions.buttons ?? [];
8737
8817
  if (buttons.length === 0) return [];
8738
- const known = declaredSequences(actions);
8739
- return buttons.filter((b) => !known.has(b.sequence)).map((b) => `${b.id}→${b.sequence}`);
8818
+ return buttons.filter((b) => !knownSequenceIds.has(b.sequenceId)).map((b) => `${b.id}→${b.sequenceId}`);
8740
8819
  }
8741
8820
  /** How many snooze choices one notification may carry. The schema caps the
8742
8821
  * authored list at 4; this is the same bound applied to a stored rule that
@@ -8787,7 +8866,7 @@ function snoozeChoices(options) {
8787
8866
  * attention than the notification did.
8788
8867
  */
8789
8868
  function buildSnoozeButtons(input) {
8790
- if (isSnoozeExempt(input.rule)) return [];
8869
+ if (isSnoozeExempt(input.rule, input.sequenceActionsOf)) return [];
8791
8870
  return snoozeChoices(input.rule.snoozeOptions).map((minutes) => {
8792
8871
  const actionId = snoozeButtonId(minutes);
8793
8872
  return {
@@ -11076,10 +11155,11 @@ function matchesIdentityScope(subject, scope) {
11076
11155
  * Derived, never a stored flag: both rule editors prune condition keys to the
11077
11156
  * catalog on save, so a new key would be dropped by the first edit.
11078
11157
  */
11079
- function ruleBelongsToAlarm(rule) {
11158
+ function ruleBelongsToAlarm(rule, sequenceActionsOf) {
11080
11159
  return ruleBelongsToAlarm$1({
11081
11160
  conditions: rule.conditions,
11082
- ...rule.actions?.onTrigger !== void 0 ? { onTrigger: rule.actions.onTrigger } : {}
11161
+ ...rule.actions !== void 0 ? { actions: rule.actions } : {},
11162
+ ...sequenceActionsOf !== void 0 ? { sequenceActionsOf } : {}
11083
11163
  });
11084
11164
  }
11085
11165
  /**
@@ -12854,7 +12934,7 @@ function incomingFromAlarmTransition(input) {
12854
12934
  const kind = transition.kind;
12855
12935
  const keys = alarmTextKeys(transition, input.body);
12856
12936
  const sensor = transition.sensor;
12857
- const alarmSource = kind === "alarm-triggered" ? source : void 0;
12937
+ const alarmSource = (kind === "alarm-triggered" || kind === "alarm-pending") && source !== void 0 ? source : void 0;
12858
12938
  const facts = {
12859
12939
  eventId: `alarm:${kind}:${String(transition.at)}${sensor !== void 0 ? `:${String(sensor.deviceId)}` : ""}${transition.refusalId !== void 0 ? `:${transition.refusalId}` : ""}`,
12860
12940
  timestamp: transition.at,
@@ -12967,7 +13047,7 @@ function deriveTankBand(reading) {
12967
13047
  case null: return null;
12968
13048
  }
12969
13049
  }
12970
- function isRecord$7(value) {
13050
+ function isRecord$9(value) {
12971
13051
  return typeof value === "object" && value !== null && !Array.isArray(value);
12972
13052
  }
12973
13053
  function tankStatusOf(value) {
@@ -12983,11 +13063,11 @@ function vacuumTanksOf(data) {
12983
13063
  if (data["capName"] !== "vacuum-control") return null;
12984
13064
  const deviceId = data["deviceId"];
12985
13065
  const slice = data["slice"];
12986
- if (typeof deviceId !== "number" || !isRecord$7(slice)) return null;
13066
+ if (typeof deviceId !== "number" || !isRecord$9(slice)) return null;
12987
13067
  const tanks = [];
12988
13068
  for (const key of NC_TANK_KEYS) {
12989
13069
  const raw = slice[key];
12990
- if (!isRecord$7(raw)) continue;
13070
+ if (!isRecord$9(raw)) continue;
12991
13071
  const level = raw["level"];
12992
13072
  tanks.push({
12993
13073
  key,
@@ -14508,7 +14588,7 @@ var en_default = {
14508
14588
  "snooze.scope.anyRule": "A rule",
14509
14589
  "snooze.scope.rule": "“{{rule}}”",
14510
14590
  "alarm.arm.body.allDevices": "Active: all devices",
14511
- "alarm.arm.body.allDevicesExcept": "Active: all devices except {{count}}: {{names}}",
14591
+ "alarm.arm.body.allDevicesExcept": "Active: all devices except {{names}}",
14512
14592
  "alarm.arm.body.noDevices": "Active: no device restriction",
14513
14593
  "alarm.arm.body.devices": "Active: {{names}}",
14514
14594
  "alarm.arm.body.devicesSkipped": "Active: {{names}}\nExcluded: {{skippedNames}}",
@@ -14518,7 +14598,8 @@ var en_default = {
14518
14598
  "alarm.arming.body.uncovered": "Active: none — no rule uses this mode",
14519
14599
  "alarm.arming.body.allSkipped": "Active: none\nExcluded: {{names}}",
14520
14600
  "alarm.arming.body.skipped": "Excluded: {{names}}",
14521
- "alarm.arm.body.bypassed": "Active: {{devices}}\nBypassed: {{bypassedNames}}",
14601
+ "alarm.arm.body.bypassed": "Active: {{names}}\nBypassed: {{bypassedNames}}",
14602
+ "alarm.arm.body.bypassedAll": "Active: all devices\nBypassed: {{bypassedNames}}",
14522
14603
  "alarm.arming.body.bypassed": "Bypassed: {{bypassedNames}}",
14523
14604
  "alarm.armRefused.body": "Open: {{names}}",
14524
14605
  "alarm.armRefused.body.hint": "Open: {{names}}\nTap “Arm anyway” to arm with them excluded.",
@@ -14835,7 +14916,7 @@ var it_default = {
14835
14916
  "snooze.scope.anyRule": "Una regola",
14836
14917
  "snooze.scope.rule": "“{{rule}}”",
14837
14918
  "alarm.arm.body.allDevices": "Attivi: tutti i dispositivi",
14838
- "alarm.arm.body.allDevicesExcept": "Attivi: tutti i dispositivi tranne {{count}}: {{names}}",
14919
+ "alarm.arm.body.allDevicesExcept": "Attivi: tutti i dispositivi tranne {{names}}",
14839
14920
  "alarm.arm.body.noDevices": "Attivi: nessuna restrizione",
14840
14921
  "alarm.arm.body.devices": "Attivi: {{names}}",
14841
14922
  "alarm.arm.body.devicesSkipped": "Attivi: {{names}}\nEsclusi: {{skippedNames}}",
@@ -14845,7 +14926,8 @@ var it_default = {
14845
14926
  "alarm.arming.body.uncovered": "Attivi: nessuno — nessuna regola usa questa modalità",
14846
14927
  "alarm.arming.body.allSkipped": "Attivi: nessuno\nEsclusi: {{names}}",
14847
14928
  "alarm.arming.body.skipped": "Esclusi: {{names}}",
14848
- "alarm.arm.body.bypassed": "Attivi: {{devices}}\nBypassati: {{bypassedNames}}",
14929
+ "alarm.arm.body.bypassed": "Attivi: {{names}}\nBypassati: {{bypassedNames}}",
14930
+ "alarm.arm.body.bypassedAll": "Attivi: tutti i dispositivi\nBypassati: {{bypassedNames}}",
14849
14931
  "alarm.arming.body.bypassed": "Bypassati: {{bypassedNames}}",
14850
14932
  "alarm.armRefused.body": "Aperti: {{names}}",
14851
14933
  "alarm.armRefused.body.hint": "Aperti: {{names}}\nTocca “Arma comunque” per armare escludendoli.",
@@ -20310,6 +20392,350 @@ var NcDeviceMuteStore = class {
20310
20392
  }
20311
20393
  };
20312
20394
  //#endregion
20395
+ //#region src/notification-center/migrate-legacy-actions.ts
20396
+ function isRecord$8(value) {
20397
+ return typeof value === "object" && value !== null && !Array.isArray(value);
20398
+ }
20399
+ /** A legacy inline sequence row — detected by `name` + `actions` + `enabled`. */
20400
+ function asLegacySequence(value) {
20401
+ const parsed = NcLegacyRuleActionSequenceSchema.safeParse(value);
20402
+ return parsed.success ? parsed.data : null;
20403
+ }
20404
+ /**
20405
+ * True when `onTrigger` still looks like the pre-shared-sequence array of
20406
+ * named sequences (not an array of `{ kind: 'sequence' | 'do', … }` bindings).
20407
+ */
20408
+ function onTriggerLooksLegacy(onTrigger) {
20409
+ if (!Array.isArray(onTrigger) || onTrigger.length === 0) return false;
20410
+ const first = onTrigger[0];
20411
+ if (!isRecord$8(first)) return false;
20412
+ if (first["kind"] === "sequence" || first["kind"] === "do") return false;
20413
+ return asLegacySequence(first) !== null;
20414
+ }
20415
+ function legacyButtonSequenceName(button) {
20416
+ if (!isRecord$8(button)) return null;
20417
+ const name = button["sequence"];
20418
+ return typeof name === "string" && name.length > 0 ? name : null;
20419
+ }
20420
+ /**
20421
+ * Rewrite a raw `actions` block. Returns the (possibly unchanged) new-shape
20422
+ * actions and whether anything was materialised into the sequence store.
20423
+ */
20424
+ async function migrateLegacyRuleActions(actions, createSequence) {
20425
+ if (actions === void 0 || actions === null) return {
20426
+ actions: void 0,
20427
+ migrated: false
20428
+ };
20429
+ if (!isRecord$8(actions)) return {
20430
+ actions: void 0,
20431
+ migrated: false
20432
+ };
20433
+ if (!onTriggerLooksLegacy(actions["onTrigger"]) && actions["sequence"] === void 0) {
20434
+ const buttons = actions["buttons"];
20435
+ if (!(Array.isArray(buttons) && buttons.some((b) => legacyButtonSequenceName(b) !== null))) {
20436
+ const parsed = NcRuleActionsSchema.safeParse(actions);
20437
+ if (parsed.success) return {
20438
+ actions: parsed.data,
20439
+ migrated: false
20440
+ };
20441
+ return {
20442
+ actions: void 0,
20443
+ migrated: false
20444
+ };
20445
+ }
20446
+ }
20447
+ const legacySequences = [];
20448
+ const onTriggerRaw = actions["onTrigger"];
20449
+ if (Array.isArray(onTriggerRaw)) for (const entry of onTriggerRaw) {
20450
+ const seq = asLegacySequence(entry);
20451
+ if (seq !== null) legacySequences.push(seq);
20452
+ }
20453
+ const nameToId = /* @__PURE__ */ new Map();
20454
+ for (const sequence of legacySequences) {
20455
+ const key = sequence.name.trim();
20456
+ if (key === "" || nameToId.has(key)) continue;
20457
+ const created = await createSequence({
20458
+ name: sequence.name,
20459
+ ...sequence.minDelaySec !== void 0 ? { minDelaySec: sequence.minDelaySec } : {},
20460
+ actions: [...sequence.actions]
20461
+ });
20462
+ nameToId.set(key, created.id);
20463
+ }
20464
+ const onTriggerBindings = [];
20465
+ for (const sequence of legacySequences) {
20466
+ if (!sequence.enabled) continue;
20467
+ const id = nameToId.get(sequence.name.trim());
20468
+ if (id === void 0) continue;
20469
+ onTriggerBindings.push({
20470
+ kind: "sequence",
20471
+ sequenceId: id
20472
+ });
20473
+ }
20474
+ const buttonsRaw = actions["buttons"];
20475
+ const buttons = [];
20476
+ if (Array.isArray(buttonsRaw)) for (const button of buttonsRaw) {
20477
+ if (!isRecord$8(button)) continue;
20478
+ const id = typeof button["id"] === "string" ? button["id"] : "";
20479
+ const label = typeof button["label"] === "string" ? button["label"] : "";
20480
+ if (id === "" || label === "") continue;
20481
+ const existingId = typeof button["sequenceId"] === "string" && button["sequenceId"].length > 0 ? button["sequenceId"] : void 0;
20482
+ const legacyName = legacyButtonSequenceName(button);
20483
+ const sequenceId = existingId ?? (legacyName !== null ? nameToId.get(legacyName.trim()) : void 0);
20484
+ if (sequenceId === void 0) continue;
20485
+ buttons.push({
20486
+ id,
20487
+ label,
20488
+ sequenceId,
20489
+ ...typeof button["icon"] === "string" ? { icon: button["icon"] } : {},
20490
+ ...typeof button["destructive"] === "boolean" ? { destructive: button["destructive"] } : {}
20491
+ });
20492
+ }
20493
+ const next = {
20494
+ ...Array.isArray(actions["onActivation"]) ? { onActivation: actions["onActivation"] } : {},
20495
+ ...Array.isArray(actions["onDeactivation"]) ? { onDeactivation: actions["onDeactivation"] } : {},
20496
+ ...onTriggerBindings.length > 0 ? { onTrigger: onTriggerBindings } : {},
20497
+ ...Array.isArray(actions["onTriggerOff"]) ? { onTriggerOff: actions["onTriggerOff"] } : {},
20498
+ ...buttons.length > 0 ? { buttons } : {}
20499
+ };
20500
+ const parsed = NcRuleActionsSchema.safeParse(next);
20501
+ if (!parsed.success) return {
20502
+ actions: void 0,
20503
+ migrated: true
20504
+ };
20505
+ return {
20506
+ actions: (parsed.data.onActivation?.length ?? 0) === 0 && (parsed.data.onDeactivation?.length ?? 0) === 0 && (parsed.data.onTrigger?.length ?? 0) === 0 && (parsed.data.onTriggerOff?.length ?? 0) === 0 && (parsed.data.buttons?.length ?? 0) === 0 ? void 0 : parsed.data,
20507
+ migrated: true
20508
+ };
20509
+ }
20510
+ //#endregion
20511
+ //#region src/notification-center/sequence-store.ts
20512
+ /**
20513
+ * Shared action sequences for the notification centre.
20514
+ *
20515
+ * A sequence is a named, ordered run of steps (`wait` | `cap`) with its own
20516
+ * throttle. Rules and notification buttons bind to it by id — never by copying
20517
+ * the steps inline — so two rules that open the same gate share one budget and
20518
+ * one authoring surface.
20519
+ *
20520
+ * Mirrors {@link NcDeviceMuteStore}: declare the collection, mirror into RAM,
20521
+ * write-through so a failed persist never leaves a phantom sequence the next
20522
+ * boot will not know.
20523
+ */
20524
+ /**
20525
+ * @durable class=config owner=notification-center
20526
+ * write="an admin creates/edits/deletes a shared sequence via
20527
+ * `notificationRules.createSequence` / `updateSequence` / `deleteSequence`,
20528
+ * or the one-shot boot migration that materialises legacy inline sequences"
20529
+ * retention="none — a row goes only when an admin deletes it. Bounded by the
20530
+ * sequence set a human is willing to author."
20531
+ */
20532
+ var NC_SEQUENCES_COLLECTION = "notification-center:sequences";
20533
+ var NC_SEQUENCES_COLUMNS = [
20534
+ {
20535
+ name: "id",
20536
+ type: "TEXT",
20537
+ primaryKey: true,
20538
+ notNull: true
20539
+ },
20540
+ {
20541
+ name: "name",
20542
+ type: "TEXT",
20543
+ notNull: true
20544
+ },
20545
+ {
20546
+ name: "minDelaySec",
20547
+ type: "INTEGER"
20548
+ },
20549
+ {
20550
+ name: "actions",
20551
+ type: "JSON",
20552
+ notNull: true
20553
+ },
20554
+ {
20555
+ name: "createdAt",
20556
+ type: "INTEGER",
20557
+ notNull: true
20558
+ },
20559
+ {
20560
+ name: "updatedAt",
20561
+ type: "INTEGER",
20562
+ notNull: true
20563
+ }
20564
+ ];
20565
+ function isRecord$7(value) {
20566
+ return typeof value === "object" && value !== null && !Array.isArray(value);
20567
+ }
20568
+ function parseActions(raw) {
20569
+ if (!Array.isArray(raw) || raw.length === 0) return null;
20570
+ const out = [];
20571
+ for (const entry of raw) {
20572
+ const parsed = NcRuleActionSchema.safeParse(entry);
20573
+ if (!parsed.success) return null;
20574
+ out.push(parsed.data);
20575
+ }
20576
+ return out;
20577
+ }
20578
+ function rowToSequence(data) {
20579
+ const id = data["id"];
20580
+ const name = data["name"];
20581
+ if (typeof id !== "string" || id.length === 0) return null;
20582
+ if (typeof name !== "string" || name.length === 0) return null;
20583
+ const actions = parseActions(data["actions"]);
20584
+ if (actions === null) return null;
20585
+ const minDelayRaw = data["minDelaySec"];
20586
+ const minDelaySec = typeof minDelayRaw === "number" && Number.isInteger(minDelayRaw) ? minDelayRaw : void 0;
20587
+ const candidate = {
20588
+ id,
20589
+ name,
20590
+ ...minDelaySec !== void 0 ? { minDelaySec } : {},
20591
+ actions: [...actions]
20592
+ };
20593
+ const parsed = NcSharedSequenceSchema.safeParse(candidate);
20594
+ return parsed.success ? parsed.data : null;
20595
+ }
20596
+ var NcSequenceStore = class {
20597
+ byId = /* @__PURE__ */ new Map();
20598
+ store;
20599
+ logger;
20600
+ now;
20601
+ constructor(deps) {
20602
+ this.store = deps.store;
20603
+ this.logger = deps.logger;
20604
+ this.now = deps.now ?? (() => Date.now());
20605
+ }
20606
+ static async declare(store) {
20607
+ await store.declareCollection.mutate({
20608
+ collection: NC_SEQUENCES_COLLECTION,
20609
+ columns: [...NC_SEQUENCES_COLUMNS]
20610
+ });
20611
+ }
20612
+ /**
20613
+ * (Re)hydrate. A failed load KEEPS what is already in memory — dropping every
20614
+ * sequence on a transient store error would leave every button and binding
20615
+ * pointing at nothing until the next successful load.
20616
+ */
20617
+ async load() {
20618
+ try {
20619
+ const rows = await this.store.query.query({
20620
+ collection: NC_SEQUENCES_COLLECTION,
20621
+ filter: { limit: 1e4 }
20622
+ });
20623
+ const next = /* @__PURE__ */ new Map();
20624
+ let skipped = 0;
20625
+ for (const row of rows) {
20626
+ if (!isRecord$7(row.data)) {
20627
+ skipped += 1;
20628
+ continue;
20629
+ }
20630
+ const sequence = rowToSequence(row.data);
20631
+ if (sequence === null) {
20632
+ skipped += 1;
20633
+ continue;
20634
+ }
20635
+ next.set(sequence.id, sequence);
20636
+ }
20637
+ this.byId.clear();
20638
+ for (const [id, sequence] of next) this.byId.set(id, sequence);
20639
+ if (skipped > 0) this.logger.warn("sequence rows skipped as malformed — bindings to them will miss", { meta: { skipped } });
20640
+ } catch (err) {
20641
+ this.logger.warn("sequence load failed — keeping the sequences already in memory", { meta: {
20642
+ error: String(err),
20643
+ sequences: this.byId.size
20644
+ } });
20645
+ }
20646
+ }
20647
+ list() {
20648
+ return [...this.byId.values()].toSorted((a, b) => a.name.localeCompare(b.name));
20649
+ }
20650
+ get(sequenceId) {
20651
+ return this.byId.get(sequenceId) ?? null;
20652
+ }
20653
+ /** Hot-path existence check for button minting. */
20654
+ has(sequenceId) {
20655
+ return this.byId.has(sequenceId);
20656
+ }
20657
+ knownIds() {
20658
+ return new Set(this.byId.keys());
20659
+ }
20660
+ async create(input) {
20661
+ const now = this.now();
20662
+ const sequence = {
20663
+ id: randomUUID(),
20664
+ name: input.name,
20665
+ ...input.minDelaySec !== void 0 ? { minDelaySec: input.minDelaySec } : {},
20666
+ actions: [...input.actions]
20667
+ };
20668
+ await this.persist(sequence, now, now);
20669
+ this.byId.set(sequence.id, sequence);
20670
+ return sequence;
20671
+ }
20672
+ /**
20673
+ * Create with a CALLER-chosen id — used by the legacy migration so a rewrite
20674
+ * can name the sequence before rewriting the rule that points at it.
20675
+ */
20676
+ async createWithId(sequence) {
20677
+ const now = this.now();
20678
+ const stored = {
20679
+ id: sequence.id,
20680
+ name: sequence.name,
20681
+ ...sequence.minDelaySec !== void 0 ? { minDelaySec: sequence.minDelaySec } : {},
20682
+ actions: [...sequence.actions]
20683
+ };
20684
+ await this.persist(stored, now, now);
20685
+ this.byId.set(stored.id, stored);
20686
+ return stored;
20687
+ }
20688
+ async update(sequenceId, input) {
20689
+ if (this.byId.get(sequenceId) === void 0) throw new Error(`sequence ${sequenceId} does not exist`);
20690
+ const now = this.now();
20691
+ const sequence = {
20692
+ id: sequenceId,
20693
+ name: input.name,
20694
+ ...input.minDelaySec !== void 0 ? { minDelaySec: input.minDelaySec } : {},
20695
+ actions: [...input.actions]
20696
+ };
20697
+ const createdAt = await this.readCreatedAt(sequenceId, now);
20698
+ await this.persist(sequence, createdAt, now);
20699
+ this.byId.set(sequenceId, sequence);
20700
+ return sequence;
20701
+ }
20702
+ async delete(sequenceId) {
20703
+ await this.store.delete.mutate({
20704
+ collection: NC_SEQUENCES_COLLECTION,
20705
+ key: sequenceId
20706
+ });
20707
+ this.byId.delete(sequenceId);
20708
+ }
20709
+ async readCreatedAt(sequenceId, fallback) {
20710
+ try {
20711
+ const raw = (await this.store.query.query({
20712
+ collection: NC_SEQUENCES_COLLECTION,
20713
+ filter: {
20714
+ where: { id: sequenceId },
20715
+ limit: 1
20716
+ }
20717
+ }))[0]?.data["createdAt"];
20718
+ return typeof raw === "number" && Number.isFinite(raw) ? raw : fallback;
20719
+ } catch {
20720
+ return fallback;
20721
+ }
20722
+ }
20723
+ async persist(sequence, createdAt, updatedAt) {
20724
+ await this.store.set.mutate({
20725
+ collection: NC_SEQUENCES_COLLECTION,
20726
+ key: sequence.id,
20727
+ value: {
20728
+ id: sequence.id,
20729
+ name: sequence.name,
20730
+ ...sequence.minDelaySec !== void 0 ? { minDelaySec: sequence.minDelaySec } : {},
20731
+ actions: sequence.actions,
20732
+ createdAt,
20733
+ updatedAt
20734
+ }
20735
+ });
20736
+ }
20737
+ };
20738
+ //#endregion
20313
20739
  //#region src/notification-center/device-prune.ts
20314
20740
  function judgeList(list, isDead) {
20315
20741
  const dead = list.filter(isDead);
@@ -20423,58 +20849,49 @@ function pruneRuleConditions(conditions, isDead, notes) {
20423
20849
  if (sceneDevice !== void 0 && isDead(sceneDevice)) notes.kept("conditions.scene", [String(sceneDevice)]);
20424
20850
  return changed ? next : null;
20425
20851
  }
20426
- /** A sequence's device steps, dead ones dropped. `null` when none was dead. */
20427
- function pruneSequence(sequence, isDead) {
20428
- const dead = [];
20429
- const kept = sequence.actions.filter((step) => {
20430
- if (step.kind !== "cap" || !isDead(step.deviceId)) return true;
20431
- dead.push(step.deviceId);
20432
- return false;
20433
- });
20434
- if (dead.length === 0) return null;
20435
- const anyDeviceStep = kept.some((step) => step.kind === "cap");
20436
- const [first, ...rest] = kept;
20437
- if (!anyDeviceStep || first === void 0) return {
20438
- next: "removed",
20439
- dead
20440
- };
20441
- return {
20442
- next: {
20443
- ...sequence,
20444
- actions: [first, ...rest]
20445
- },
20446
- dead
20852
+ /** A Do binding whose device is dead is dropped; sequence refs are left alone
20853
+ * (shared sequences are pruned on their own store, not per-rule). */
20854
+ function pruneHookBindings(bindings, field, isDead, notes) {
20855
+ if (bindings === void 0) return {
20856
+ next: void 0,
20857
+ changed: false
20447
20858
  };
20448
- }
20449
- /** The actions after a device prune — `null` when no step moved. */
20450
- function pruneRuleActions(actions, isDead, notes) {
20451
- const sequences = actions.onTrigger;
20452
- if (sequences === void 0) return null;
20859
+ const kept = [];
20453
20860
  let changed = false;
20454
- const removedNames = /* @__PURE__ */ new Set();
20455
- const nextSequences = [];
20456
- for (const sequence of sequences) {
20457
- const pruned = pruneSequence(sequence, isDead);
20458
- if (pruned === null) {
20459
- nextSequences.push(sequence);
20861
+ for (const binding of bindings) {
20862
+ if (binding.kind === "sequence") {
20863
+ kept.push(binding);
20460
20864
  continue;
20461
20865
  }
20462
- changed = true;
20463
- const field = `actions.onTrigger.${sequence.name}`;
20464
- if (pruned.next === "removed") {
20465
- removedNames.add(sequence.name);
20466
- notes.removed(field, deviceSubjects(pruned.dead), "sequence-removed");
20467
- } else {
20468
- nextSequences.push(pruned.next);
20469
- notes.removed(field, deviceSubjects(pruned.dead));
20866
+ if (binding.action.kind === "cap" && isDead(binding.action.deviceId)) {
20867
+ changed = true;
20868
+ notes.removed(field, deviceSubjects([binding.action.deviceId]), "binding-removed");
20869
+ continue;
20470
20870
  }
20871
+ kept.push(binding);
20471
20872
  }
20472
- if (!changed) return null;
20473
- const buttons = actions.buttons;
20873
+ if (!changed) return {
20874
+ next: bindings,
20875
+ changed: false
20876
+ };
20877
+ return {
20878
+ next: kept.length > 0 ? kept : void 0,
20879
+ changed: true
20880
+ };
20881
+ }
20882
+ /** The actions after a device prune — `null` when no binding moved. */
20883
+ function pruneRuleActions(actions, isDead, notes) {
20884
+ const onActivation = pruneHookBindings(actions.onActivation, "actions.onActivation", isDead, notes);
20885
+ const onDeactivation = pruneHookBindings(actions.onDeactivation, "actions.onDeactivation", isDead, notes);
20886
+ const onTrigger = pruneHookBindings(actions.onTrigger, "actions.onTrigger", isDead, notes);
20887
+ const onTriggerOff = pruneHookBindings(actions.onTriggerOff, "actions.onTriggerOff", isDead, notes);
20888
+ if (!onActivation.changed && !onDeactivation.changed && !onTrigger.changed && !onTriggerOff.changed) return null;
20474
20889
  return {
20475
- ...actions,
20476
- onTrigger: nextSequences,
20477
- ...buttons !== void 0 ? { buttons: buttons.filter((button) => !removedNames.has(button.sequence)) } : {}
20890
+ ...onActivation.next !== void 0 ? { onActivation: [...onActivation.next] } : {},
20891
+ ...onDeactivation.next !== void 0 ? { onDeactivation: [...onDeactivation.next] } : {},
20892
+ ...onTrigger.next !== void 0 ? { onTrigger: [...onTrigger.next] } : {},
20893
+ ...onTriggerOff.next !== void 0 ? { onTriggerOff: [...onTriggerOff.next] } : {},
20894
+ ...actions.buttons !== void 0 ? { buttons: actions.buttons } : {}
20478
20895
  };
20479
20896
  }
20480
20897
  function planRuleDevices(rule, isDead, guard) {
@@ -24709,6 +25126,10 @@ function buildTemplateVars(texts, entry, deviceName, zoneLabels, timeZone) {
24709
25126
  return {
24710
25127
  camera: deviceName,
24711
25128
  class: detection?.className ?? (systemEvent !== void 0 ? "" : subject.className),
25129
+ classLabel: (() => {
25130
+ const wire = detection?.className ?? (systemEvent !== void 0 ? "" : subject.className);
25131
+ return wire.length === 0 ? "" : eventKindLabel(wire, texts.language);
25132
+ })(),
24712
25133
  label: detection?.label ?? subject.label ?? "",
24713
25134
  zones: zoneLabels.join(", "),
24714
25135
  subject: detection?.className !== void 0 ? ncSubjectText(texts, {
@@ -28850,9 +29271,32 @@ function lastRunRow(data) {
28850
29271
  }
28851
29272
  //#endregion
28852
29273
  //#region src/notification-center/rule-actions.ts
29274
+ function wrapDo(action) {
29275
+ const name = action.kind === "wait" ? `wait ${String(action.seconds)}s` : `${action.cap}.${action.method}`;
29276
+ return {
29277
+ id: `do:${name}`,
29278
+ name,
29279
+ enabled: true,
29280
+ actions: [action],
29281
+ throttle: false
29282
+ };
29283
+ }
29284
+ function resolveBinding(binding, lookup) {
29285
+ if (binding.kind === "do") return wrapDo(binding.action);
29286
+ const sequence = lookup(binding.sequenceId);
29287
+ if (sequence === null) return { missing: binding.sequenceId };
29288
+ return {
29289
+ id: sequence.id,
29290
+ name: sequence.name,
29291
+ enabled: true,
29292
+ ...sequence.minDelaySec !== void 0 ? { minDelaySec: sequence.minDelaySec } : {},
29293
+ actions: sequence.actions,
29294
+ throttle: true
29295
+ };
29296
+ }
28853
29297
  var NcRuleActionRunner = class {
28854
29298
  deps;
28855
- /** Last run per `${ruleId}:${sequenceName}` — the throttle's memory. */
29299
+ /** Last run per `sequenceId` — the throttle's memory (shared across rules). */
28856
29300
  lastRunAt = /* @__PURE__ */ new Map();
28857
29301
  now;
28858
29302
  sleep;
@@ -28862,11 +29306,49 @@ var NcRuleActionRunner = class {
28862
29306
  this.sleep = deps.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
28863
29307
  }
28864
29308
  /**
28865
- * Run every enabled sequence, in order.
29309
+ * Resolve bindings via the sequence store and run each, in order.
28866
29310
  *
28867
29311
  * Never throws: an actuation that fails must not cost the NOTIFICATION about
28868
- * the very same event. The operator finds out from the log and from the
28869
- * device not having moved — not from silence on both.
29312
+ * the very same event.
29313
+ */
29314
+ async runBindings(input) {
29315
+ const sequences = [];
29316
+ const missing = [];
29317
+ for (const binding of input.bindings) {
29318
+ const resolved = resolveBinding(binding, input.lookup);
29319
+ if ("missing" in resolved) {
29320
+ this.deps.logger.warn("rule action binding skipped — sequence missing", {
29321
+ tags: { deviceId: input.deviceId },
29322
+ meta: {
29323
+ ruleId: input.ruleId,
29324
+ sequenceId: resolved.missing
29325
+ }
29326
+ });
29327
+ missing.push({
29328
+ id: resolved.missing,
29329
+ name: resolved.missing,
29330
+ ran: false,
29331
+ stepsRun: 0,
29332
+ skippedReason: "missing"
29333
+ });
29334
+ continue;
29335
+ }
29336
+ sequences.push(resolved);
29337
+ }
29338
+ const ran = await this.run({
29339
+ ruleId: input.ruleId,
29340
+ ruleName: input.ruleName,
29341
+ deviceId: input.deviceId,
29342
+ sequences,
29343
+ ...input.origin !== void 0 ? { origin: input.origin } : {}
29344
+ });
29345
+ return [...missing, ...ran];
29346
+ }
29347
+ /**
29348
+ * Run every enabled sequence, in order.
29349
+ *
29350
+ * Prefer {@link runBindings} at call sites that hold bindings. This remains
29351
+ * for button grants (force-enabled shared sequence) and tests.
28870
29352
  */
28871
29353
  async run(input) {
28872
29354
  const out = [];
@@ -28875,38 +29357,41 @@ var NcRuleActionRunner = class {
28875
29357
  }
28876
29358
  async runOne(input, sequence) {
28877
29359
  if (!sequence.enabled) return {
29360
+ id: sequence.id,
28878
29361
  name: sequence.name,
28879
29362
  ran: false,
28880
29363
  stepsRun: 0,
28881
29364
  skippedReason: "disabled"
28882
29365
  };
28883
29366
  if (sequence.actions.length === 0) return {
29367
+ id: sequence.id,
28884
29368
  name: sequence.name,
28885
29369
  ran: false,
28886
29370
  stepsRun: 0,
28887
29371
  skippedReason: "empty"
28888
29372
  };
28889
- const key = `${input.ruleId}:${sequence.name}`;
28890
29373
  const now = this.now();
28891
- const minDelayMs = (sequence.minDelaySec ?? 0) * 1e3;
28892
- const last = this.lastRunAt.get(key);
29374
+ const minDelayMs = sequence.throttle ? (sequence.minDelaySec ?? 0) * 1e3 : 0;
29375
+ const last = this.lastRunAt.get(sequence.id);
28893
29376
  if (minDelayMs > 0 && last !== void 0 && now - last < minDelayMs) {
28894
29377
  this.deps.logger.debug("rule action sequence throttled", {
28895
29378
  tags: { deviceId: input.deviceId },
28896
29379
  meta: {
28897
29380
  ruleId: input.ruleId,
29381
+ sequenceId: sequence.id,
28898
29382
  sequence: sequence.name,
28899
29383
  sinceLastMs: now - last
28900
29384
  }
28901
29385
  });
28902
29386
  return {
29387
+ id: sequence.id,
28903
29388
  name: sequence.name,
28904
29389
  ran: false,
28905
29390
  stepsRun: 0,
28906
29391
  skippedReason: "throttled"
28907
29392
  };
28908
29393
  }
28909
- this.lastRunAt.set(key, now);
29394
+ if (sequence.throttle) this.lastRunAt.set(sequence.id, now);
28910
29395
  let stepsRun = 0;
28911
29396
  for (const [index, action] of sequence.actions.entries()) try {
28912
29397
  await this.step(action, input.origin);
@@ -28917,6 +29402,7 @@ var NcRuleActionRunner = class {
28917
29402
  meta: {
28918
29403
  ruleId: input.ruleId,
28919
29404
  rule: input.ruleName,
29405
+ sequenceId: sequence.id,
28920
29406
  sequence: sequence.name,
28921
29407
  step: index,
28922
29408
  action: action.kind === "cap" ? `${action.cap}.${action.method}` : "wait",
@@ -28924,6 +29410,7 @@ var NcRuleActionRunner = class {
28924
29410
  }
28925
29411
  });
28926
29412
  return {
29413
+ id: sequence.id,
28927
29414
  name: sequence.name,
28928
29415
  ran: true,
28929
29416
  stepsRun,
@@ -28935,11 +29422,13 @@ var NcRuleActionRunner = class {
28935
29422
  meta: {
28936
29423
  ruleId: input.ruleId,
28937
29424
  rule: input.ruleName,
29425
+ sequenceId: sequence.id,
28938
29426
  sequence: sequence.name,
28939
29427
  steps: stepsRun
28940
29428
  }
28941
29429
  });
28942
29430
  return {
29431
+ id: sequence.id,
28943
29432
  name: sequence.name,
28944
29433
  ran: true,
28945
29434
  stepsRun
@@ -28960,6 +29449,17 @@ var NcRuleActionRunner = class {
28960
29449
  });
28961
29450
  }
28962
29451
  };
29452
+ /** Force-enable a shared sequence for a button tap (always runs, still throttled). */
29453
+ function asForcedSequence(sequence) {
29454
+ return {
29455
+ id: sequence.id,
29456
+ name: sequence.name,
29457
+ enabled: true,
29458
+ ...sequence.minDelaySec !== void 0 ? { minDelaySec: sequence.minDelaySec } : {},
29459
+ actions: sequence.actions,
29460
+ throttle: true
29461
+ };
29462
+ }
28963
29463
  //#endregion
28964
29464
  //#region src/notification-center/rule-ai-analyst.ts
28965
29465
  /**
@@ -30806,7 +31306,7 @@ function snoozeVerdict(input, deps) {
30806
31306
  });
30807
31307
  if (active === null) return { kind: "clear" };
30808
31308
  const rule = deps.ruleFor(input.ruleId);
30809
- if (rule !== null && isSnoozeExempt(rule)) return {
31309
+ if (rule !== null && isSnoozeExempt(rule, deps.sequenceActionsOf)) return {
30810
31310
  kind: "exempt",
30811
31311
  snooze: active
30812
31312
  };
@@ -36607,13 +37107,16 @@ function frozenOccupancy(rule, occ) {
36607
37107
  function fireRefFor(rule, subject, kind) {
36608
37108
  const hasEventMedia = kind === "object-event" || kind === "package-event";
36609
37109
  const className = subject.classNames[0];
37110
+ const linkedOwners = subject.trackId === void 0 && subject.linkedStills !== void 0 ? linkedMediaOwners(subject.linkedStills, linkedCameraMode(rule.media)) : void 0;
37111
+ const trackId = subject.trackId ?? linkedOwners?.trackId;
36610
37112
  return {
36611
37113
  ruleId: rule.id,
36612
37114
  ruleName: rule.name,
36613
37115
  deviceId: subject.deviceId,
36614
37116
  ...subject.sourceDeviceId !== void 0 ? { sourceDeviceId: subject.sourceDeviceId } : {},
36615
37117
  recordId: subject.recordId,
36616
- ...subject.trackId !== void 0 ? { trackId: subject.trackId } : {},
37118
+ ...trackId !== void 0 ? { trackId } : {},
37119
+ ...linkedOwners !== void 0 && linkedOwners.extraStills.length > 0 ? { extraStills: linkedOwners.extraStills.map((x) => ({ ...x })) } : {},
36617
37120
  ...hasEventMedia ? { eventId: subject.recordId } : {},
36618
37121
  timestamp: subject.timestamp,
36619
37122
  ...className !== void 0 ? { className } : {},
@@ -36918,6 +37421,8 @@ var NotificationCenter = class {
36918
37421
  snoozes;
36919
37422
  /** The permanent per-camera notification off switch — see `device-mute-store.ts`. */
36920
37423
  deviceMutes;
37424
+ /** Shared action sequences rules and buttons bind to by id. */
37425
+ sequences;
36921
37426
  /** The durable episode rows behind the sustained-state alerts. */
36922
37427
  alertStore;
36923
37428
  /** Battery band → notification, and the reminder sweep. */
@@ -37156,7 +37661,8 @@ var NotificationCenter = class {
37156
37661
  const drafts = [];
37157
37662
  const sequenceButtons = this.actionRunner === null || stored === null ? [] : buildActionButtons({
37158
37663
  actions: stored.actions,
37159
- mintUrl: ({ sequence, actionId }) => {
37664
+ knownSequenceIds: this.sequences.knownIds(),
37665
+ mintUrl: ({ sequenceId, actionId }) => {
37160
37666
  const id = randomUUID();
37161
37667
  drafts.push({
37162
37668
  id,
@@ -37166,7 +37672,7 @@ var NotificationCenter = class {
37166
37672
  id,
37167
37673
  ruleId: input.ruleId,
37168
37674
  ruleName: input.ruleName,
37169
- sequence,
37675
+ sequenceId,
37170
37676
  deviceId: input.deviceId,
37171
37677
  actionId,
37172
37678
  targetId: input.targetId,
@@ -37178,6 +37684,7 @@ var NotificationCenter = class {
37178
37684
  });
37179
37685
  const snoozeButtons = input.ownerUserId === void 0 || stored === null ? [] : buildSnoozeButtons({
37180
37686
  rule: stored,
37687
+ sequenceActionsOf: this.sequenceActionsOf,
37181
37688
  label: (minutes) => this.snoozeButtonLabel(minutes),
37182
37689
  mintUrl: ({ actionId, minutes }) => {
37183
37690
  const id = randomUUID();
@@ -37418,7 +37925,7 @@ var NotificationCenter = class {
37418
37925
  */
37419
37926
  async applyGrantedSnooze(grant) {
37420
37927
  const rule = this.rules.get(grant.ruleId);
37421
- if (rule !== null && isSnoozeExempt(rule)) throw new Error(`"${grant.ruleName}" is an alarm rule and cannot be snoozed`);
37928
+ if (rule !== null && isSnoozeExempt(rule, this.sequenceActionsOf)) throw new Error(`"${grant.ruleName}" is an alarm rule and cannot be snoozed`);
37422
37929
  const snooze = await this.snoozes.create({
37423
37930
  userId: grant.userId,
37424
37931
  scope: "device",
@@ -37717,16 +38224,13 @@ var NotificationCenter = class {
37717
38224
  if (this.actionRunner === null) throw new Error("no actuator is wired on this node");
37718
38225
  const rule = this.rules.get(grant.ruleId);
37719
38226
  if (rule === null) throw new Error(`rule ${grant.ruleId} no longer exists`);
37720
- const sequence = (rule.actions?.onTrigger ?? []).find((s) => s.name === grant.sequence);
37721
- if (sequence === void 0) throw new Error(`sequence "${grant.sequence}" no longer exists on rule ${rule.name}`);
38227
+ const sequence = this.sequences.get(grant.sequenceId);
38228
+ if (sequence === null) throw new Error(`sequence "${grant.sequenceId}" no longer exists`);
37722
38229
  const [outcome] = await this.actionRunner.run({
37723
38230
  ruleId: rule.id,
37724
38231
  ruleName: rule.name,
37725
38232
  deviceId: grant.deviceId,
37726
- sequences: [{
37727
- ...sequence,
37728
- enabled: true
37729
- }]
38233
+ sequences: [asForcedSequence(sequence)]
37730
38234
  });
37731
38235
  if (outcome === void 0) throw new Error("the sequence did not run");
37732
38236
  if (outcome.failedAt !== void 0) throw new Error(`step ${String(outcome.failedAt)} of "${sequence.name}" failed`);
@@ -37845,6 +38349,11 @@ var NotificationCenter = class {
37845
38349
  logger: this.logger.child("device-mute"),
37846
38350
  ...deps.now !== void 0 ? { now: deps.now } : {}
37847
38351
  });
38352
+ this.sequences = new NcSequenceStore({
38353
+ store: deps.store,
38354
+ logger: this.logger.child("sequences"),
38355
+ ...deps.now !== void 0 ? { now: deps.now } : {}
38356
+ });
37848
38357
  this.alertStore = new NcAlertStore({
37849
38358
  store: deps.store,
37850
38359
  logger: this.logger.child("device-alerts")
@@ -38111,6 +38620,7 @@ var NotificationCenter = class {
38111
38620
  await NcStateSummaryService.declare(store);
38112
38621
  await NcSnoozeStore.declare(store);
38113
38622
  await NcDeviceMuteStore.declare(store);
38623
+ await NcSequenceStore.declare(store);
38114
38624
  await TimelapseStore.declare(store);
38115
38625
  await NcSummaryStore.declare(store);
38116
38626
  await NcCalendarEventsStore.declare(store);
@@ -38172,6 +38682,8 @@ var NotificationCenter = class {
38172
38682
  sinceStartMs: now - t0
38173
38683
  } });
38174
38684
  };
38685
+ await timed("sequences", () => this.sequences.load());
38686
+ await timed("migrateLegacyActions", () => this.migrateLegacyRuleActions());
38175
38687
  await Promise.all([
38176
38688
  timed("texts", () => this.reloadTexts()),
38177
38689
  timed("rules", () => this.rules.load()),
@@ -39471,7 +39983,32 @@ var NotificationCenter = class {
39471
39983
  return { success: true };
39472
39984
  },
39473
39985
  setRuleEnabled: async ({ ruleId, enabled }) => {
39474
- await this.rules.setEnabled(ruleId, enabled);
39986
+ const updated = await this.rules.setEnabled(ruleId, enabled);
39987
+ const bindings = enabled ? updated.actions?.onActivation : updated.actions?.onDeactivation;
39988
+ if (bindings !== void 0 && bindings.length > 0 && this.actionRunner !== null) this.actionRunner.runBindings({
39989
+ ruleId: updated.id,
39990
+ ruleName: updated.name,
39991
+ deviceId: this.deviceIdForRuleActions(updated),
39992
+ bindings,
39993
+ lookup: (id) => this.sequences.get(id)
39994
+ });
39995
+ return { success: true };
39996
+ },
39997
+ listSequences: async () => ({ sequences: [...this.sequences.list()] }),
39998
+ getSequence: async ({ sequenceId }) => ({ sequence: this.sequences.get(sequenceId) }),
39999
+ createSequence: async ({ sequence }) => {
40000
+ const created = await this.sequences.create(sequence);
40001
+ this.logger.info("notification sequence created", { meta: { sequenceId: created.id } });
40002
+ return { sequence: created };
40003
+ },
40004
+ updateSequence: async ({ sequenceId, sequence }) => {
40005
+ const updated = await this.sequences.update(sequenceId, sequence);
40006
+ this.logger.info("notification sequence updated", { meta: { sequenceId } });
40007
+ return { sequence: updated };
40008
+ },
40009
+ deleteSequence: async ({ sequenceId }) => {
40010
+ await this.sequences.delete(sequenceId);
40011
+ this.logger.info("notification sequence deleted", { meta: { sequenceId } });
39475
40012
  return { success: true };
39476
40013
  },
39477
40014
  testRule: async ({ rule, lookbackMinutes }) => ({ results: [...await this.dryRun(rule, lookbackMinutes)] }),
@@ -40205,10 +40742,11 @@ var NotificationCenter = class {
40205
40742
  const alarmSource = subject.systemEvent?.alarmSource;
40206
40743
  const sourceRule = alarmSource !== void 0 ? this.rules.get(alarmSource.ruleId) : null;
40207
40744
  const media = sourceRule !== null ? mediaPolicyFields(sourceRule, void 0) : { media: "none" };
40745
+ const mediaDeviceId = alarmSource?.deviceId ?? panel.deviceId;
40208
40746
  const entries = targets.map((targetId) => ({
40209
40747
  ruleId: NC_ALARM_ANNOUNCE_RULE_ID,
40210
40748
  targetId,
40211
- deviceId: panel.deviceId,
40749
+ deviceId: mediaDeviceId,
40212
40750
  recordKind: "system-event",
40213
40751
  recordId: subject.recordId,
40214
40752
  payload: {
@@ -40217,8 +40755,9 @@ var NotificationCenter = class {
40217
40755
  priority: 2,
40218
40756
  ...media,
40219
40757
  subject: {
40220
- deviceId: panel.deviceId,
40758
+ deviceId: mediaDeviceId,
40221
40759
  ...alarmSource?.trackId !== void 0 ? { trackId: alarmSource.trackId } : {},
40760
+ ...alarmSource?.extraStills !== void 0 && alarmSource.extraStills.length > 0 ? { extraStills: alarmSource.extraStills.map((x) => ({ ...x })) } : {},
40222
40761
  ...alarmSource?.eventId !== void 0 ? { eventId: alarmSource.eventId } : {},
40223
40762
  className: "alarm",
40224
40763
  zones: [],
@@ -40968,7 +41507,7 @@ var NotificationCenter = class {
40968
41507
  } : {}
40969
41508
  }
40970
41509
  });
40971
- const unresolvable = unresolvableButtons(rule.actions);
41510
+ const unresolvable = unresolvableButtons(rule.actions, this.sequences.knownIds());
40972
41511
  if (unresolvable.length > 0) this.logger.warn("rule buttons reference sequences that do not exist — not sent", {
40973
41512
  tags: { deviceId: subject.deviceId },
40974
41513
  meta: {
@@ -41095,20 +41634,85 @@ var NotificationCenter = class {
41095
41634
  return this.groupStore.list(query);
41096
41635
  }
41097
41636
  /**
41098
- * Run this rule's `onTrigger` sequences for this match.
41637
+ * Materialise legacy inline `onTrigger` sequences into the shared store and
41638
+ * rewrite each rule's actions to bindings + `buttons.sequenceId`. Runs on RAW
41639
+ * rows before {@link NcRuleStore.load} so Zod never skips them.
41640
+ */
41641
+ async migrateLegacyRuleActions() {
41642
+ let rows;
41643
+ try {
41644
+ rows = await this.deps.store.query.query({
41645
+ collection: NC_RULES_COLLECTION,
41646
+ filter: { limit: 1e4 }
41647
+ });
41648
+ } catch (err) {
41649
+ this.logger.warn("legacy actions migration skipped — could not read rules", { meta: { error: String(err) } });
41650
+ return;
41651
+ }
41652
+ let migrated = 0;
41653
+ for (const row of rows) {
41654
+ const ruleBlob = row.data["rule"];
41655
+ if (typeof ruleBlob !== "object" || ruleBlob === null || Array.isArray(ruleBlob)) continue;
41656
+ const rule = { ...ruleBlob };
41657
+ const ruleId = typeof rule["id"] === "string" ? rule["id"] : void 0;
41658
+ if (ruleId === void 0) continue;
41659
+ const result = await migrateLegacyRuleActions(rule["actions"], async (input) => {
41660
+ return { id: (await this.sequences.create(input)).id };
41661
+ });
41662
+ if (!result.migrated) continue;
41663
+ if (result.actions !== void 0) rule["actions"] = result.actions;
41664
+ else delete rule["actions"];
41665
+ const { id: _rowId, ...columns } = row.data;
41666
+ await this.deps.store.set.mutate({
41667
+ collection: NC_RULES_COLLECTION,
41668
+ key: ruleId,
41669
+ value: {
41670
+ ...columns,
41671
+ rule
41672
+ }
41673
+ });
41674
+ migrated += 1;
41675
+ }
41676
+ if (migrated > 0) this.logger.info("migrated legacy inline rule actions into shared sequences", { meta: {
41677
+ rules: migrated,
41678
+ sequences: this.sequences.list().length
41679
+ } });
41680
+ }
41681
+ /**
41682
+ * True when this match is a clear / leave edge — the subject for
41683
+ * `onTriggerOff`. Raise / enter / ordinary matches use `onTrigger`.
41684
+ */
41685
+ isTriggerOffSubject(subject) {
41686
+ if (subject.sensorRaised === false) return true;
41687
+ if (subject.occupancy !== void 0 && subject.occupancy.occupied === false) return true;
41688
+ return false;
41689
+ }
41690
+ /** Device id for activation/deactivation log tags — first scoped device, else 0. */
41691
+ deviceIdForRuleActions(rule) {
41692
+ const devices = rule.conditions.devices;
41693
+ if (devices !== void 0 && devices.length > 0) {
41694
+ const first = devices[0];
41695
+ if (first !== void 0) return first;
41696
+ }
41697
+ return 0;
41698
+ }
41699
+ sequenceActionsOf = (sequenceId) => this.sequences.get(sequenceId)?.actions;
41700
+ /**
41701
+ * Run this rule's onTrigger / onTriggerOff bindings for this match.
41099
41702
  *
41100
41703
  * Called BEFORE the cooldown gate — see the call site for why. Everything
41101
41704
  * here is fire-and-forget and nothing may throw into the evaluation.
41102
41705
  */
41103
41706
  runRuleActions(rule, subject, kind, controls) {
41104
- const sequences = rule.actions?.onTrigger;
41105
- if (sequences === void 0 || sequences.length === 0) return;
41106
41707
  if (this.actionRunner === null || controls.runActions === false) return;
41107
- this.actionRunner.run({
41708
+ const bindings = this.isTriggerOffSubject(subject) ? rule.actions?.onTriggerOff : rule.actions?.onTrigger;
41709
+ if (bindings === void 0 || bindings.length === 0) return;
41710
+ this.actionRunner.runBindings({
41108
41711
  ruleId: rule.id,
41109
41712
  ruleName: rule.name,
41110
41713
  deviceId: subject.deviceId,
41111
- sequences,
41714
+ bindings,
41715
+ lookup: (id) => this.sequences.get(id),
41112
41716
  origin: fireRefFor(rule, subject, kind)
41113
41717
  });
41114
41718
  }
@@ -41238,7 +41842,10 @@ var NotificationCenter = class {
41238
41842
  ...target.params !== void 0 ? { params: target.params } : {},
41239
41843
  subject: {
41240
41844
  deviceId: subject.deviceId,
41241
- ...alarmSource?.trackId !== void 0 ? { trackId: alarmSource.trackId } : linkedOwners !== void 0 ? {
41845
+ ...alarmSource?.trackId !== void 0 ? {
41846
+ trackId: alarmSource.trackId,
41847
+ ...alarmSource.extraStills !== void 0 && alarmSource.extraStills.length > 0 ? { extraStills: alarmSource.extraStills.map((x) => ({ ...x })) } : {}
41848
+ } : linkedOwners !== void 0 ? {
41242
41849
  trackId: linkedOwners.trackId,
41243
41850
  ...linkedOwners.extraStills.length > 0 ? { extraStills: linkedOwners.extraStills.map((x) => ({ ...x })) } : {},
41244
41851
  analysisTiles: linkedOwners.analysisTiles.map((x) => ({ ...x }))
@@ -42012,7 +42619,8 @@ var NotificationCenter = class {
42012
42619
  snoozeDecision(input) {
42013
42620
  const verdict = snoozeVerdict(input, {
42014
42621
  activeFor: (query) => this.snoozes.activeFor(query),
42015
- ruleFor: (ruleId) => this.rules.get(ruleId)
42622
+ ruleFor: (ruleId) => this.rules.get(ruleId),
42623
+ sequenceActionsOf: this.sequenceActionsOf
42016
42624
  });
42017
42625
  if (verdict.kind === "exempt") this.logger.info("snooze does not apply — alarm rules are never silenced", {
42018
42626
  tags: { deviceId: input.deviceId },