@camstack/addon-post-analysis 1.2.321 → 1.2.323

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-BCx3qCL5.js → clip-model-registry-8IMzj7jJ.js} +4542 -3562
  2. package/dist/{clip-model-registry-Dnm9dfy0.mjs → clip-model-registry-BENGs4rw.mjs} +5926 -5036
  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-CPcZN3mA.mjs → _virtual_mf-localSharedImportMap___mfe_internal__addon_pipeline_analytics_widgets-CGo6RmK3.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-Dt-jaGuG.mjs → hostInit-D5UUhDKa.mjs} +2 -2
  10. package/dist/pipeline-analytics/index.js +941 -108
  11. package/dist/pipeline-analytics/index.mjs +941 -108
  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-CGUnHrR_.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-CVH5tT7I.mjs +0 -26
@@ -1,4 +1,4 @@
1
- import { $ as NC_ALARM_SYSTEM_EVENT_KINDS, $n as isVectorIndexDimConflict, $r as boolean, $t as SCENE_DEFAULT_UNCOVERED_POLICY, A as EMBEDDING_REQUIREMENTS_FAILED, An as describeSystemEventDeliveryProblem, Ar as templateCarriesSummary, At as NcScheduleSchema, B as ListTrackRelationsInputSchema, Bn as failureContributionCapability, Br as BaseAddon, Bt as OPS_LOG_DEFAULT_LIMIT, C as DEFAULT_EVENT_COLOR, Cn as assertTimelapseCadences, Cr as storageOccupancyCapability, Ct as NcConditionDescriptorSchema, D as DeclaredDevices, Dn as consumableLowPercentOf, Dr as systemEventFilterApplies, Dt as NcRuleSchema, E as DETECTION_PIPELINE_CAP_NAME, En as buildEventKindDescriptor, Er as systemEventFamilyProblems, Et as NcRulePatchSchema, F as EXPORT_DENSE_MAX_RANGES, Fr as canonicalHash, Ft as NcSystemEventKindSchema, G as MAX_BIRTH_LATENCY_PROXY_MS, Gn as isDetectionMacroClass, Gr as hydrateSchema, Gt as RebuildObjectEmbeddingsRefusalSchema, H as LockStateSchema, Hn as gifFromMp4Args, Hr as createEvent, I as FIRST_LEVEL_MACRO_CLASSES, Ir as evaluateSensorEdge, It as NcTaxonomySchema, J as MOTION_CLOSE_AFTER_MS, Jn as isMediaOwnerType, Jt as SCENE_CONFIRM_DEFAULT_TIMEOUT_MS, K as MAX_TRACK_PURITIES, Kn as isEventOwnerType, Kr as isDeviceScopedCap, Kt as RetrainStatusSchema, L as FULL_IMAGE_BBOX, Ln as encodeVectorBase64, Lr as isSourceCap, Lt as NcTemplatePreviewInputSchema, M as EVENT_DENSITY_BATCH_MAX, Mr as vectorDimFromBase64, Mt as NcSnoozeSchema, N as EVENT_KIND_BY_CAP, Nr as zoneAnalyticsCapability, Nt as NcSnoozeSuppressedSchema, O as DetectionSourceSchema, On as cosineSimilarity$1, Or as systemEventSummaryScheduleOf, Ot as NcRuleTargetSchema, P as EVENT_OWNER_TYPES, Pn as embeddingRefusalOf, Pr as DEFAULT_FIRST_SIGHTING_FRESHNESS_MS, Pt as NcStateSummarySectionIdSchema, Q as NC_ALARM_DELAY_MAX_SEC, Qn as isSystemEventSummaryRule, Qr as array, Qt as SCENE_DEFAULT_QUIET_SECONDS, R as FailureCounters, Rn as evictionPolicyOfLocation, Rr as DeviceType, Rt as NcTemplatePreviewSchema, S as CoverStateSchema, Sn as applySecureOnArmEdit, Sr as servedTemplateVars, St as NcAlarmSettingsPatchSchema, T as DETECTION_MACRO_CLASSES, Tn as audioModeOf, Tr as systemEventDeliveryProblems, Tt as NcRuleInputSchema, U as MAX_ARCHIVED_DEBUG_NOTES, Un as isAlarmControllableCap, Ur as customAction, Ut as RECORDING_EXPORT_MAX_READ_BYTES, V as ListTrackRelationsResultSchema, Vn as gifDeriveSpeed, Vr as CamProfileSchema, Vt as OpsLogEntrySchema, W as MAX_BIRTH_DECISION_RECORDS, Wn as isAudioClassName, Wr as defineCustomActions, Wt as RECORDING_SOURCE_CAMSTACK_ADDON, X as MotionTriggeredBySchema, Xn as isScheduleActive, Xr as ZodObject, Xt as SCENE_DEFAULT_CHECK_INTERVAL_SEC, Y as MediaFileKindEnum, Yn as isRetiredClearKey, Yr as sleep$1, Yt as SCENE_DEFAULT_ANCHOR_THRESHOLD, Z as NC_ALARM_DELAY_FIELDS, Zn as isSystemDelivery, Zr as _enum, Zt as SCENE_DEFAULT_OBSERVATION_SPACING_SEC, _ as BaseDevice, _n as alarmDeviceSecureOnArm, _r as ruleGroupingApplies, _t as NC_TAXONOMY, a as CLIP_EMBEDDING_STEP_ID, ai as partialRecord, an as SetTrackRelationInputSchema, at as NC_CLIP_RETENTION_MAX_SEC, b as ClearTrackRelationInputSchema, bn as applyModeDelaysEdits, br as secureActuationFor, bt as NcAlarmSensorsInputSchema, c as resolveClusterModelPin, ci as string, cn as TimelapseRuleInputSchema, cr as readDeviceStateFrom, ct as NC_CONSUMABLE_SYSTEM_EVENT_KINDS, d as APPEARANCE_RESOLVES_AMBIGUITY_DEFAULT, di as union, dn as TrackPuritySchema, dr as resolveClipSearchMinScore, dt as NC_GROUPING_MAX_SEC, ei as discriminatedUnion, en as SCENE_DIVERGED, er as kebabToCamel, et as NC_ALARM_TRIGGERED_MAX_SEC, f as ARCHIVED_DEBUG_NOTES_DEFAULT_LIMIT, fi as unknown, fn as TrackRelationKindSchema, fr as resolveLocationMode, ft as NC_GROUPING_MIN_SEC, g as BIRTH_DECISION_DEFAULT_LIMIT, gn as addonWidgetsSourceCapability, gr as ruleDeviceCompatCapQueries, gt as NC_TANK_SYSTEM_EVENT_KINDS, h as ArchivedDebugNoteSchema, hn as TrackSourceSchema, hr as ruleBelongsToAlarm$1, ht as NC_SYSTEM_EVENT_KIND_GROUP_SPECS, ii as object, in as SetTrackPurityResultSchema, ir as occupancyScope, it as NC_CLIP_PRE_ROLL_DEFAULT_SEC, j as EMBEDDING_REQUIREMENTS_UNAVAILABLE, jn as describeSystemEventFamilyProblem, jr as templateVarsFor, jt as NcSnoozeInputSchema, k as EMBEDDING_MODEL_FAILED, kn as deriveRecordingMode, kr as systemEventSummarySplitOf, kt as NcRuleUpdateInputSchema, l as APPEARANCE_BIN_COUNT, li as templateLiteral, ln as TimelapseRulePatchSchema, lr as readMotionTriggeredBy, lt as NC_DEFAULT_SNOOZE_MINUTES, m as AlarmArmModeSchema, mn as TrackSideSchema, mt as NC_SNOOZE_MAX_MINUTES, ni as looseObject, nn as SceneMonitorSchema, nr as memoizePinnedFfmpeg, nt as NC_AUDIO_CONFIRM_WINDOW_SEC_DEFAULT, o as FACE_EMBEDDING_STEP_ID, oi as record, on as TIMELAPSE_DENSE_FLOOR_SEC, or as pipelineAnalyticsCapability, ot as NC_CONDITION_CATALOG, p as AUDIO_MACRO_LABELS, pi as EventCategory, pn as TrackRelationSchema, pr as resolveModeDelays, pt as NC_SENSOR_TRANSITION_KINDS, q as MAX_TRACK_RELATIONS, qn as isKnownTemplateVar, qr as nodePin, qt as SCENE_CONFIRM_DEFAULT_MAX_IMAGE_PX, ri as number, rn as SetTrackPurityInputSchema, rr as notificationRulesCapability, rt as NC_CLIP_POST_ROLL_DEFAULT_SEC, s as readClusterModelPinStrict, si as strictObject, sn as TRACK_RELATIONS_DEFAULT_LIMIT, sr as plateGalleryCapability, st as NC_CONSUMABLE_LOW_PERCENT_DEFAULT, t as BUILTIN_CLIP_MODELS, ti as literal, tn as SCENE_RESET_RECAPTURES, tr as mayWriteToLocation, tt as NC_AUDIO_CONFIRM_HITS_DEFAULT, u as APPEARANCE_BLOCKS, ui as tuple, un as TimelapseRuleSchema, ur as readTimelapseGeneratedAt, ut as NC_GROUPING_DEFAULT_SEC, v as BirthDecisionRecordSchema, vn as alarmPanelCapability, vr as ruleKindOf, vt as NC_TEMPLATE_VARS, w as DEFAULT_TIMELAPSE_PREVIEW_TEXT, wn as audioMetricsCapability, wr as subKindsOf, wt as NcConditionsSchema, x as ClearTrackRelationResultSchema, xn as applyNonBlockingEdit, xr as sensorRaisedOf, xt as NcAlarmSensorsSchema, y as COCO_TO_MACRO, yn as applyBlockingEdit, yr as sceneMonitorCapability, yt as NcAlarmConfigSchema, z as LabelAttributionSchema, zn as faceGalleryCapability, zr as errMsg, zt as NcTemplateVarDescriptorSchema } from "../clip-model-registry-Dnm9dfy0.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-BENGs4rw.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";
@@ -938,7 +938,7 @@ function grantMeta(grant) {
938
938
  ...common,
939
939
  ruleId: grant.ruleId,
940
940
  rule: grant.ruleName,
941
- sequence: grant.sequence
941
+ sequenceId: grant.sequenceId
942
942
  };
943
943
  case "snooze": return {
944
944
  ...common,
@@ -1941,6 +1941,21 @@ var ncAlarmPanelSchema = object({
1941
1941
  exitDelaySec: number().optional(),
1942
1942
  entryDelaySec: number().optional(),
1943
1943
  triggeredDurationSec: number().optional()
1944
+ })).default([]),
1945
+ /**
1946
+ * Active alarm configuration profile id. Empty / absent on a blob older than
1947
+ * profiles → seeded to Default on first read via {@link ensureAlarmProfiles}.
1948
+ */
1949
+ activeProfileId: string().default(NC_ALARM_DEFAULT_PROFILE_ID),
1950
+ /**
1951
+ * Named settings blobs. Loose on purpose — each profile's settings are
1952
+ * re-validated through {@link settingsBlobFromPersisted} so a corrupt entry
1953
+ * drops rather than taking down the panel.
1954
+ */
1955
+ profiles: array(object({
1956
+ id: string(),
1957
+ name: string(),
1958
+ settings: record(string(), unknown())
1944
1959
  })).default([])
1945
1960
  });
1946
1961
  /**
@@ -1963,6 +1978,106 @@ function modeDeviceListFromPersisted(raw) {
1963
1978
  return lists;
1964
1979
  }
1965
1980
  /**
1981
+ * Rebuild a trusted {@link NcAlarmSettings} from a persisted profile blob.
1982
+ * Unknown / corrupt fields fall back to system-safe empties via the flat-key
1983
+ * readers the panel already uses for the live settings.
1984
+ */
1985
+ function settingsBlobFromPersisted(raw) {
1986
+ const exit = typeof raw["exitDelaySec"] === "number" ? raw["exitDelaySec"] : DEFAULT_DELAYS.exitDelaySec;
1987
+ const entry = typeof raw["entryDelaySec"] === "number" ? raw["entryDelaySec"] : DEFAULT_DELAYS.entryDelaySec;
1988
+ const triggered = typeof raw["triggeredDurationSec"] === "number" ? raw["triggeredDurationSec"] : DEFAULT_DELAYS.triggeredDurationSec;
1989
+ const announceTargets = Array.isArray(raw["announceTargets"]) ? raw["announceTargets"].filter((t) => typeof t === "string") : [];
1990
+ const lists = (key) => {
1991
+ const v = raw[key];
1992
+ if (!Array.isArray(v)) return [];
1993
+ const rows = [];
1994
+ for (const item of v) {
1995
+ if (typeof item !== "object" || item === null) continue;
1996
+ const rec = item;
1997
+ if (typeof rec["mode"] !== "string" || !Array.isArray(rec["deviceIds"])) continue;
1998
+ rows.push({
1999
+ mode: rec["mode"],
2000
+ deviceIds: rec["deviceIds"].filter((id) => typeof id === "number")
2001
+ });
2002
+ }
2003
+ return modeDeviceListFromPersisted(rows);
2004
+ };
2005
+ const modeDelaysRaw = Array.isArray(raw["modeDelays"]) ? raw["modeDelays"] : [];
2006
+ const modeDelaysLoose = [];
2007
+ for (const item of modeDelaysRaw) {
2008
+ if (typeof item !== "object" || item === null) continue;
2009
+ const rec = item;
2010
+ if (typeof rec["mode"] !== "string") continue;
2011
+ const entry = { mode: rec["mode"] };
2012
+ if (typeof rec["exitDelaySec"] === "number") entry.exitDelaySec = rec["exitDelaySec"];
2013
+ if (typeof rec["entryDelaySec"] === "number") entry.entryDelaySec = rec["entryDelaySec"];
2014
+ if (typeof rec["triggeredDurationSec"] === "number") entry.triggeredDurationSec = rec["triggeredDurationSec"];
2015
+ modeDelaysLoose.push(entry);
2016
+ }
2017
+ return {
2018
+ exitDelaySec: Math.max(0, Math.min(600, Math.trunc(exit))),
2019
+ entryDelaySec: Math.max(0, Math.min(600, Math.trunc(entry))),
2020
+ triggeredDurationSec: Math.max(0, Math.min(3600, Math.trunc(triggered))),
2021
+ announceTargets,
2022
+ nonBlocking: lists("nonBlocking"),
2023
+ blocking: lists("blocking"),
2024
+ secureOnArmOff: lists("secureOnArmOff"),
2025
+ modeDelays: modeDelaysFromPersisted(modeDelaysLoose).entries
2026
+ };
2027
+ }
2028
+ function settingsToPersisted(settings) {
2029
+ return {
2030
+ exitDelaySec: settings.exitDelaySec,
2031
+ entryDelaySec: settings.entryDelaySec,
2032
+ triggeredDurationSec: settings.triggeredDurationSec,
2033
+ announceTargets: [...settings.announceTargets],
2034
+ nonBlocking: settings.nonBlocking.map((l) => ({
2035
+ mode: l.mode,
2036
+ deviceIds: [...l.deviceIds]
2037
+ })),
2038
+ blocking: settings.blocking.map((l) => ({
2039
+ mode: l.mode,
2040
+ deviceIds: [...l.deviceIds]
2041
+ })),
2042
+ secureOnArmOff: settings.secureOnArmOff.map((l) => ({
2043
+ mode: l.mode,
2044
+ deviceIds: [...l.deviceIds]
2045
+ })),
2046
+ modeDelays: settings.modeDelays.map((e) => ({ ...e }))
2047
+ };
2048
+ }
2049
+ function profilesStateFromPersisted(activeId, rows, current) {
2050
+ const profiles = rows.filter((r) => r.id.length > 0 && r.name.trim().length > 0).map((r) => ({
2051
+ id: r.id,
2052
+ name: r.name,
2053
+ settings: settingsBlobFromPersisted(r.settings)
2054
+ }));
2055
+ return ensureAlarmProfiles(profiles.length > 0 ? {
2056
+ activeId,
2057
+ profiles
2058
+ } : null, current);
2059
+ }
2060
+ function profilesToPersisted(state) {
2061
+ return {
2062
+ activeProfileId: state.activeId,
2063
+ profiles: state.profiles.map((p) => ({
2064
+ id: p.id,
2065
+ name: p.name,
2066
+ settings: settingsToPersisted(p.settings)
2067
+ }))
2068
+ };
2069
+ }
2070
+ function profilesViewOf(state) {
2071
+ return {
2072
+ activeId: state.activeId,
2073
+ items: state.profiles.map((p) => ({
2074
+ id: p.id,
2075
+ name: p.name,
2076
+ isDefault: isDefaultAlarmProfileId(p.id)
2077
+ }))
2078
+ };
2079
+ }
2080
+ /**
1966
2081
  * The log line for a trigger the state machine ignored, per state. `trigger`
1967
2082
  * ignores four: disarmed, arming (the exit delay), and pending / triggered
1968
2083
  * (already counting down or going off — a second sensor must not restart it).
@@ -2210,7 +2325,134 @@ var NcAlarmPanelDevice = class extends BaseDevice {
2210
2325
  changed: membershipChanged
2211
2326
  }
2212
2327
  });
2213
- return this.settings();
2328
+ const settled = this.settings();
2329
+ await this.persistProfilesState(syncActiveAlarmProfileSettings(this.readProfilesState(), settled));
2330
+ return settled;
2331
+ }
2332
+ /** Named profiles + which one is active — for {@link NcAlarmConfig.profiles}. */
2333
+ profilesView() {
2334
+ return profilesViewOf(this.readProfilesState());
2335
+ }
2336
+ /**
2337
+ * Create a named profile (system defaults, or clone). Does not activate.
2338
+ * `newId` is assigned by the caller (usually `randomUUID`).
2339
+ */
2340
+ async createProfile(input) {
2341
+ const result = createAlarmProfile(this.readProfilesState(), {
2342
+ name: input.name,
2343
+ id: input.newId,
2344
+ source: input.source
2345
+ });
2346
+ if (result.kind === "refused") throw this.profilesRefuseError(result.reason, result.detail);
2347
+ await this.persistProfilesState(result.state);
2348
+ this.ctx.logger.info("alarm profile created", {
2349
+ tags: { deviceId: this.id },
2350
+ meta: {
2351
+ profileId: input.newId,
2352
+ name: input.name.trim(),
2353
+ source: input.source.kind
2354
+ }
2355
+ });
2356
+ return profilesViewOf(result.state);
2357
+ }
2358
+ async renameProfile(id, name) {
2359
+ const result = renameAlarmProfile(this.readProfilesState(), {
2360
+ id,
2361
+ name
2362
+ });
2363
+ if (result.kind === "refused") throw this.profilesRefuseError(result.reason, result.detail);
2364
+ await this.persistProfilesState(result.state);
2365
+ this.ctx.logger.info("alarm profile renamed", {
2366
+ tags: { deviceId: this.id },
2367
+ meta: {
2368
+ profileId: id,
2369
+ name: name.trim()
2370
+ }
2371
+ });
2372
+ return profilesViewOf(result.state);
2373
+ }
2374
+ /**
2375
+ * Delete a non-default profile. If it was active, Default becomes active and
2376
+ * its settings are applied to the flat keys (arm path).
2377
+ */
2378
+ async deleteProfile(id) {
2379
+ const before = this.readProfilesState();
2380
+ const result = deleteAlarmProfile(before, id);
2381
+ if (result.kind === "refused") throw this.profilesRefuseError(result.reason, result.detail);
2382
+ const switched = before.activeId === id;
2383
+ await this.persistProfilesState(result.state);
2384
+ if (switched) {
2385
+ const def = result.state.profiles.find((p) => p.id === NC_ALARM_DEFAULT_PROFILE_ID);
2386
+ if (def !== void 0) await this.replaceFlatSettings(def.settings);
2387
+ }
2388
+ this.ctx.logger.info("alarm profile deleted", {
2389
+ tags: { deviceId: this.id },
2390
+ meta: {
2391
+ profileId: id,
2392
+ activatedDefault: switched
2393
+ }
2394
+ });
2395
+ return profilesViewOf(result.state);
2396
+ }
2397
+ /**
2398
+ * Make a profile active and apply its blob onto the flat settings the arm
2399
+ * path reads.
2400
+ */
2401
+ async activateProfile(id) {
2402
+ const result = activateAlarmProfile(syncActiveAlarmProfileSettings(this.readProfilesState(), this.settings()), id);
2403
+ if (result.kind === "refused") throw this.profilesRefuseError(result.reason, result.detail);
2404
+ const next = result.state.profiles.find((p) => p.id === id);
2405
+ if (next === void 0) throw this.profilesRefuseError("not-found", id);
2406
+ await this.persistProfilesState(result.state);
2407
+ await this.replaceFlatSettings(next.settings);
2408
+ this.ctx.logger.info("alarm profile activated", {
2409
+ tags: { deviceId: this.id },
2410
+ meta: {
2411
+ profileId: id,
2412
+ name: next.name
2413
+ }
2414
+ });
2415
+ return profilesViewOf(result.state);
2416
+ }
2417
+ readProfilesState() {
2418
+ return profilesStateFromPersisted(this.config.get("activeProfileId"), this.config.get("profiles"), this.settings());
2419
+ }
2420
+ async persistProfilesState(state) {
2421
+ const persisted = profilesToPersisted(state);
2422
+ await Promise.all([this.config.set("activeProfileId", persisted.activeProfileId), this.config.set("profiles", persisted.profiles)]);
2423
+ }
2424
+ /** Write a full settings blob onto the flat keys the arm path reads. */
2425
+ async replaceFlatSettings(settings) {
2426
+ const blob = cloneAlarmSettings(settings);
2427
+ await Promise.all([
2428
+ this.config.set("exitDelaySec", blob.exitDelaySec),
2429
+ this.config.set("entryDelaySec", blob.entryDelaySec),
2430
+ this.config.set("triggeredDurationSec", blob.triggeredDurationSec),
2431
+ this.config.set("announceTargets", [...blob.announceTargets]),
2432
+ this.config.set("nonBlocking", blob.nonBlocking.map((l) => ({
2433
+ mode: l.mode,
2434
+ deviceIds: [...l.deviceIds]
2435
+ }))),
2436
+ this.config.set("blocking", blob.blocking.map((l) => ({
2437
+ mode: l.mode,
2438
+ deviceIds: [...l.deviceIds]
2439
+ }))),
2440
+ this.config.set("secureOnArmOff", blob.secureOnArmOff.map((l) => ({
2441
+ mode: l.mode,
2442
+ deviceIds: [...l.deviceIds]
2443
+ }))),
2444
+ this.config.set("modeDelays", blob.modeDelays.map((e) => ({ ...e })))
2445
+ ]);
2446
+ }
2447
+ profilesRefuseError(reason, detail) {
2448
+ switch (reason) {
2449
+ case "name-empty": return /* @__PURE__ */ new Error("alarm profile name must not be empty");
2450
+ case "name-taken": return /* @__PURE__ */ new Error(`an alarm profile named '${detail ?? ""}' already exists`);
2451
+ case "cannot-delete-default": return /* @__PURE__ */ new Error("the Default alarm profile cannot be deleted");
2452
+ case "clone-source-missing": return /* @__PURE__ */ new Error(`alarm profile '${detail ?? ""}' was not found to clone`);
2453
+ case "not-found": return /* @__PURE__ */ new Error(`alarm profile '${detail ?? ""}' was not found`);
2454
+ default: return /* @__PURE__ */ new Error(`alarm profile operation refused (${reason})`);
2455
+ }
2214
2456
  }
2215
2457
  /**
2216
2458
  * Refuse a mode this panel does not offer — logged by reason, then thrown. A membership edit
@@ -2943,6 +3185,11 @@ function ncAlarmPanelPort(panel) {
2943
3185
  deviceId: panel.id,
2944
3186
  settings: () => panel.settings(),
2945
3187
  applySettings: (patch) => panel.applySettings(patch),
3188
+ profilesView: () => panel.profilesView(),
3189
+ createProfile: (input) => panel.createProfile(input),
3190
+ renameProfile: (id, name) => panel.renameProfile(id, name),
3191
+ deleteProfile: (id) => panel.deleteProfile(id),
3192
+ activateProfile: (id) => panel.activateProfile(id),
2946
3193
  availableModes: () => NC_ALARM_MODES,
2947
3194
  noteRuleTrigger: (source) => panel.noteRuleTrigger(source),
2948
3195
  excludedDevices: () => panel.excludedDevices(),
@@ -4813,6 +5060,27 @@ function planDenseRanges(input) {
4813
5060
  }
4814
5061
  //#endregion
4815
5062
  //#region src/notification-center/text-compose.ts
5063
+ /**
5064
+ * The COMPOSITE variables — phrases assembled from catalog fragments before
5065
+ * the one and only render pass.
5066
+ *
5067
+ * A notification body is not a sentence with slots. "person (John) on Front
5068
+ * door in gate" contains three sub-phrases whose SHAPE differs per language
5069
+ * and whose presence is conditional: the label parentheses, the zone clause,
5070
+ * the plural noun. Expressing that with `{{class}}{{label}}{{zones}}` and
5071
+ * three scalars pushes the grammar into the template, where a translator
5072
+ * cannot fix it. So each becomes ONE variable — `{{subject}}`, `{{inZones}}`,
5073
+ * `{{op}}`, `{{detectionSummary}}` — composed here from its own keys and handed
5074
+ * to {@link renderTemplate} as a finished string.
5075
+ *
5076
+ * **Fragments see the SCALARS only.** Never the composites. That is what makes
5077
+ * the depth exactly one and not "one so far": a fragment key edited to say
5078
+ * `{{inZones}}` renders empty rather than starting a second round, and
5079
+ * `renderTemplate` never re-scans what it inserted, so a composite's own value
5080
+ * is printed verbatim whatever it contains.
5081
+ *
5082
+ * Pure: a catalog in, a string out. No clock, no store, no I/O.
5083
+ */
4816
5084
  /** One normalisation, used on both sides of every class lookup — a pipeline
4817
5085
  * that emitted `Person` must find the key written `class.person`. */
4818
5086
  function normaliseClass(className) {
@@ -4821,24 +5089,35 @@ function normaliseClass(className) {
4821
5089
  /**
4822
5090
  * The noun for a class, in the count's plural category.
4823
5091
  *
4824
- * A class no locale names falls through to `class.other`, which prints the RAW
4825
- * name: a custom model's class must appear in the sentence rather than vanish
4826
- * out of it, and a missing translation is a smaller defect than a missing
4827
- * detection.
5092
+ * Lookup order: the NC text catalog (`class.*`, plural-aware) → the shared
5093
+ * {@link eventKindLabel} map (viewer filters / EVENT_TAXONOMY) → `class.other`
5094
+ * with the RAW wire id. A custom model class must still appear rather than
5095
+ * vanish; a missing translation is a smaller defect than a missing detection.
4828
5096
  */
4829
5097
  function ncClassNoun(texts, className, count) {
4830
- const key = `class.${normaliseClass(className)}`;
5098
+ const normalised = normaliseClass(className);
5099
+ const key = `class.${normalised}`;
4831
5100
  if (texts.has(key)) return texts.text({
4832
5101
  key,
4833
5102
  count,
4834
5103
  vars: { name: className }
4835
5104
  });
5105
+ if (isEventKindLabelKey(normalised)) return eventKindLabel(normalised, texts.language);
4836
5106
  return texts.text({
4837
5107
  key: "class.other",
4838
5108
  count,
4839
5109
  vars: { name: className }
4840
5110
  });
4841
5111
  }
5112
+ /**
5113
+ * The parenthetical half of a labelled subject: a recognised person or plate
5114
+ * is printed verbatim; a classification (species, COCO sub, …) goes through
5115
+ * the shared event-kind map so "animal (cat)" becomes "animale (Gatto)".
5116
+ */
5117
+ function ncSubjectLabelText(texts, label, labelKind) {
5118
+ if (labelKind === "identity" || labelKind === "plate") return label;
5119
+ return eventKindLabel(label, texts.language);
5120
+ }
4842
5121
  /** `person (John)` / `persona (John)` — the thing the notification is about. */
4843
5122
  function ncSubjectText(texts, input) {
4844
5123
  const classNoun = ncClassNoun(texts, input.className, 1);
@@ -4850,7 +5129,7 @@ function ncSubjectText(texts, input) {
4850
5129
  key: subjectLabelKey(input.labelKind),
4851
5130
  vars: {
4852
5131
  classNoun,
4853
- label: input.label
5132
+ label: ncSubjectLabelText(texts, input.label, input.labelKind)
4854
5133
  }
4855
5134
  });
4856
5135
  }
@@ -8420,31 +8699,48 @@ function replaceConditionReferences(monitor, condition, reference) {
8420
8699
  /** The cap whose presence in a sequence makes the rule the alarm's trigger. */
8421
8700
  var ALARM_PANEL_CAP$1 = "alarm-panel";
8422
8701
  var ALARM_KINDS = new Set(NC_ALARM_SYSTEM_EVENT_KINDS);
8423
- function actuatesAlarmPanel(sequences) {
8424
- for (const sequence of sequences) for (const step of sequence.actions) if (step.kind === "cap" && step.cap === ALARM_PANEL_CAP$1) return true;
8702
+ function stepActuatesAlarm(step) {
8703
+ return step.kind === "cap" && step.cap === ALARM_PANEL_CAP$1;
8704
+ }
8705
+ function bindingActuatesAlarm(binding, sequenceActionsOf) {
8706
+ if (binding.kind === "do") return stepActuatesAlarm(binding.action);
8707
+ const steps = sequenceActionsOf?.(binding.sequenceId);
8708
+ if (steps === void 0) return false;
8709
+ return steps.some(stepActuatesAlarm);
8710
+ }
8711
+ function actionsActuateAlarm(rule, sequenceActionsOf) {
8712
+ const actions = rule.actions;
8713
+ if (actions === void 0) return false;
8714
+ const hooks = [
8715
+ actions.onActivation,
8716
+ actions.onDeactivation,
8717
+ actions.onTrigger,
8718
+ actions.onTriggerOff
8719
+ ];
8720
+ for (const bindings of hooks) for (const binding of bindings ?? []) if (bindingActuatesAlarm(binding, sequenceActionsOf)) return true;
8721
+ for (const button of actions.buttons ?? []) {
8722
+ const steps = sequenceActionsOf?.(button.sequenceId);
8723
+ if (steps !== void 0 && steps.some(stepActuatesAlarm)) return true;
8724
+ }
8425
8725
  return false;
8426
8726
  }
8427
8727
  /**
8428
8728
  * May a snooze window silence this rule?
8429
8729
  *
8430
- * `true` = never. Deliberately answered from the rule alone: the gate reads it
8431
- * on the delivery path, where a fallible lookup would be a read that can
8432
- * DESTROY the exemption (D49) — and the failure direction of this one is
8433
- * silencing an alarm.
8730
+ * `true` = never. Deliberately answered from the rule alone (plus an optional
8731
+ * sequence lookup): the gate reads it on the delivery path, where a fallible
8732
+ * lookup would be a read that can DESTROY the exemption (D49) — and the
8733
+ * failure direction of this one is silencing an alarm. A missing sequence
8734
+ * lookup means sequence-ref bindings cannot prove exemption; Do bindings and
8735
+ * system-event kinds still can.
8434
8736
  */
8435
- function isSnoozeExempt(rule) {
8737
+ function isSnoozeExempt(rule, sequenceActionsOf) {
8436
8738
  const kinds = rule.conditions.systemEvent?.kinds ?? [];
8437
8739
  for (const kind of kinds) if (ALARM_KINDS.has(kind)) return true;
8438
- return actuatesAlarmPanel(rule.actions?.onTrigger ?? []);
8740
+ return actionsActuateAlarm(rule, sequenceActionsOf);
8439
8741
  }
8440
8742
  //#endregion
8441
8743
  //#region src/notification-center/action-buttons.ts
8442
- /** Sequence names the rule actually declares. A button may only name one. */
8443
- function declaredSequences(actions) {
8444
- const names = /* @__PURE__ */ new Set();
8445
- for (const sequence of actions.onTrigger ?? []) names.add(sequence.name);
8446
- return names;
8447
- }
8448
8744
  /**
8449
8745
  * Build the notification's buttons.
8450
8746
  *
@@ -8457,18 +8753,18 @@ function buildActionButtons(input) {
8457
8753
  if (actions === void 0) return [];
8458
8754
  const buttons = actions.buttons ?? [];
8459
8755
  if (buttons.length === 0) return [];
8460
- const known = declaredSequences(actions);
8756
+ const known = input.knownSequenceIds;
8461
8757
  const seen = /* @__PURE__ */ new Set();
8462
8758
  const out = [];
8463
8759
  for (const button of buttons) {
8464
- if (!known.has(button.sequence)) continue;
8760
+ if (!known.has(button.sequenceId)) continue;
8465
8761
  if (seen.has(button.id)) continue;
8466
8762
  seen.add(button.id);
8467
8763
  out.push({
8468
8764
  id: button.id,
8469
8765
  label: button.label,
8470
8766
  url: input.mintUrl({
8471
- sequence: button.sequence,
8767
+ sequenceId: button.sequenceId,
8472
8768
  actionId: button.id
8473
8769
  }),
8474
8770
  ...button.icon !== void 0 ? { icon: button.icon } : {},
@@ -8478,18 +8774,17 @@ function buildActionButtons(input) {
8478
8774
  return out;
8479
8775
  }
8480
8776
  /**
8481
- * Which of a rule's buttons name a sequence it does not have.
8777
+ * Which of a rule's buttons name a sequence that is not known.
8482
8778
  *
8483
8779
  * Exported so the caller can LOG the drop. A button silently missing from a
8484
8780
  * notification is the exact shape of failure this repo keeps paying for — the
8485
8781
  * operator authored it, it never appeared, and nothing said why.
8486
8782
  */
8487
- function unresolvableButtons(actions) {
8783
+ function unresolvableButtons(actions, knownSequenceIds) {
8488
8784
  if (actions === void 0) return [];
8489
8785
  const buttons = actions.buttons ?? [];
8490
8786
  if (buttons.length === 0) return [];
8491
- const known = declaredSequences(actions);
8492
- return buttons.filter((b) => !known.has(b.sequence)).map((b) => `${b.id}→${b.sequence}`);
8787
+ return buttons.filter((b) => !knownSequenceIds.has(b.sequenceId)).map((b) => `${b.id}→${b.sequenceId}`);
8493
8788
  }
8494
8789
  /** How many snooze choices one notification may carry. The schema caps the
8495
8790
  * authored list at 4; this is the same bound applied to a stored rule that
@@ -8540,7 +8835,7 @@ function snoozeChoices(options) {
8540
8835
  * attention than the notification did.
8541
8836
  */
8542
8837
  function buildSnoozeButtons(input) {
8543
- if (isSnoozeExempt(input.rule)) return [];
8838
+ if (isSnoozeExempt(input.rule, input.sequenceActionsOf)) return [];
8544
8839
  return snoozeChoices(input.rule.snoozeOptions).map((minutes) => {
8545
8840
  const actionId = snoozeButtonId(minutes);
8546
8841
  return {
@@ -10829,10 +11124,11 @@ function matchesIdentityScope(subject, scope) {
10829
11124
  * Derived, never a stored flag: both rule editors prune condition keys to the
10830
11125
  * catalog on save, so a new key would be dropped by the first edit.
10831
11126
  */
10832
- function ruleBelongsToAlarm(rule) {
11127
+ function ruleBelongsToAlarm(rule, sequenceActionsOf) {
10833
11128
  return ruleBelongsToAlarm$1({
10834
11129
  conditions: rule.conditions,
10835
- ...rule.actions?.onTrigger !== void 0 ? { onTrigger: rule.actions.onTrigger } : {}
11130
+ ...rule.actions !== void 0 ? { actions: rule.actions } : {},
11131
+ ...sequenceActionsOf !== void 0 ? { sequenceActionsOf } : {}
10836
11132
  });
10837
11133
  }
10838
11134
  /**
@@ -12720,7 +13016,7 @@ function deriveTankBand(reading) {
12720
13016
  case null: return null;
12721
13017
  }
12722
13018
  }
12723
- function isRecord$7(value) {
13019
+ function isRecord$9(value) {
12724
13020
  return typeof value === "object" && value !== null && !Array.isArray(value);
12725
13021
  }
12726
13022
  function tankStatusOf(value) {
@@ -12736,11 +13032,11 @@ function vacuumTanksOf(data) {
12736
13032
  if (data["capName"] !== "vacuum-control") return null;
12737
13033
  const deviceId = data["deviceId"];
12738
13034
  const slice = data["slice"];
12739
- if (typeof deviceId !== "number" || !isRecord$7(slice)) return null;
13035
+ if (typeof deviceId !== "number" || !isRecord$9(slice)) return null;
12740
13036
  const tanks = [];
12741
13037
  for (const key of NC_TANK_KEYS) {
12742
13038
  const raw = slice[key];
12743
- if (!isRecord$7(raw)) continue;
13039
+ if (!isRecord$9(raw)) continue;
12744
13040
  const level = raw["level"];
12745
13041
  tanks.push({
12746
13042
  key,
@@ -20063,6 +20359,350 @@ var NcDeviceMuteStore = class {
20063
20359
  }
20064
20360
  };
20065
20361
  //#endregion
20362
+ //#region src/notification-center/migrate-legacy-actions.ts
20363
+ function isRecord$8(value) {
20364
+ return typeof value === "object" && value !== null && !Array.isArray(value);
20365
+ }
20366
+ /** A legacy inline sequence row — detected by `name` + `actions` + `enabled`. */
20367
+ function asLegacySequence(value) {
20368
+ const parsed = NcLegacyRuleActionSequenceSchema.safeParse(value);
20369
+ return parsed.success ? parsed.data : null;
20370
+ }
20371
+ /**
20372
+ * True when `onTrigger` still looks like the pre-shared-sequence array of
20373
+ * named sequences (not an array of `{ kind: 'sequence' | 'do', … }` bindings).
20374
+ */
20375
+ function onTriggerLooksLegacy(onTrigger) {
20376
+ if (!Array.isArray(onTrigger) || onTrigger.length === 0) return false;
20377
+ const first = onTrigger[0];
20378
+ if (!isRecord$8(first)) return false;
20379
+ if (first["kind"] === "sequence" || first["kind"] === "do") return false;
20380
+ return asLegacySequence(first) !== null;
20381
+ }
20382
+ function legacyButtonSequenceName(button) {
20383
+ if (!isRecord$8(button)) return null;
20384
+ const name = button["sequence"];
20385
+ return typeof name === "string" && name.length > 0 ? name : null;
20386
+ }
20387
+ /**
20388
+ * Rewrite a raw `actions` block. Returns the (possibly unchanged) new-shape
20389
+ * actions and whether anything was materialised into the sequence store.
20390
+ */
20391
+ async function migrateLegacyRuleActions(actions, createSequence) {
20392
+ if (actions === void 0 || actions === null) return {
20393
+ actions: void 0,
20394
+ migrated: false
20395
+ };
20396
+ if (!isRecord$8(actions)) return {
20397
+ actions: void 0,
20398
+ migrated: false
20399
+ };
20400
+ if (!onTriggerLooksLegacy(actions["onTrigger"]) && actions["sequence"] === void 0) {
20401
+ const buttons = actions["buttons"];
20402
+ if (!(Array.isArray(buttons) && buttons.some((b) => legacyButtonSequenceName(b) !== null))) {
20403
+ const parsed = NcRuleActionsSchema.safeParse(actions);
20404
+ if (parsed.success) return {
20405
+ actions: parsed.data,
20406
+ migrated: false
20407
+ };
20408
+ return {
20409
+ actions: void 0,
20410
+ migrated: false
20411
+ };
20412
+ }
20413
+ }
20414
+ const legacySequences = [];
20415
+ const onTriggerRaw = actions["onTrigger"];
20416
+ if (Array.isArray(onTriggerRaw)) for (const entry of onTriggerRaw) {
20417
+ const seq = asLegacySequence(entry);
20418
+ if (seq !== null) legacySequences.push(seq);
20419
+ }
20420
+ const nameToId = /* @__PURE__ */ new Map();
20421
+ for (const sequence of legacySequences) {
20422
+ const key = sequence.name.trim();
20423
+ if (key === "" || nameToId.has(key)) continue;
20424
+ const created = await createSequence({
20425
+ name: sequence.name,
20426
+ ...sequence.minDelaySec !== void 0 ? { minDelaySec: sequence.minDelaySec } : {},
20427
+ actions: [...sequence.actions]
20428
+ });
20429
+ nameToId.set(key, created.id);
20430
+ }
20431
+ const onTriggerBindings = [];
20432
+ for (const sequence of legacySequences) {
20433
+ if (!sequence.enabled) continue;
20434
+ const id = nameToId.get(sequence.name.trim());
20435
+ if (id === void 0) continue;
20436
+ onTriggerBindings.push({
20437
+ kind: "sequence",
20438
+ sequenceId: id
20439
+ });
20440
+ }
20441
+ const buttonsRaw = actions["buttons"];
20442
+ const buttons = [];
20443
+ if (Array.isArray(buttonsRaw)) for (const button of buttonsRaw) {
20444
+ if (!isRecord$8(button)) continue;
20445
+ const id = typeof button["id"] === "string" ? button["id"] : "";
20446
+ const label = typeof button["label"] === "string" ? button["label"] : "";
20447
+ if (id === "" || label === "") continue;
20448
+ const existingId = typeof button["sequenceId"] === "string" && button["sequenceId"].length > 0 ? button["sequenceId"] : void 0;
20449
+ const legacyName = legacyButtonSequenceName(button);
20450
+ const sequenceId = existingId ?? (legacyName !== null ? nameToId.get(legacyName.trim()) : void 0);
20451
+ if (sequenceId === void 0) continue;
20452
+ buttons.push({
20453
+ id,
20454
+ label,
20455
+ sequenceId,
20456
+ ...typeof button["icon"] === "string" ? { icon: button["icon"] } : {},
20457
+ ...typeof button["destructive"] === "boolean" ? { destructive: button["destructive"] } : {}
20458
+ });
20459
+ }
20460
+ const next = {
20461
+ ...Array.isArray(actions["onActivation"]) ? { onActivation: actions["onActivation"] } : {},
20462
+ ...Array.isArray(actions["onDeactivation"]) ? { onDeactivation: actions["onDeactivation"] } : {},
20463
+ ...onTriggerBindings.length > 0 ? { onTrigger: onTriggerBindings } : {},
20464
+ ...Array.isArray(actions["onTriggerOff"]) ? { onTriggerOff: actions["onTriggerOff"] } : {},
20465
+ ...buttons.length > 0 ? { buttons } : {}
20466
+ };
20467
+ const parsed = NcRuleActionsSchema.safeParse(next);
20468
+ if (!parsed.success) return {
20469
+ actions: void 0,
20470
+ migrated: true
20471
+ };
20472
+ return {
20473
+ 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,
20474
+ migrated: true
20475
+ };
20476
+ }
20477
+ //#endregion
20478
+ //#region src/notification-center/sequence-store.ts
20479
+ /**
20480
+ * Shared action sequences for the notification centre.
20481
+ *
20482
+ * A sequence is a named, ordered run of steps (`wait` | `cap`) with its own
20483
+ * throttle. Rules and notification buttons bind to it by id — never by copying
20484
+ * the steps inline — so two rules that open the same gate share one budget and
20485
+ * one authoring surface.
20486
+ *
20487
+ * Mirrors {@link NcDeviceMuteStore}: declare the collection, mirror into RAM,
20488
+ * write-through so a failed persist never leaves a phantom sequence the next
20489
+ * boot will not know.
20490
+ */
20491
+ /**
20492
+ * @durable class=config owner=notification-center
20493
+ * write="an admin creates/edits/deletes a shared sequence via
20494
+ * `notificationRules.createSequence` / `updateSequence` / `deleteSequence`,
20495
+ * or the one-shot boot migration that materialises legacy inline sequences"
20496
+ * retention="none — a row goes only when an admin deletes it. Bounded by the
20497
+ * sequence set a human is willing to author."
20498
+ */
20499
+ var NC_SEQUENCES_COLLECTION = "notification-center:sequences";
20500
+ var NC_SEQUENCES_COLUMNS = [
20501
+ {
20502
+ name: "id",
20503
+ type: "TEXT",
20504
+ primaryKey: true,
20505
+ notNull: true
20506
+ },
20507
+ {
20508
+ name: "name",
20509
+ type: "TEXT",
20510
+ notNull: true
20511
+ },
20512
+ {
20513
+ name: "minDelaySec",
20514
+ type: "INTEGER"
20515
+ },
20516
+ {
20517
+ name: "actions",
20518
+ type: "JSON",
20519
+ notNull: true
20520
+ },
20521
+ {
20522
+ name: "createdAt",
20523
+ type: "INTEGER",
20524
+ notNull: true
20525
+ },
20526
+ {
20527
+ name: "updatedAt",
20528
+ type: "INTEGER",
20529
+ notNull: true
20530
+ }
20531
+ ];
20532
+ function isRecord$7(value) {
20533
+ return typeof value === "object" && value !== null && !Array.isArray(value);
20534
+ }
20535
+ function parseActions(raw) {
20536
+ if (!Array.isArray(raw) || raw.length === 0) return null;
20537
+ const out = [];
20538
+ for (const entry of raw) {
20539
+ const parsed = NcRuleActionSchema.safeParse(entry);
20540
+ if (!parsed.success) return null;
20541
+ out.push(parsed.data);
20542
+ }
20543
+ return out;
20544
+ }
20545
+ function rowToSequence(data) {
20546
+ const id = data["id"];
20547
+ const name = data["name"];
20548
+ if (typeof id !== "string" || id.length === 0) return null;
20549
+ if (typeof name !== "string" || name.length === 0) return null;
20550
+ const actions = parseActions(data["actions"]);
20551
+ if (actions === null) return null;
20552
+ const minDelayRaw = data["minDelaySec"];
20553
+ const minDelaySec = typeof minDelayRaw === "number" && Number.isInteger(minDelayRaw) ? minDelayRaw : void 0;
20554
+ const candidate = {
20555
+ id,
20556
+ name,
20557
+ ...minDelaySec !== void 0 ? { minDelaySec } : {},
20558
+ actions: [...actions]
20559
+ };
20560
+ const parsed = NcSharedSequenceSchema.safeParse(candidate);
20561
+ return parsed.success ? parsed.data : null;
20562
+ }
20563
+ var NcSequenceStore = class {
20564
+ byId = /* @__PURE__ */ new Map();
20565
+ store;
20566
+ logger;
20567
+ now;
20568
+ constructor(deps) {
20569
+ this.store = deps.store;
20570
+ this.logger = deps.logger;
20571
+ this.now = deps.now ?? (() => Date.now());
20572
+ }
20573
+ static async declare(store) {
20574
+ await store.declareCollection.mutate({
20575
+ collection: NC_SEQUENCES_COLLECTION,
20576
+ columns: [...NC_SEQUENCES_COLUMNS]
20577
+ });
20578
+ }
20579
+ /**
20580
+ * (Re)hydrate. A failed load KEEPS what is already in memory — dropping every
20581
+ * sequence on a transient store error would leave every button and binding
20582
+ * pointing at nothing until the next successful load.
20583
+ */
20584
+ async load() {
20585
+ try {
20586
+ const rows = await this.store.query.query({
20587
+ collection: NC_SEQUENCES_COLLECTION,
20588
+ filter: { limit: 1e4 }
20589
+ });
20590
+ const next = /* @__PURE__ */ new Map();
20591
+ let skipped = 0;
20592
+ for (const row of rows) {
20593
+ if (!isRecord$7(row.data)) {
20594
+ skipped += 1;
20595
+ continue;
20596
+ }
20597
+ const sequence = rowToSequence(row.data);
20598
+ if (sequence === null) {
20599
+ skipped += 1;
20600
+ continue;
20601
+ }
20602
+ next.set(sequence.id, sequence);
20603
+ }
20604
+ this.byId.clear();
20605
+ for (const [id, sequence] of next) this.byId.set(id, sequence);
20606
+ if (skipped > 0) this.logger.warn("sequence rows skipped as malformed — bindings to them will miss", { meta: { skipped } });
20607
+ } catch (err) {
20608
+ this.logger.warn("sequence load failed — keeping the sequences already in memory", { meta: {
20609
+ error: String(err),
20610
+ sequences: this.byId.size
20611
+ } });
20612
+ }
20613
+ }
20614
+ list() {
20615
+ return [...this.byId.values()].toSorted((a, b) => a.name.localeCompare(b.name));
20616
+ }
20617
+ get(sequenceId) {
20618
+ return this.byId.get(sequenceId) ?? null;
20619
+ }
20620
+ /** Hot-path existence check for button minting. */
20621
+ has(sequenceId) {
20622
+ return this.byId.has(sequenceId);
20623
+ }
20624
+ knownIds() {
20625
+ return new Set(this.byId.keys());
20626
+ }
20627
+ async create(input) {
20628
+ const now = this.now();
20629
+ const sequence = {
20630
+ id: randomUUID(),
20631
+ name: input.name,
20632
+ ...input.minDelaySec !== void 0 ? { minDelaySec: input.minDelaySec } : {},
20633
+ actions: [...input.actions]
20634
+ };
20635
+ await this.persist(sequence, now, now);
20636
+ this.byId.set(sequence.id, sequence);
20637
+ return sequence;
20638
+ }
20639
+ /**
20640
+ * Create with a CALLER-chosen id — used by the legacy migration so a rewrite
20641
+ * can name the sequence before rewriting the rule that points at it.
20642
+ */
20643
+ async createWithId(sequence) {
20644
+ const now = this.now();
20645
+ const stored = {
20646
+ id: sequence.id,
20647
+ name: sequence.name,
20648
+ ...sequence.minDelaySec !== void 0 ? { minDelaySec: sequence.minDelaySec } : {},
20649
+ actions: [...sequence.actions]
20650
+ };
20651
+ await this.persist(stored, now, now);
20652
+ this.byId.set(stored.id, stored);
20653
+ return stored;
20654
+ }
20655
+ async update(sequenceId, input) {
20656
+ if (this.byId.get(sequenceId) === void 0) throw new Error(`sequence ${sequenceId} does not exist`);
20657
+ const now = this.now();
20658
+ const sequence = {
20659
+ id: sequenceId,
20660
+ name: input.name,
20661
+ ...input.minDelaySec !== void 0 ? { minDelaySec: input.minDelaySec } : {},
20662
+ actions: [...input.actions]
20663
+ };
20664
+ const createdAt = await this.readCreatedAt(sequenceId, now);
20665
+ await this.persist(sequence, createdAt, now);
20666
+ this.byId.set(sequenceId, sequence);
20667
+ return sequence;
20668
+ }
20669
+ async delete(sequenceId) {
20670
+ await this.store.delete.mutate({
20671
+ collection: NC_SEQUENCES_COLLECTION,
20672
+ key: sequenceId
20673
+ });
20674
+ this.byId.delete(sequenceId);
20675
+ }
20676
+ async readCreatedAt(sequenceId, fallback) {
20677
+ try {
20678
+ const raw = (await this.store.query.query({
20679
+ collection: NC_SEQUENCES_COLLECTION,
20680
+ filter: {
20681
+ where: { id: sequenceId },
20682
+ limit: 1
20683
+ }
20684
+ }))[0]?.data["createdAt"];
20685
+ return typeof raw === "number" && Number.isFinite(raw) ? raw : fallback;
20686
+ } catch {
20687
+ return fallback;
20688
+ }
20689
+ }
20690
+ async persist(sequence, createdAt, updatedAt) {
20691
+ await this.store.set.mutate({
20692
+ collection: NC_SEQUENCES_COLLECTION,
20693
+ key: sequence.id,
20694
+ value: {
20695
+ id: sequence.id,
20696
+ name: sequence.name,
20697
+ ...sequence.minDelaySec !== void 0 ? { minDelaySec: sequence.minDelaySec } : {},
20698
+ actions: sequence.actions,
20699
+ createdAt,
20700
+ updatedAt
20701
+ }
20702
+ });
20703
+ }
20704
+ };
20705
+ //#endregion
20066
20706
  //#region src/notification-center/device-prune.ts
20067
20707
  function judgeList(list, isDead) {
20068
20708
  const dead = list.filter(isDead);
@@ -20176,58 +20816,49 @@ function pruneRuleConditions(conditions, isDead, notes) {
20176
20816
  if (sceneDevice !== void 0 && isDead(sceneDevice)) notes.kept("conditions.scene", [String(sceneDevice)]);
20177
20817
  return changed ? next : null;
20178
20818
  }
20179
- /** A sequence's device steps, dead ones dropped. `null` when none was dead. */
20180
- function pruneSequence(sequence, isDead) {
20181
- const dead = [];
20182
- const kept = sequence.actions.filter((step) => {
20183
- if (step.kind !== "cap" || !isDead(step.deviceId)) return true;
20184
- dead.push(step.deviceId);
20185
- return false;
20186
- });
20187
- if (dead.length === 0) return null;
20188
- const anyDeviceStep = kept.some((step) => step.kind === "cap");
20189
- const [first, ...rest] = kept;
20190
- if (!anyDeviceStep || first === void 0) return {
20191
- next: "removed",
20192
- dead
20193
- };
20194
- return {
20195
- next: {
20196
- ...sequence,
20197
- actions: [first, ...rest]
20198
- },
20199
- dead
20819
+ /** A Do binding whose device is dead is dropped; sequence refs are left alone
20820
+ * (shared sequences are pruned on their own store, not per-rule). */
20821
+ function pruneHookBindings(bindings, field, isDead, notes) {
20822
+ if (bindings === void 0) return {
20823
+ next: void 0,
20824
+ changed: false
20200
20825
  };
20201
- }
20202
- /** The actions after a device prune — `null` when no step moved. */
20203
- function pruneRuleActions(actions, isDead, notes) {
20204
- const sequences = actions.onTrigger;
20205
- if (sequences === void 0) return null;
20826
+ const kept = [];
20206
20827
  let changed = false;
20207
- const removedNames = /* @__PURE__ */ new Set();
20208
- const nextSequences = [];
20209
- for (const sequence of sequences) {
20210
- const pruned = pruneSequence(sequence, isDead);
20211
- if (pruned === null) {
20212
- nextSequences.push(sequence);
20828
+ for (const binding of bindings) {
20829
+ if (binding.kind === "sequence") {
20830
+ kept.push(binding);
20213
20831
  continue;
20214
20832
  }
20215
- changed = true;
20216
- const field = `actions.onTrigger.${sequence.name}`;
20217
- if (pruned.next === "removed") {
20218
- removedNames.add(sequence.name);
20219
- notes.removed(field, deviceSubjects(pruned.dead), "sequence-removed");
20220
- } else {
20221
- nextSequences.push(pruned.next);
20222
- notes.removed(field, deviceSubjects(pruned.dead));
20833
+ if (binding.action.kind === "cap" && isDead(binding.action.deviceId)) {
20834
+ changed = true;
20835
+ notes.removed(field, deviceSubjects([binding.action.deviceId]), "binding-removed");
20836
+ continue;
20223
20837
  }
20838
+ kept.push(binding);
20224
20839
  }
20225
- if (!changed) return null;
20226
- const buttons = actions.buttons;
20840
+ if (!changed) return {
20841
+ next: bindings,
20842
+ changed: false
20843
+ };
20227
20844
  return {
20228
- ...actions,
20229
- onTrigger: nextSequences,
20230
- ...buttons !== void 0 ? { buttons: buttons.filter((button) => !removedNames.has(button.sequence)) } : {}
20845
+ next: kept.length > 0 ? kept : void 0,
20846
+ changed: true
20847
+ };
20848
+ }
20849
+ /** The actions after a device prune — `null` when no binding moved. */
20850
+ function pruneRuleActions(actions, isDead, notes) {
20851
+ const onActivation = pruneHookBindings(actions.onActivation, "actions.onActivation", isDead, notes);
20852
+ const onDeactivation = pruneHookBindings(actions.onDeactivation, "actions.onDeactivation", isDead, notes);
20853
+ const onTrigger = pruneHookBindings(actions.onTrigger, "actions.onTrigger", isDead, notes);
20854
+ const onTriggerOff = pruneHookBindings(actions.onTriggerOff, "actions.onTriggerOff", isDead, notes);
20855
+ if (!onActivation.changed && !onDeactivation.changed && !onTrigger.changed && !onTriggerOff.changed) return null;
20856
+ return {
20857
+ ...onActivation.next !== void 0 ? { onActivation: [...onActivation.next] } : {},
20858
+ ...onDeactivation.next !== void 0 ? { onDeactivation: [...onDeactivation.next] } : {},
20859
+ ...onTrigger.next !== void 0 ? { onTrigger: [...onTrigger.next] } : {},
20860
+ ...onTriggerOff.next !== void 0 ? { onTriggerOff: [...onTriggerOff.next] } : {},
20861
+ ...actions.buttons !== void 0 ? { buttons: actions.buttons } : {}
20231
20862
  };
20232
20863
  }
20233
20864
  function planRuleDevices(rule, isDead, guard) {
@@ -24462,6 +25093,10 @@ function buildTemplateVars(texts, entry, deviceName, zoneLabels, timeZone) {
24462
25093
  return {
24463
25094
  camera: deviceName,
24464
25095
  class: detection?.className ?? (systemEvent !== void 0 ? "" : subject.className),
25096
+ classLabel: (() => {
25097
+ const wire = detection?.className ?? (systemEvent !== void 0 ? "" : subject.className);
25098
+ return wire.length === 0 ? "" : eventKindLabel(wire, texts.language);
25099
+ })(),
24465
25100
  label: detection?.label ?? subject.label ?? "",
24466
25101
  zones: zoneLabels.join(", "),
24467
25102
  subject: detection?.className !== void 0 ? ncSubjectText(texts, {
@@ -28603,9 +29238,32 @@ function lastRunRow(data) {
28603
29238
  }
28604
29239
  //#endregion
28605
29240
  //#region src/notification-center/rule-actions.ts
29241
+ function wrapDo(action) {
29242
+ const name = action.kind === "wait" ? `wait ${String(action.seconds)}s` : `${action.cap}.${action.method}`;
29243
+ return {
29244
+ id: `do:${name}`,
29245
+ name,
29246
+ enabled: true,
29247
+ actions: [action],
29248
+ throttle: false
29249
+ };
29250
+ }
29251
+ function resolveBinding(binding, lookup) {
29252
+ if (binding.kind === "do") return wrapDo(binding.action);
29253
+ const sequence = lookup(binding.sequenceId);
29254
+ if (sequence === null) return { missing: binding.sequenceId };
29255
+ return {
29256
+ id: sequence.id,
29257
+ name: sequence.name,
29258
+ enabled: true,
29259
+ ...sequence.minDelaySec !== void 0 ? { minDelaySec: sequence.minDelaySec } : {},
29260
+ actions: sequence.actions,
29261
+ throttle: true
29262
+ };
29263
+ }
28606
29264
  var NcRuleActionRunner = class {
28607
29265
  deps;
28608
- /** Last run per `${ruleId}:${sequenceName}` — the throttle's memory. */
29266
+ /** Last run per `sequenceId` — the throttle's memory (shared across rules). */
28609
29267
  lastRunAt = /* @__PURE__ */ new Map();
28610
29268
  now;
28611
29269
  sleep;
@@ -28615,11 +29273,49 @@ var NcRuleActionRunner = class {
28615
29273
  this.sleep = deps.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
28616
29274
  }
28617
29275
  /**
28618
- * Run every enabled sequence, in order.
29276
+ * Resolve bindings via the sequence store and run each, in order.
28619
29277
  *
28620
29278
  * Never throws: an actuation that fails must not cost the NOTIFICATION about
28621
- * the very same event. The operator finds out from the log and from the
28622
- * device not having moved — not from silence on both.
29279
+ * the very same event.
29280
+ */
29281
+ async runBindings(input) {
29282
+ const sequences = [];
29283
+ const missing = [];
29284
+ for (const binding of input.bindings) {
29285
+ const resolved = resolveBinding(binding, input.lookup);
29286
+ if ("missing" in resolved) {
29287
+ this.deps.logger.warn("rule action binding skipped — sequence missing", {
29288
+ tags: { deviceId: input.deviceId },
29289
+ meta: {
29290
+ ruleId: input.ruleId,
29291
+ sequenceId: resolved.missing
29292
+ }
29293
+ });
29294
+ missing.push({
29295
+ id: resolved.missing,
29296
+ name: resolved.missing,
29297
+ ran: false,
29298
+ stepsRun: 0,
29299
+ skippedReason: "missing"
29300
+ });
29301
+ continue;
29302
+ }
29303
+ sequences.push(resolved);
29304
+ }
29305
+ const ran = await this.run({
29306
+ ruleId: input.ruleId,
29307
+ ruleName: input.ruleName,
29308
+ deviceId: input.deviceId,
29309
+ sequences,
29310
+ ...input.origin !== void 0 ? { origin: input.origin } : {}
29311
+ });
29312
+ return [...missing, ...ran];
29313
+ }
29314
+ /**
29315
+ * Run every enabled sequence, in order.
29316
+ *
29317
+ * Prefer {@link runBindings} at call sites that hold bindings. This remains
29318
+ * for button grants (force-enabled shared sequence) and tests.
28623
29319
  */
28624
29320
  async run(input) {
28625
29321
  const out = [];
@@ -28628,38 +29324,41 @@ var NcRuleActionRunner = class {
28628
29324
  }
28629
29325
  async runOne(input, sequence) {
28630
29326
  if (!sequence.enabled) return {
29327
+ id: sequence.id,
28631
29328
  name: sequence.name,
28632
29329
  ran: false,
28633
29330
  stepsRun: 0,
28634
29331
  skippedReason: "disabled"
28635
29332
  };
28636
29333
  if (sequence.actions.length === 0) return {
29334
+ id: sequence.id,
28637
29335
  name: sequence.name,
28638
29336
  ran: false,
28639
29337
  stepsRun: 0,
28640
29338
  skippedReason: "empty"
28641
29339
  };
28642
- const key = `${input.ruleId}:${sequence.name}`;
28643
29340
  const now = this.now();
28644
- const minDelayMs = (sequence.minDelaySec ?? 0) * 1e3;
28645
- const last = this.lastRunAt.get(key);
29341
+ const minDelayMs = sequence.throttle ? (sequence.minDelaySec ?? 0) * 1e3 : 0;
29342
+ const last = this.lastRunAt.get(sequence.id);
28646
29343
  if (minDelayMs > 0 && last !== void 0 && now - last < minDelayMs) {
28647
29344
  this.deps.logger.debug("rule action sequence throttled", {
28648
29345
  tags: { deviceId: input.deviceId },
28649
29346
  meta: {
28650
29347
  ruleId: input.ruleId,
29348
+ sequenceId: sequence.id,
28651
29349
  sequence: sequence.name,
28652
29350
  sinceLastMs: now - last
28653
29351
  }
28654
29352
  });
28655
29353
  return {
29354
+ id: sequence.id,
28656
29355
  name: sequence.name,
28657
29356
  ran: false,
28658
29357
  stepsRun: 0,
28659
29358
  skippedReason: "throttled"
28660
29359
  };
28661
29360
  }
28662
- this.lastRunAt.set(key, now);
29361
+ if (sequence.throttle) this.lastRunAt.set(sequence.id, now);
28663
29362
  let stepsRun = 0;
28664
29363
  for (const [index, action] of sequence.actions.entries()) try {
28665
29364
  await this.step(action, input.origin);
@@ -28670,6 +29369,7 @@ var NcRuleActionRunner = class {
28670
29369
  meta: {
28671
29370
  ruleId: input.ruleId,
28672
29371
  rule: input.ruleName,
29372
+ sequenceId: sequence.id,
28673
29373
  sequence: sequence.name,
28674
29374
  step: index,
28675
29375
  action: action.kind === "cap" ? `${action.cap}.${action.method}` : "wait",
@@ -28677,6 +29377,7 @@ var NcRuleActionRunner = class {
28677
29377
  }
28678
29378
  });
28679
29379
  return {
29380
+ id: sequence.id,
28680
29381
  name: sequence.name,
28681
29382
  ran: true,
28682
29383
  stepsRun,
@@ -28688,11 +29389,13 @@ var NcRuleActionRunner = class {
28688
29389
  meta: {
28689
29390
  ruleId: input.ruleId,
28690
29391
  rule: input.ruleName,
29392
+ sequenceId: sequence.id,
28691
29393
  sequence: sequence.name,
28692
29394
  steps: stepsRun
28693
29395
  }
28694
29396
  });
28695
29397
  return {
29398
+ id: sequence.id,
28696
29399
  name: sequence.name,
28697
29400
  ran: true,
28698
29401
  stepsRun
@@ -28713,6 +29416,17 @@ var NcRuleActionRunner = class {
28713
29416
  });
28714
29417
  }
28715
29418
  };
29419
+ /** Force-enable a shared sequence for a button tap (always runs, still throttled). */
29420
+ function asForcedSequence(sequence) {
29421
+ return {
29422
+ id: sequence.id,
29423
+ name: sequence.name,
29424
+ enabled: true,
29425
+ ...sequence.minDelaySec !== void 0 ? { minDelaySec: sequence.minDelaySec } : {},
29426
+ actions: sequence.actions,
29427
+ throttle: true
29428
+ };
29429
+ }
28716
29430
  //#endregion
28717
29431
  //#region src/notification-center/rule-ai-analyst.ts
28718
29432
  /**
@@ -30559,7 +31273,7 @@ function snoozeVerdict(input, deps) {
30559
31273
  });
30560
31274
  if (active === null) return { kind: "clear" };
30561
31275
  const rule = deps.ruleFor(input.ruleId);
30562
- if (rule !== null && isSnoozeExempt(rule)) return {
31276
+ if (rule !== null && isSnoozeExempt(rule, deps.sequenceActionsOf)) return {
30563
31277
  kind: "exempt",
30564
31278
  snooze: active
30565
31279
  };
@@ -36671,6 +37385,8 @@ var NotificationCenter = class {
36671
37385
  snoozes;
36672
37386
  /** The permanent per-camera notification off switch — see `device-mute-store.ts`. */
36673
37387
  deviceMutes;
37388
+ /** Shared action sequences rules and buttons bind to by id. */
37389
+ sequences;
36674
37390
  /** The durable episode rows behind the sustained-state alerts. */
36675
37391
  alertStore;
36676
37392
  /** Battery band → notification, and the reminder sweep. */
@@ -36909,7 +37625,8 @@ var NotificationCenter = class {
36909
37625
  const drafts = [];
36910
37626
  const sequenceButtons = this.actionRunner === null || stored === null ? [] : buildActionButtons({
36911
37627
  actions: stored.actions,
36912
- mintUrl: ({ sequence, actionId }) => {
37628
+ knownSequenceIds: this.sequences.knownIds(),
37629
+ mintUrl: ({ sequenceId, actionId }) => {
36913
37630
  const id = randomUUID();
36914
37631
  drafts.push({
36915
37632
  id,
@@ -36919,7 +37636,7 @@ var NotificationCenter = class {
36919
37636
  id,
36920
37637
  ruleId: input.ruleId,
36921
37638
  ruleName: input.ruleName,
36922
- sequence,
37639
+ sequenceId,
36923
37640
  deviceId: input.deviceId,
36924
37641
  actionId,
36925
37642
  targetId: input.targetId,
@@ -36931,6 +37648,7 @@ var NotificationCenter = class {
36931
37648
  });
36932
37649
  const snoozeButtons = input.ownerUserId === void 0 || stored === null ? [] : buildSnoozeButtons({
36933
37650
  rule: stored,
37651
+ sequenceActionsOf: this.sequenceActionsOf,
36934
37652
  label: (minutes) => this.snoozeButtonLabel(minutes),
36935
37653
  mintUrl: ({ actionId, minutes }) => {
36936
37654
  const id = randomUUID();
@@ -37171,7 +37889,7 @@ var NotificationCenter = class {
37171
37889
  */
37172
37890
  async applyGrantedSnooze(grant) {
37173
37891
  const rule = this.rules.get(grant.ruleId);
37174
- if (rule !== null && isSnoozeExempt(rule)) throw new Error(`"${grant.ruleName}" is an alarm rule and cannot be snoozed`);
37892
+ if (rule !== null && isSnoozeExempt(rule, this.sequenceActionsOf)) throw new Error(`"${grant.ruleName}" is an alarm rule and cannot be snoozed`);
37175
37893
  const snooze = await this.snoozes.create({
37176
37894
  userId: grant.userId,
37177
37895
  scope: "device",
@@ -37470,16 +38188,13 @@ var NotificationCenter = class {
37470
38188
  if (this.actionRunner === null) throw new Error("no actuator is wired on this node");
37471
38189
  const rule = this.rules.get(grant.ruleId);
37472
38190
  if (rule === null) throw new Error(`rule ${grant.ruleId} no longer exists`);
37473
- const sequence = (rule.actions?.onTrigger ?? []).find((s) => s.name === grant.sequence);
37474
- if (sequence === void 0) throw new Error(`sequence "${grant.sequence}" no longer exists on rule ${rule.name}`);
38191
+ const sequence = this.sequences.get(grant.sequenceId);
38192
+ if (sequence === null) throw new Error(`sequence "${grant.sequenceId}" no longer exists`);
37475
38193
  const [outcome] = await this.actionRunner.run({
37476
38194
  ruleId: rule.id,
37477
38195
  ruleName: rule.name,
37478
38196
  deviceId: grant.deviceId,
37479
- sequences: [{
37480
- ...sequence,
37481
- enabled: true
37482
- }]
38197
+ sequences: [asForcedSequence(sequence)]
37483
38198
  });
37484
38199
  if (outcome === void 0) throw new Error("the sequence did not run");
37485
38200
  if (outcome.failedAt !== void 0) throw new Error(`step ${String(outcome.failedAt)} of "${sequence.name}" failed`);
@@ -37598,6 +38313,11 @@ var NotificationCenter = class {
37598
38313
  logger: this.logger.child("device-mute"),
37599
38314
  ...deps.now !== void 0 ? { now: deps.now } : {}
37600
38315
  });
38316
+ this.sequences = new NcSequenceStore({
38317
+ store: deps.store,
38318
+ logger: this.logger.child("sequences"),
38319
+ ...deps.now !== void 0 ? { now: deps.now } : {}
38320
+ });
37601
38321
  this.alertStore = new NcAlertStore({
37602
38322
  store: deps.store,
37603
38323
  logger: this.logger.child("device-alerts")
@@ -37864,6 +38584,7 @@ var NotificationCenter = class {
37864
38584
  await NcStateSummaryService.declare(store);
37865
38585
  await NcSnoozeStore.declare(store);
37866
38586
  await NcDeviceMuteStore.declare(store);
38587
+ await NcSequenceStore.declare(store);
37867
38588
  await TimelapseStore.declare(store);
37868
38589
  await NcSummaryStore.declare(store);
37869
38590
  await NcCalendarEventsStore.declare(store);
@@ -37925,6 +38646,8 @@ var NotificationCenter = class {
37925
38646
  sinceStartMs: now - t0
37926
38647
  } });
37927
38648
  };
38649
+ await timed("sequences", () => this.sequences.load());
38650
+ await timed("migrateLegacyActions", () => this.migrateLegacyRuleActions());
37928
38651
  await Promise.all([
37929
38652
  timed("texts", () => this.reloadTexts()),
37930
38653
  timed("rules", () => this.rules.load()),
@@ -39224,7 +39947,32 @@ var NotificationCenter = class {
39224
39947
  return { success: true };
39225
39948
  },
39226
39949
  setRuleEnabled: async ({ ruleId, enabled }) => {
39227
- await this.rules.setEnabled(ruleId, enabled);
39950
+ const updated = await this.rules.setEnabled(ruleId, enabled);
39951
+ const bindings = enabled ? updated.actions?.onActivation : updated.actions?.onDeactivation;
39952
+ if (bindings !== void 0 && bindings.length > 0 && this.actionRunner !== null) this.actionRunner.runBindings({
39953
+ ruleId: updated.id,
39954
+ ruleName: updated.name,
39955
+ deviceId: this.deviceIdForRuleActions(updated),
39956
+ bindings,
39957
+ lookup: (id) => this.sequences.get(id)
39958
+ });
39959
+ return { success: true };
39960
+ },
39961
+ listSequences: async () => ({ sequences: [...this.sequences.list()] }),
39962
+ getSequence: async ({ sequenceId }) => ({ sequence: this.sequences.get(sequenceId) }),
39963
+ createSequence: async ({ sequence }) => {
39964
+ const created = await this.sequences.create(sequence);
39965
+ this.logger.info("notification sequence created", { meta: { sequenceId: created.id } });
39966
+ return { sequence: created };
39967
+ },
39968
+ updateSequence: async ({ sequenceId, sequence }) => {
39969
+ const updated = await this.sequences.update(sequenceId, sequence);
39970
+ this.logger.info("notification sequence updated", { meta: { sequenceId } });
39971
+ return { sequence: updated };
39972
+ },
39973
+ deleteSequence: async ({ sequenceId }) => {
39974
+ await this.sequences.delete(sequenceId);
39975
+ this.logger.info("notification sequence deleted", { meta: { sequenceId } });
39228
39976
  return { success: true };
39229
39977
  },
39230
39978
  testRule: async ({ rule, lookbackMinutes }) => ({ results: [...await this.dryRun(rule, lookbackMinutes)] }),
@@ -39281,7 +40029,18 @@ var NotificationCenter = class {
39281
40029
  },
39282
40030
  getAlarmConfig: async () => this.alarmConfig(),
39283
40031
  getAlarmSensors: async (input) => this.alarmSensors(input),
39284
- setAlarmConfig: async ({ patch }) => this.setAlarmConfig(patch)
40032
+ setAlarmConfig: async ({ patch }) => this.setAlarmConfig(patch),
40033
+ createAlarmProfile: async ({ name, cloneFromId }) => this.mutateAlarmProfile((panel) => panel.createProfile({
40034
+ name,
40035
+ newId: randomUUID(),
40036
+ source: cloneFromId === void 0 ? { kind: "defaults" } : {
40037
+ kind: "clone",
40038
+ fromId: cloneFromId
40039
+ }
40040
+ })),
40041
+ renameAlarmProfile: async ({ id, name }) => this.mutateAlarmProfile((panel) => panel.renameProfile(id, name)),
40042
+ deleteAlarmProfile: async ({ id }) => this.mutateAlarmProfile((panel) => panel.deleteProfile(id)),
40043
+ activateAlarmProfile: async ({ id }) => this.mutateAlarmProfile((panel) => panel.activateProfile(id))
39285
40044
  };
39286
40045
  }
39287
40046
  /**
@@ -39574,6 +40333,13 @@ var NotificationCenter = class {
39574
40333
  });
39575
40334
  return this.alarmConfig();
39576
40335
  }
40336
+ /** Profile CRUD — each mutation returns the whole config (settings applied). */
40337
+ async mutateAlarmProfile(run) {
40338
+ const panel = this.alarmPanel;
40339
+ if (panel === null) throw new Error("this node has no alarm panel");
40340
+ await run(panel);
40341
+ return this.alarmConfig();
40342
+ }
39577
40343
  /**
39578
40344
  * The panel as it is NOW — see `alarm-live.ts`.
39579
40345
  *
@@ -39670,6 +40436,7 @@ var NotificationCenter = class {
39670
40436
  return {
39671
40437
  deviceId: panel.deviceId,
39672
40438
  settings: panel.settings(),
40439
+ profiles: panel.profilesView(),
39673
40440
  live: await this.alarmLive(panel),
39674
40441
  coverage: this.coverageFor(panel).map((c) => ({
39675
40442
  ...c,
@@ -40702,7 +41469,7 @@ var NotificationCenter = class {
40702
41469
  } : {}
40703
41470
  }
40704
41471
  });
40705
- const unresolvable = unresolvableButtons(rule.actions);
41472
+ const unresolvable = unresolvableButtons(rule.actions, this.sequences.knownIds());
40706
41473
  if (unresolvable.length > 0) this.logger.warn("rule buttons reference sequences that do not exist — not sent", {
40707
41474
  tags: { deviceId: subject.deviceId },
40708
41475
  meta: {
@@ -40829,20 +41596,85 @@ var NotificationCenter = class {
40829
41596
  return this.groupStore.list(query);
40830
41597
  }
40831
41598
  /**
40832
- * Run this rule's `onTrigger` sequences for this match.
41599
+ * Materialise legacy inline `onTrigger` sequences into the shared store and
41600
+ * rewrite each rule's actions to bindings + `buttons.sequenceId`. Runs on RAW
41601
+ * rows before {@link NcRuleStore.load} so Zod never skips them.
41602
+ */
41603
+ async migrateLegacyRuleActions() {
41604
+ let rows;
41605
+ try {
41606
+ rows = await this.deps.store.query.query({
41607
+ collection: NC_RULES_COLLECTION,
41608
+ filter: { limit: 1e4 }
41609
+ });
41610
+ } catch (err) {
41611
+ this.logger.warn("legacy actions migration skipped — could not read rules", { meta: { error: String(err) } });
41612
+ return;
41613
+ }
41614
+ let migrated = 0;
41615
+ for (const row of rows) {
41616
+ const ruleBlob = row.data["rule"];
41617
+ if (typeof ruleBlob !== "object" || ruleBlob === null || Array.isArray(ruleBlob)) continue;
41618
+ const rule = { ...ruleBlob };
41619
+ const ruleId = typeof rule["id"] === "string" ? rule["id"] : void 0;
41620
+ if (ruleId === void 0) continue;
41621
+ const result = await migrateLegacyRuleActions(rule["actions"], async (input) => {
41622
+ return { id: (await this.sequences.create(input)).id };
41623
+ });
41624
+ if (!result.migrated) continue;
41625
+ if (result.actions !== void 0) rule["actions"] = result.actions;
41626
+ else delete rule["actions"];
41627
+ const { id: _rowId, ...columns } = row.data;
41628
+ await this.deps.store.set.mutate({
41629
+ collection: NC_RULES_COLLECTION,
41630
+ key: ruleId,
41631
+ value: {
41632
+ ...columns,
41633
+ rule
41634
+ }
41635
+ });
41636
+ migrated += 1;
41637
+ }
41638
+ if (migrated > 0) this.logger.info("migrated legacy inline rule actions into shared sequences", { meta: {
41639
+ rules: migrated,
41640
+ sequences: this.sequences.list().length
41641
+ } });
41642
+ }
41643
+ /**
41644
+ * True when this match is a clear / leave edge — the subject for
41645
+ * `onTriggerOff`. Raise / enter / ordinary matches use `onTrigger`.
41646
+ */
41647
+ isTriggerOffSubject(subject) {
41648
+ if (subject.sensorRaised === false) return true;
41649
+ if (subject.occupancy !== void 0 && subject.occupancy.occupied === false) return true;
41650
+ return false;
41651
+ }
41652
+ /** Device id for activation/deactivation log tags — first scoped device, else 0. */
41653
+ deviceIdForRuleActions(rule) {
41654
+ const devices = rule.conditions.devices;
41655
+ if (devices !== void 0 && devices.length > 0) {
41656
+ const first = devices[0];
41657
+ if (first !== void 0) return first;
41658
+ }
41659
+ return 0;
41660
+ }
41661
+ sequenceActionsOf = (sequenceId) => this.sequences.get(sequenceId)?.actions;
41662
+ /**
41663
+ * Run this rule's onTrigger / onTriggerOff bindings for this match.
40833
41664
  *
40834
41665
  * Called BEFORE the cooldown gate — see the call site for why. Everything
40835
41666
  * here is fire-and-forget and nothing may throw into the evaluation.
40836
41667
  */
40837
41668
  runRuleActions(rule, subject, kind, controls) {
40838
- const sequences = rule.actions?.onTrigger;
40839
- if (sequences === void 0 || sequences.length === 0) return;
40840
41669
  if (this.actionRunner === null || controls.runActions === false) return;
40841
- this.actionRunner.run({
41670
+ const bindings = this.isTriggerOffSubject(subject) ? rule.actions?.onTriggerOff : rule.actions?.onTrigger;
41671
+ if (bindings === void 0 || bindings.length === 0) return;
41672
+ this.actionRunner.runBindings({
40842
41673
  ruleId: rule.id,
40843
41674
  ruleName: rule.name,
40844
41675
  deviceId: subject.deviceId,
40845
- sequences,
41676
+ bindings,
41677
+ lookup: (id) => this.sequences.get(id),
40846
41678
  origin: fireRefFor(rule, subject, kind)
40847
41679
  });
40848
41680
  }
@@ -41746,7 +42578,8 @@ var NotificationCenter = class {
41746
42578
  snoozeDecision(input) {
41747
42579
  const verdict = snoozeVerdict(input, {
41748
42580
  activeFor: (query) => this.snoozes.activeFor(query),
41749
- ruleFor: (ruleId) => this.rules.get(ruleId)
42581
+ ruleFor: (ruleId) => this.rules.get(ruleId),
42582
+ sequenceActionsOf: this.sequenceActionsOf
41750
42583
  });
41751
42584
  if (verdict.kind === "exempt") this.logger.info("snooze does not apply — alarm rules are never silenced", {
41752
42585
  tags: { deviceId: input.deviceId },