@camstack/addon-post-analysis 1.2.331 → 1.2.332

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,4 +1,4 @@
1
- import { $ as NC_ALARM_DELAY_MAX_SEC, $n as failureContributionCapability, $r as canonicalHash, $t as SCENE_CONFIRM_DEFAULT_MAX_IMAGE_PX, A as EMBEDDING_REQUIREMENTS_FAILED, Ai as unknown, An as assertTimelapseCadences, At as NcRuleInputSchema, B as ListTrackRelationsInputSchema, Bn as deriveRecordingMode, Br as storageOccupancyCapability, Bt as NcStateSummarySectionIdSchema, C as DEFAULT_EVENT_COLOR, Ci as partialRecord, Cn as addonWidgetsSourceCapability, Cr as readDeviceStateFrom, Ct as NcAlarmSensorsSchema, D as DeclaredDevices, Di as templateLiteral, Dn as applyModeDelaysEdits, Dr as resolveClipSearchMinScore, Dt as NcLegacyRuleActionSequenceSchema, E as DETECTION_PIPELINE_CAP_NAME, Ei as string, En as applyBlockingEdit, Er as renameAlarmProfile, Et as NcConditionsSchema, F as EXPORT_DENSE_MAX_RANGES, Fn as consumableLowPercentOf, Fr as ruleKindOf, Ft as NcScheduleSchema, G as MAX_BIRTH_LATENCY_PROXY_MS, Gn as embeddingRefusalOf, Gr as systemEventFilterApplies, Gt as NcTemplateVarDescriptorSchema, H as LockStateSchema, Hn as describeSystemEventFamilyProblem, Hr as syncActiveAlarmProfileSettings, Ht as NcTaxonomySchema, I as FIRST_LEVEL_MACRO_CLASSES, In as cosineSimilarity$1, Ir as sceneMonitorCapability, It as NcSharedSequenceSchema, J as MOTION_CLOSE_AFTER_MS, Jn as encodeVectorBase64, Jr as templateCarriesSummary, K as MAX_TRACK_PURITIES, Kr as systemEventSummaryScheduleOf, Kt as OPS_LOG_DEFAULT_LIMIT, L as FULL_IMAGE_BBOX, Ln as createAlarmProfile, Lr as secureActuationFor, Lt as NcSnoozeInputSchema, M as EVENT_DENSITY_BATCH_MAX, Mn as audioModeOf, Mr as ruleBelongsToAlarm$1, Mt as NcRuleSchema, N as EVENT_KIND_BY_CAP, Nn as buildEventKindDescriptor, Nr as ruleDeviceCompatCapQueries, Nt as NcRuleTargetSchema, O as DetectionSourceSchema, Oi as tuple, On as applyNonBlockingEdit, Or as resolveLocationMode, Ot as NcRuleActionSchema, P as EVENT_OWNER_TYPES, Pn as cloneAlarmSettings, Pr as ruleGroupingApplies, Pt as NcRuleUpdateInputSchema, Q as NC_ALARM_DELAY_FIELDS, Qn as faceGalleryCapability, Qr as DEFAULT_FIRST_SIGHTING_FRESHNESS_MS, Qt as RetrainStatusSchema, R as FailureCounters, Rn as deleteAlarmProfile, Rr as sensorRaisedOf, Rt as NcSnoozeSchema, S as CoverStateSchema, Si as object, Sn as activateAlarmProfile, Sr as plateGalleryCapability, St as NcAlarmSensorsInputSchema, T as DETECTION_MACRO_CLASSES, Ti as strictObject, Tn as alarmPanelCapability, Tr as readTimelapseGeneratedAt, Tt as NcConditionDescriptorSchema, U as MAX_ARCHIVED_DEBUG_NOTES, Ur as systemEventDeliveryProblems, Ut as NcTemplatePreviewInputSchema, V as ListTrackRelationsResultSchema, Vn as describeSystemEventDeliveryProblem, Vr as subKindsOf, Vt as NcSystemEventKindSchema, W as MAX_BIRTH_DECISION_RECORDS, Wr as systemEventFamilyProblems, Wt as NcTemplatePreviewSchema, X as MotionTriggeredBySchema, Xn as eventKindLabel, Xr as vectorDimFromBase64, Xt as RECORDING_SOURCE_CAMSTACK_ADDON, Y as MediaFileKindEnum, Yn as ensureAlarmProfiles, Yr as templateVarsFor, Yt as RECORDING_EXPORT_MAX_READ_BYTES, Z as NC_ALARM_DEFAULT_PROFILE_ID, Zn as evictionPolicyOfLocation, Zr as zoneAnalyticsCapability, Zt as RebuildObjectEmbeddingsRefusalSchema, _ as BaseDevice, _i as boolean, _n as TrackPuritySchema, _r as memoizePinnedFfmpeg, _t as NC_TANK_SYSTEM_EVENT_KINDS, a as CLIP_EMBEDDING_STEP_ID, ai as CamProfileSchema, an as SCENE_DEFAULT_UNCOVERED_POLICY, ar as isDetectionMacroClass, at as NC_CLIP_PRE_ROLL_DEFAULT_SEC, b as ClearTrackRelationInputSchema, bi as looseObject, bn as TrackSideSchema, bt as NcAlarmConfigSchema, c as resolveClusterModelPin, ci as defineCustomActions, cn as SceneMonitorSchema, cr as isKnownTemplateVar, ct as NC_CONSUMABLE_LOW_PERCENT_DEFAULT, d as APPEARANCE_RESOLVES_AMBIGUITY_DEFAULT, di as nodePin, dn as SetTrackRelationInputSchema, dr as isScheduleActive, dt as NC_GROUPING_DEFAULT_SEC, ei as evaluateSensorEdge, en as SCENE_CONFIRM_DEFAULT_TIMEOUT_MS, er as gifDeriveSpeed, et as NC_ALARM_SYSTEM_EVENT_KINDS, f as ARCHIVED_DEBUG_NOTES_DEFAULT_LIMIT, fn as TIMELAPSE_DENSE_FLOOR_SEC, fr as isSystemDelivery, ft as NC_GROUPING_MAX_SEC, g as BIRTH_DECISION_DEFAULT_LIMIT, gi as array, gn as TimelapseRuleSchema, gr as mayWriteToLocation, gt as NC_SYSTEM_EVENT_KIND_GROUP_SPECS, h as ArchivedDebugNoteSchema, hi as _enum, hn as TimelapseRulePatchSchema, hr as kebabToCamel, ht as NC_SNOOZE_MAX_MINUTES, ii as BaseAddon, in as SCENE_DEFAULT_QUIET_SECONDS, ir as isDefaultAlarmProfileId, it as NC_CLIP_POST_ROLL_DEFAULT_SEC, j as EMBEDDING_REQUIREMENTS_UNAVAILABLE, ji as EventCategory, jn as audioMetricsCapability, jr as ruleAutoTriggersAlarm, jt as NcRulePatchSchema, k as EMBEDDING_MODEL_FAILED, ki as union, kn as applySecureOnArmEdit, kr as resolveModeDelays, kt as NcRuleActionsSchema, l as APPEARANCE_BIN_COUNT, li as hydrateSchema, ln as SetTrackPurityInputSchema, lr as isMediaOwnerType, lt as NC_CONSUMABLE_SYSTEM_EVENT_KINDS, m as AlarmArmModeSchema, mi as ZodObject, mn as TimelapseRuleInputSchema, mr as isVectorIndexDimConflict, mt as NC_SENSOR_TRANSITION_KINDS, ni as DeviceType, nn as SCENE_DEFAULT_CHECK_INTERVAL_SEC, nr as isAlarmControllableCap, nt as NC_AUDIO_CONFIRM_HITS_DEFAULT, o as FACE_EMBEDDING_STEP_ID, oi as createEvent, on as SCENE_DIVERGED, or as isEventKindLabelKey, ot as NC_CLIP_RETENTION_MAX_SEC, p as AUDIO_MACRO_LABELS, pi as sleep$1, pn as TRACK_RELATIONS_DEFAULT_LIMIT, pr as isSystemEventSummaryRule, pt as NC_GROUPING_MIN_SEC, q as MAX_TRACK_RELATIONS, qr as systemEventSummarySplitOf, qt as OpsLogEntrySchema, ri as errMsg, rn as SCENE_DEFAULT_OBSERVATION_SPACING_SEC, rr as isAudioClassName, rt as NC_AUDIO_CONFIRM_WINDOW_SEC_DEFAULT, s as readClusterModelPinStrict, si as customAction, sn as SCENE_RESET_RECAPTURES, sr as isEventOwnerType, st as NC_CONDITION_CATALOG, t as BUILTIN_CLIP_MODELS, ti as isSourceCap, tn as SCENE_DEFAULT_ANCHOR_THRESHOLD, tr as gifFromMp4Args, tt as NC_ALARM_TRIGGERED_MAX_SEC, u as APPEARANCE_BLOCKS, ui as isDeviceScopedCap, un as SetTrackPurityResultSchema, ur as isRetiredClearKey, ut as NC_DEFAULT_SNOOZE_MINUTES, v as BirthDecisionRecordSchema, vi as discriminatedUnion, vn as TrackRelationKindSchema, vr as notificationRulesCapability, vt as NC_TAXONOMY, w as DEFAULT_TIMELAPSE_PREVIEW_TEXT, wi as record, wn as alarmDeviceSecureOnArm, wr as readMotionTriggeredBy, wt as NcAlarmSettingsPatchSchema, x as ClearTrackRelationResultSchema, xi as number, xn as TrackSourceSchema, xr as pipelineAnalyticsCapability, xt as NcAlarmPostTriggeredActionSchema, y as COCO_TO_MACRO, yi as literal, yn as TrackRelationSchema, yr as occupancyScope, yt as NC_TEMPLATE_VARS, z as LabelAttributionSchema, zn as derivePostTriggeredAction, zr as servedTemplateVars, zt as NcSnoozeSuppressedSchema } from "../clip-model-registry-CGaaUvAD.mjs";
1
+ import { $ as NC_ALARM_DELAY_MAX_SEC, $n as failureContributionCapability, $r as canonicalHash, $t as SCENE_CONFIRM_DEFAULT_MAX_IMAGE_PX, A as EMBEDDING_REQUIREMENTS_FAILED, Ai as unknown, An as assertTimelapseCadences, At as NcRuleInputSchema, B as ListTrackRelationsInputSchema, Bn as deriveRecordingMode, Br as storageOccupancyCapability, Bt as NcStateSummarySectionIdSchema, C as DEFAULT_EVENT_COLOR, Ci as partialRecord, Cn as addonWidgetsSourceCapability, Cr as readDeviceStateFrom, Ct as NcAlarmSensorsSchema, D as DeclaredDevices, Di as templateLiteral, Dn as applyModeDelaysEdits, Dr as resolveClipSearchMinScore, Dt as NcLegacyRuleActionSequenceSchema, E as DETECTION_PIPELINE_CAP_NAME, Ei as string, En as applyBlockingEdit, Er as renameAlarmProfile, Et as NcConditionsSchema, F as EXPORT_DENSE_MAX_RANGES, Fn as consumableLowPercentOf, Fr as ruleKindOf, Ft as NcScheduleSchema, G as MAX_BIRTH_LATENCY_PROXY_MS, Gn as embeddingRefusalOf, Gr as systemEventFilterApplies, Gt as NcTemplateVarDescriptorSchema, H as LockStateSchema, Hn as describeSystemEventFamilyProblem, Hr as syncActiveAlarmProfileSettings, Ht as NcTaxonomySchema, I as FIRST_LEVEL_MACRO_CLASSES, In as cosineSimilarity$1, Ir as sceneMonitorCapability, It as NcSharedSequenceSchema, J as MOTION_CLOSE_AFTER_MS, Jn as encodeVectorBase64, Jr as templateCarriesSummary, K as MAX_TRACK_PURITIES, Kr as systemEventSummaryScheduleOf, Kt as OPS_LOG_DEFAULT_LIMIT, L as FULL_IMAGE_BBOX, Ln as createAlarmProfile, Lr as secureActuationFor, Lt as NcSnoozeInputSchema, M as EVENT_DENSITY_BATCH_MAX, Mn as audioModeOf, Mr as ruleBelongsToAlarm$1, Mt as NcRuleSchema, N as EVENT_KIND_BY_CAP, Nn as buildEventKindDescriptor, Nr as ruleDeviceCompatCapQueries, Nt as NcRuleTargetSchema, O as DetectionSourceSchema, Oi as tuple, On as applyNonBlockingEdit, Or as resolveLocationMode, Ot as NcRuleActionSchema, P as EVENT_OWNER_TYPES, Pn as cloneAlarmSettings, Pr as ruleGroupingApplies, Pt as NcRuleUpdateInputSchema, Q as NC_ALARM_DELAY_FIELDS, Qn as faceGalleryCapability, Qr as DEFAULT_FIRST_SIGHTING_FRESHNESS_MS, Qt as RetrainStatusSchema, R as FailureCounters, Rn as deleteAlarmProfile, Rr as sensorRaisedOf, Rt as NcSnoozeSchema, S as CoverStateSchema, Si as object, Sn as activateAlarmProfile, Sr as plateGalleryCapability, St as NcAlarmSensorsInputSchema, T as DETECTION_MACRO_CLASSES, Ti as strictObject, Tn as alarmPanelCapability, Tr as readTimelapseGeneratedAt, Tt as NcConditionDescriptorSchema, U as MAX_ARCHIVED_DEBUG_NOTES, Ur as systemEventDeliveryProblems, Ut as NcTemplatePreviewInputSchema, V as ListTrackRelationsResultSchema, Vn as describeSystemEventDeliveryProblem, Vr as subKindsOf, Vt as NcSystemEventKindSchema, W as MAX_BIRTH_DECISION_RECORDS, Wr as systemEventFamilyProblems, Wt as NcTemplatePreviewSchema, X as MotionTriggeredBySchema, Xn as eventKindLabel, Xr as vectorDimFromBase64, Xt as RECORDING_SOURCE_CAMSTACK_ADDON, Y as MediaFileKindEnum, Yn as ensureAlarmProfiles, Yr as templateVarsFor, Yt as RECORDING_EXPORT_MAX_READ_BYTES, Z as NC_ALARM_DEFAULT_PROFILE_ID, Zn as evictionPolicyOfLocation, Zr as zoneAnalyticsCapability, Zt as RebuildObjectEmbeddingsRefusalSchema, _ as BaseDevice, _i as boolean, _n as TrackPuritySchema, _r as memoizePinnedFfmpeg, _t as NC_TANK_SYSTEM_EVENT_KINDS, a as CLIP_EMBEDDING_STEP_ID, ai as CamProfileSchema, an as SCENE_DEFAULT_UNCOVERED_POLICY, ar as isDetectionMacroClass, at as NC_CLIP_PRE_ROLL_DEFAULT_SEC, b as ClearTrackRelationInputSchema, bi as looseObject, bn as TrackSideSchema, bt as NcAlarmConfigSchema, c as resolveClusterModelPin, ci as defineCustomActions, cn as SceneMonitorSchema, cr as isKnownTemplateVar, ct as NC_CONSUMABLE_LOW_PERCENT_DEFAULT, d as APPEARANCE_RESOLVES_AMBIGUITY_DEFAULT, di as nodePin, dn as SetTrackRelationInputSchema, dr as isScheduleActive, dt as NC_GROUPING_DEFAULT_SEC, ei as evaluateSensorEdge, en as SCENE_CONFIRM_DEFAULT_TIMEOUT_MS, er as gifDeriveSpeed, et as NC_ALARM_SYSTEM_EVENT_KINDS, f as ARCHIVED_DEBUG_NOTES_DEFAULT_LIMIT, fn as TIMELAPSE_DENSE_FLOOR_SEC, fr as isSystemDelivery, ft as NC_GROUPING_MAX_SEC, g as BIRTH_DECISION_DEFAULT_LIMIT, gi as array, gn as TimelapseRuleSchema, gr as mayWriteToLocation, gt as NC_SYSTEM_EVENT_KIND_GROUP_SPECS, h as ArchivedDebugNoteSchema, hi as _enum, hn as TimelapseRulePatchSchema, hr as kebabToCamel, ht as NC_SNOOZE_MAX_MINUTES, ii as BaseAddon, in as SCENE_DEFAULT_QUIET_SECONDS, ir as isDefaultAlarmProfileId, it as NC_CLIP_POST_ROLL_DEFAULT_SEC, j as EMBEDDING_REQUIREMENTS_UNAVAILABLE, ji as EventCategory, jn as audioMetricsCapability, jr as ruleAutoTriggersAlarm, jt as NcRulePatchSchema, k as EMBEDDING_MODEL_FAILED, ki as union, kn as applySecureOnArmEdit, kr as resolveModeDelays, kt as NcRuleActionsSchema, l as APPEARANCE_BIN_COUNT, li as hydrateSchema, ln as SetTrackPurityInputSchema, lr as isMediaOwnerType, lt as NC_CONSUMABLE_SYSTEM_EVENT_KINDS, m as AlarmArmModeSchema, mi as ZodObject, mn as TimelapseRuleInputSchema, mr as isVectorIndexDimConflict, mt as NC_SENSOR_TRANSITION_KINDS, ni as DeviceType, nn as SCENE_DEFAULT_CHECK_INTERVAL_SEC, nr as isAlarmControllableCap, nt as NC_AUDIO_CONFIRM_HITS_DEFAULT, o as FACE_EMBEDDING_STEP_ID, oi as createEvent, on as SCENE_DIVERGED, or as isEventKindLabelKey, ot as NC_CLIP_RETENTION_MAX_SEC, p as AUDIO_MACRO_LABELS, pi as sleep$1, pn as TRACK_RELATIONS_DEFAULT_LIMIT, pr as isSystemEventSummaryRule, pt as NC_GROUPING_MIN_SEC, q as MAX_TRACK_RELATIONS, qr as systemEventSummarySplitOf, qt as OpsLogEntrySchema, ri as errMsg, rn as SCENE_DEFAULT_OBSERVATION_SPACING_SEC, rr as isAudioClassName, rt as NC_AUDIO_CONFIRM_WINDOW_SEC_DEFAULT, s as readClusterModelPinStrict, si as customAction, sn as SCENE_RESET_RECAPTURES, sr as isEventOwnerType, st as NC_CONDITION_CATALOG, t as BUILTIN_CLIP_MODELS, ti as isSourceCap, tn as SCENE_DEFAULT_ANCHOR_THRESHOLD, tr as gifFromMp4Args, tt as NC_ALARM_TRIGGERED_MAX_SEC, u as APPEARANCE_BLOCKS, ui as isDeviceScopedCap, un as SetTrackPurityResultSchema, ur as isRetiredClearKey, ut as NC_DEFAULT_SNOOZE_MINUTES, v as BirthDecisionRecordSchema, vi as discriminatedUnion, vn as TrackRelationKindSchema, vr as notificationRulesCapability, vt as NC_TAXONOMY, w as DEFAULT_TIMELAPSE_PREVIEW_TEXT, wi as record, wn as alarmDeviceSecureOnArm, wr as readMotionTriggeredBy, wt as NcAlarmSettingsPatchSchema, x as ClearTrackRelationResultSchema, xi as number, xn as TrackSourceSchema, xr as pipelineAnalyticsCapability, xt as NcAlarmPostTriggeredActionSchema, y as COCO_TO_MACRO, yi as literal, yn as TrackRelationSchema, yr as occupancyScope, yt as NC_TEMPLATE_VARS, z as LabelAttributionSchema, zn as derivePostTriggeredAction, zr as servedTemplateVars, zt as NcSnoozeSuppressedSchema } from "../clip-model-registry-cujWRWxP.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";
@@ -363,6 +363,20 @@ function skippedFor(deviceIds, skipProbe) {
363
363
  return out;
364
364
  }
365
365
  /**
366
+ * `active` / `skipped` for the arm / arming TITLE — `(n attivi, m saltati)`.
367
+ *
368
+ * Named coverage only: an `allDevices` mode has no finite Active set, so the
369
+ * caller picks the `.all` title key instead of stuffing a fake number here.
370
+ */
371
+ function armCountParams(coverage, bypassed = []) {
372
+ const skipped = new Set(coverage.skippedDevices.map((s) => s.deviceId));
373
+ const excluded = new Set(bypassed);
374
+ return {
375
+ active: `${coverage.deviceIds.filter((id) => !skipped.has(id) && !excluded.has(id)).length}`,
376
+ skipped: `${skipped.size}`
377
+ };
378
+ }
379
+ /**
366
380
  * Whether this mode arms anything, and if not, which way it fails.
367
381
  *
368
382
  * An `allDevices` mode is never a gap: `skippedDevices` only ever probes the
@@ -377,28 +391,13 @@ function alarmCoverageGap(coverage) {
377
391
  if (coverage.skippedDevices.length < coverage.deviceIds.length) return null;
378
392
  return "all-skipped";
379
393
  }
394
+ /** Device names as a lock-screen bullet list (one name per line). */
395
+ function bulletDeviceNames(names) {
396
+ return names.map((n) => `• ${n}`).join("\n");
397
+ }
380
398
  /**
381
- * "Attivi: Cancello, Garage — Esclusi: Porta retro."
382
- *
383
- * A LIST, not a sentence. The mode is in the title and the body is one
384
- * `Label: names` line per device group, because this is read on a lock screen
385
- * and the question is always "which one" — the prose version ("3 dispositivi
386
- * attivi: …. Esclusi: ….") made the operator parse a paragraph to find a
387
- * window. Each variant still owns its own catalog key, so the Texts tab can
388
- * re-word "nothing is armed" without touching "these are armed".
389
- *
390
- * Names, not ids: this is read by a person standing at a door. An id that has
391
- * no name falls back to a NAMED, translated placeholder rather than being
392
- * dropped — a silently shorter list would understate what is armed, which is
393
- * the one error this message must not make. (It used to hard-code the English
394
- * `Device <id>` into both languages.)
395
- *
396
- * **Always a sentence.** It used to return null for a mode no rule is gated on,
397
- * on the grounds that "Night armed" followed by an unwatched night is a false
398
- * assurance — true, but the caller then emitted `alarm-armed` anyway with a
399
- * fallback body key that existed in NEITHER locale, so the operator received a
400
- * title and an empty line. Silence is not the third option here: SAYING that
401
- * the mode arms nothing is.
399
+ * Skipped / excluded devices FIRST, then active — each name on its own
400
+ * `•` line. The mode is in the title; the body answers "which ones".
402
401
  */
403
402
  function buildArmAnnouncement(coverage, deviceName, bypassed = []) {
404
403
  const rules = `${coverage.ruleCount}`;
@@ -407,7 +406,11 @@ function buildArmAnnouncement(coverage, deviceName, bypassed = []) {
407
406
  const gap = alarmCoverageGap(coverage);
408
407
  if (gap === "uncovered") return {
409
408
  bodyKey: "alarm.arm.body.uncovered",
410
- textParams: { mode },
409
+ textParams: {
410
+ mode,
411
+ active: "0",
412
+ skipped: "0"
413
+ },
411
414
  textCount: 0
412
415
  };
413
416
  if (gap === "all-skipped") {
@@ -418,7 +421,9 @@ function buildArmAnnouncement(coverage, deviceName, bypassed = []) {
418
421
  rules,
419
422
  mode,
420
423
  devices: `${names.length}`,
421
- names: names.join(", ")
424
+ names: bulletDeviceNames(names),
425
+ active: "0",
426
+ skipped: `${names.length}`
422
427
  },
423
428
  textCount: names.length
424
429
  };
@@ -431,9 +436,10 @@ function buildArmAnnouncement(coverage, deviceName, bypassed = []) {
431
436
  rules,
432
437
  mode,
433
438
  count: `${names.length}`,
434
- names: names.join(", ")
439
+ names: bulletDeviceNames(names)
435
440
  },
436
- textCount: names.length
441
+ textCount: names.length,
442
+ titleKeySuffix: "all"
437
443
  };
438
444
  }
439
445
  if (coverage.allDevices) return {
@@ -442,25 +448,30 @@ function buildArmAnnouncement(coverage, deviceName, bypassed = []) {
442
448
  rules,
443
449
  mode
444
450
  },
445
- textCount: coverage.ruleCount
451
+ textCount: coverage.ruleCount,
452
+ titleKeySuffix: "all"
446
453
  };
447
454
  if (coverage.deviceIds.length === 0) return {
448
455
  bodyKey: "alarm.arm.body.noDevices",
449
456
  textParams: {
450
457
  rules,
451
- mode
458
+ mode,
459
+ active: "0",
460
+ skipped: "0"
452
461
  },
453
462
  textCount: coverage.ruleCount
454
463
  };
455
464
  const skipped = new Set(coverage.skippedDevices.map((s) => s.deviceId));
456
465
  const names = coverage.deviceIds.filter((id) => !skipped.has(id)).map(deviceName);
466
+ const counts = armCountParams(coverage);
457
467
  if (skipped.size === 0) return {
458
468
  bodyKey: "alarm.arm.body.devices",
459
469
  textParams: {
460
470
  rules,
461
471
  mode,
462
472
  devices: `${names.length}`,
463
- names: names.join(", ")
473
+ names: bulletDeviceNames(names),
474
+ ...counts
464
475
  },
465
476
  textCount: names.length
466
477
  };
@@ -470,8 +481,9 @@ function buildArmAnnouncement(coverage, deviceName, bypassed = []) {
470
481
  rules,
471
482
  mode,
472
483
  devices: `${names.length}`,
473
- names: names.join(", "),
474
- skippedNames: coverage.skippedDevices.map((s) => deviceName(s.deviceId)).join(", ")
484
+ names: bulletDeviceNames(names),
485
+ skippedNames: bulletDeviceNames(coverage.skippedDevices.map((s) => deviceName(s.deviceId))),
486
+ ...counts
475
487
  },
476
488
  textCount: names.length
477
489
  };
@@ -486,7 +498,7 @@ function buildArmAnnouncement(coverage, deviceName, bypassed = []) {
486
498
  function armedWithBypass(coverage, bypassed, deviceName) {
487
499
  const skipped = new Set(coverage.skippedDevices.map((s) => s.deviceId));
488
500
  const excluded = new Set(bypassed);
489
- const bypassedNames = bypassed.map(deviceName).join(", ");
501
+ const bypassedNames = bulletDeviceNames(bypassed.map(deviceName));
490
502
  if (coverage.allDevices) return {
491
503
  bodyKey: "alarm.arm.body.bypassedAll",
492
504
  textParams: {
@@ -494,10 +506,12 @@ function armedWithBypass(coverage, bypassed, deviceName) {
494
506
  mode: coverage.mode,
495
507
  bypassedNames
496
508
  },
497
- textCount: coverage.ruleCount
509
+ textCount: coverage.ruleCount,
510
+ titleKeySuffix: "all"
498
511
  };
499
512
  const armedIds = coverage.deviceIds.filter((id) => !skipped.has(id) && !excluded.has(id));
500
- const names = armedIds.map(deviceName).join(", ");
513
+ const names = bulletDeviceNames(armedIds.map(deviceName));
514
+ const counts = armCountParams(coverage, bypassed);
501
515
  return {
502
516
  bodyKey: "alarm.arm.body.bypassed",
503
517
  textParams: {
@@ -505,7 +519,8 @@ function armedWithBypass(coverage, bypassed, deviceName) {
505
519
  mode: coverage.mode,
506
520
  devices: `${armedIds.length}`,
507
521
  names,
508
- bypassedNames
522
+ bypassedNames,
523
+ ...counts
509
524
  },
510
525
  textCount: armedIds.length
511
526
  };
@@ -526,69 +541,159 @@ function buildArmRefusedAnnouncement(mode, names, withArmAnyway) {
526
541
  textParams: {
527
542
  mode,
528
543
  devices: `${names.length}`,
529
- names: names.join(", ")
544
+ names: bulletDeviceNames(names)
530
545
  },
531
546
  textCount: names.length
532
547
  };
533
548
  }
534
549
  /**
535
- * The EXCEPTIONS to the same coverage, said 30 seconds earlier.
550
+ * The same coverage lists as {@link buildArmAnnouncement}, said at second 0.
536
551
  *
537
- * The exit-delay sentence is deliberately SHORTER than the arm one — it is read
538
- * on the way out of a door — and it carries exactly one thing the arm sentence
539
- * cannot: the warning arrives while the operator can still disarm and fix it.
540
- * A mode that arms nothing is worth interrupting somebody for at second 0 and
541
- * merely worth recording at second 30.
542
- *
543
- * **It never repeats the device list.** The countdown lives in the TITLE
544
- * (`system.alarm-arming.title.<mode>` renders `{{seconds}}`), and the armed
545
- * list arrives thirty seconds later in the arm announcement — printing it
546
- * twice is the verbosity that made the operator stop reading both. So the body
547
- * is what is WRONG: what was bypassed, what is excluded, or that nothing is.
552
+ * The countdown lives in the TITLE (`system.alarm-arming.title.<mode>` renders
553
+ * `{{seconds}}`); the body is the labelled device lists — Active and Skipped
554
+ * (and Bypassed when the operator confirmed an open sensor). Saying the lists
555
+ * only at second 30 left the exit-delay push as "No exclusions" / a lone
556
+ * Esclusi line, which is exactly when the operator can still disarm and fix
557
+ * a mute or an offline camera.
548
558
  *
549
559
  * `seconds` stays in the params because the title asks for it, and `textCount`
550
560
  * stays with it: a row enqueued before this deploy still pluralises on the
551
561
  * countdown it was written for.
552
562
  */
553
563
  function buildArmingAnnouncement(coverage, seconds, deviceName, bypassed = []) {
564
+ const mode = coverage.mode;
565
+ const rules = `${coverage.ruleCount}`;
554
566
  const textParams = {
555
- mode: coverage.mode,
567
+ mode,
556
568
  seconds: `${seconds}`
557
569
  };
558
- if (bypassed.length > 0) return {
559
- bodyKey: "alarm.arming.body.bypassed",
570
+ if (bypassed.length > 0) return armingWithBypass(coverage, seconds, bypassed, deviceName);
571
+ const gap = alarmCoverageGap(coverage);
572
+ if (gap === "uncovered") return {
573
+ bodyKey: "alarm.arming.body.uncovered",
560
574
  textParams: {
561
575
  ...textParams,
562
- bypassedNames: bypassed.map(deviceName).join(", ")
576
+ active: "0",
577
+ skipped: "0"
563
578
  },
564
579
  textCount: seconds
565
580
  };
566
- const skippedNames = coverage.skippedDevices.map((s) => deviceName(s.deviceId)).join(", ");
567
- const gap = alarmCoverageGap(coverage);
568
- if (gap === "uncovered") return {
569
- bodyKey: "alarm.arming.body.uncovered",
570
- textParams,
571
- textCount: seconds
581
+ if (gap === "all-skipped") {
582
+ const names = coverage.skippedDevices.map((s) => deviceName(s.deviceId));
583
+ return {
584
+ bodyKey: "alarm.arming.body.allSkipped",
585
+ textParams: {
586
+ ...textParams,
587
+ rules,
588
+ devices: `${names.length}`,
589
+ names: bulletDeviceNames(names),
590
+ active: "0",
591
+ skipped: `${names.length}`
592
+ },
593
+ textCount: seconds
594
+ };
595
+ }
596
+ if (coverage.allDevices && coverage.allDevicesExcept.length > 0) {
597
+ const names = coverage.allDevicesExcept.map(deviceName);
598
+ return {
599
+ bodyKey: "alarm.arming.body.allDevicesExcept",
600
+ textParams: {
601
+ ...textParams,
602
+ rules,
603
+ count: `${names.length}`,
604
+ names: bulletDeviceNames(names)
605
+ },
606
+ textCount: seconds,
607
+ titleKeySuffix: "all"
608
+ };
609
+ }
610
+ if (coverage.allDevices) return {
611
+ bodyKey: "alarm.arming.body.allDevices",
612
+ textParams: {
613
+ ...textParams,
614
+ rules
615
+ },
616
+ textCount: seconds,
617
+ titleKeySuffix: "all"
572
618
  };
573
- if (gap === "all-skipped") return {
574
- bodyKey: "alarm.arming.body.allSkipped",
619
+ if (coverage.deviceIds.length === 0) return {
620
+ bodyKey: "alarm.arming.body.noDevices",
575
621
  textParams: {
576
622
  ...textParams,
577
- names: skippedNames
623
+ rules,
624
+ active: "0",
625
+ skipped: "0"
578
626
  },
579
627
  textCount: seconds
580
628
  };
581
- if (coverage.skippedDevices.length > 0) return {
582
- bodyKey: "alarm.arming.body.skipped",
629
+ const skipped = new Set(coverage.skippedDevices.map((s) => s.deviceId));
630
+ const names = coverage.deviceIds.filter((id) => !skipped.has(id)).map(deviceName);
631
+ const counts = armCountParams(coverage);
632
+ if (skipped.size === 0) return {
633
+ bodyKey: "alarm.arming.body.devices",
583
634
  textParams: {
584
635
  ...textParams,
585
- names: skippedNames
636
+ rules,
637
+ devices: `${names.length}`,
638
+ names: bulletDeviceNames(names),
639
+ ...counts
586
640
  },
587
- textCount: coverage.skippedDevices.length
641
+ textCount: seconds
588
642
  };
589
643
  return {
590
- bodyKey: "alarm.arming.body",
644
+ bodyKey: "alarm.arming.body.devicesSkipped",
645
+ textParams: {
646
+ ...textParams,
647
+ rules,
648
+ devices: `${names.length}`,
649
+ names: bulletDeviceNames(names),
650
+ skippedNames: bulletDeviceNames(coverage.skippedDevices.map((s) => deviceName(s.deviceId))),
651
+ ...counts
652
+ },
653
+ textCount: seconds
654
+ };
655
+ }
656
+ /**
657
+ * "Attivi: Porta retro, Cancello — Bypassati: Finestra veranda" while the exit
658
+ * delay still runs — same halves as {@link armedWithBypass}, with `seconds`
659
+ * frozen for the title.
660
+ */
661
+ function armingWithBypass(coverage, seconds, bypassed, deviceName) {
662
+ const skipped = new Set(coverage.skippedDevices.map((s) => s.deviceId));
663
+ const excluded = new Set(bypassed);
664
+ const bypassedNames = bulletDeviceNames(bypassed.map(deviceName));
665
+ const textParams = {
666
+ mode: coverage.mode,
667
+ seconds: `${seconds}`,
668
+ rules: `${coverage.ruleCount}`,
669
+ bypassedNames
670
+ };
671
+ if (coverage.allDevices) return {
672
+ bodyKey: "alarm.arming.body.bypassedAll",
591
673
  textParams,
674
+ textCount: seconds,
675
+ titleKeySuffix: "all"
676
+ };
677
+ const armedIds = coverage.deviceIds.filter((id) => !skipped.has(id) && !excluded.has(id));
678
+ const counts = armCountParams(coverage, bypassed);
679
+ if (armedIds.length === 0) return {
680
+ bodyKey: "alarm.arming.body.bypassedOnly",
681
+ textParams: {
682
+ mode: coverage.mode,
683
+ seconds: `${seconds}`,
684
+ bypassedNames,
685
+ ...counts
686
+ },
687
+ textCount: seconds
688
+ };
689
+ return {
690
+ bodyKey: "alarm.arming.body.bypassed",
691
+ textParams: {
692
+ ...textParams,
693
+ devices: `${armedIds.length}`,
694
+ names: bulletDeviceNames(armedIds.map(deviceName)),
695
+ ...counts
696
+ },
592
697
  textCount: seconds
593
698
  };
594
699
  }
@@ -3211,12 +3316,23 @@ var NcAlarmPanelDevice = class extends BaseDevice {
3211
3316
  * dropped it anyway, so the exclusion is not what ignored it.
3212
3317
  */
3213
3318
  announceIgnoredTrigger(deviceId, now) {
3319
+ const state = stateAt(this.machine, now);
3214
3320
  if (!isArmed(this.machine, now)) {
3215
3321
  this.ctx.logger.debug("alarm bypass not announced — the panel is not armed yet", {
3216
3322
  tags: { deviceId },
3217
3323
  meta: {
3218
3324
  panelDeviceId: this.id,
3219
- state: stateAt(this.machine, now)
3325
+ state
3326
+ }
3327
+ });
3328
+ return;
3329
+ }
3330
+ if (state === "triggered") {
3331
+ this.ctx.logger.debug("alarm bypass not announced — the siren is already sounding", {
3332
+ tags: { deviceId },
3333
+ meta: {
3334
+ panelDeviceId: this.id,
3335
+ state
3220
3336
  }
3221
3337
  });
3222
3338
  return;
@@ -5921,7 +6037,7 @@ function isAdoptable(record, nowMs) {
5921
6037
  * what `localDayMinute` does for the same input — one behaviour for a bad
5922
6038
  * zone, not two.
5923
6039
  */
5924
- function clockOf$7(atMs, timeZone) {
6040
+ function clockOf$6(atMs, timeZone) {
5925
6041
  if (timeZone !== void 0) try {
5926
6042
  return new Intl.DateTimeFormat("en-GB", {
5927
6043
  timeZone,
@@ -6057,7 +6173,7 @@ var TimelapseRenderer = class {
6057
6173
  includeAudio: false,
6058
6174
  maxLifeMs: await this.ports.exportLifeMs(),
6059
6175
  deleteAfterDownload: false,
6060
- title: `${request.ruleName} ${clockOf$7(window.startMs, request.timezone)}→${clockOf$7(window.endMs, request.timezone)}`,
6176
+ title: `${request.ruleName} ${clockOf$6(window.startMs, request.timezone)}→${clockOf$6(window.endMs, request.timezone)}`,
6061
6177
  timelapse: {
6062
6178
  everyMs,
6063
6179
  outputFps: request.framerate,
@@ -6388,7 +6504,7 @@ var TimelapseRenderer = class {
6388
6504
  ...meta,
6389
6505
  unservedWindowCount: verdict.unservedWindows.length,
6390
6506
  unservedWindows: verdict.unservedWindows.slice(0, 12).map((w) => ({
6391
- at: clockOf$7(request.window.startMs + w.fromSec * 1e3, request.timezone),
6507
+ at: clockOf$6(request.window.startMs + w.fromSec * 1e3, request.timezone),
6392
6508
  fromSec: Math.round(w.fromSec),
6393
6509
  toSec: Math.round(w.toSec),
6394
6510
  expectedDenseFrames: w.expectedDenseFrames,
@@ -13056,7 +13172,8 @@ function alarmTextKeys(transition, body) {
13056
13172
  const { kind, source } = transition;
13057
13173
  const combined = combinedAlarmText(kind, source);
13058
13174
  if (combined !== null) return combined;
13059
- const titleKey = transition.mode !== void 0 ? `system.${kind}.title.${transition.mode}` : `system.${kind}.title`;
13175
+ const baseTitle = transition.mode !== void 0 ? `system.${kind}.title.${transition.mode}` : `system.${kind}.title`;
13176
+ const titleKey = body?.titleKeySuffix !== void 0 ? `${baseTitle}.${body.titleKeySuffix}` : baseTitle;
13060
13177
  if (body !== void 0) return {
13061
13178
  titleKey,
13062
13179
  bodyKey: body.bodyKey
@@ -14763,34 +14880,47 @@ var en_default = {
14763
14880
  "snooze.scope.anyRule": "A rule",
14764
14881
  "snooze.scope.rule": "“{{rule}}”",
14765
14882
  "alarm.arm.body.allDevices": "Active: all devices",
14766
- "alarm.arm.body.allDevicesExcept": "Active: all devices except {{names}}",
14883
+ "alarm.arm.body.allDevicesExcept": "Active: all devices except\n{{names}}",
14767
14884
  "alarm.arm.body.noDevices": "Active: no device restriction",
14768
- "alarm.arm.body.devices": "Active: {{names}}",
14769
- "alarm.arm.body.devicesSkipped": "Active: {{names}}\nExcluded: {{skippedNames}}",
14885
+ "alarm.arm.body.devices": "Active:\n{{names}}",
14886
+ "alarm.arm.body.devicesSkipped": "Excluded:\n{{skippedNames}}\nActive:\n{{names}}",
14770
14887
  "alarm.arm.body.uncovered": "Active: none — no rule uses this mode",
14771
- "alarm.arm.body.allSkipped": "Active: none\nExcluded: {{names}}",
14888
+ "alarm.arm.body.allSkipped": "Excluded:\n{{names}}\nActive: none",
14772
14889
  "alarm.arming.body": "No exclusions",
14890
+ "alarm.arming.body.allDevices": "Active: all devices",
14891
+ "alarm.arming.body.allDevicesExcept": "Active: all devices except\n{{names}}",
14892
+ "alarm.arming.body.noDevices": "Active: no device restriction",
14893
+ "alarm.arming.body.devices": "Active:\n{{names}}",
14894
+ "alarm.arming.body.devicesSkipped": "Excluded:\n{{skippedNames}}\nActive:\n{{names}}",
14773
14895
  "alarm.arming.body.uncovered": "Active: none — no rule uses this mode",
14774
- "alarm.arming.body.allSkipped": "Active: none\nExcluded: {{names}}",
14775
- "alarm.arming.body.skipped": "Excluded: {{names}}",
14776
- "alarm.arm.body.bypassed": "Active: {{names}}\nBypassed: {{bypassedNames}}",
14777
- "alarm.arm.body.bypassedAll": "Active: all devices\nBypassed: {{bypassedNames}}",
14778
- "alarm.arming.body.bypassed": "Bypassed: {{bypassedNames}}",
14779
- "alarm.armRefused.body": "Open: {{names}}",
14780
- "alarm.armRefused.body.hint": "Open: {{names}}\nTap “Arm anyway” to arm with them excluded.",
14896
+ "alarm.arming.body.allSkipped": "Excluded:\n{{names}}\nActive: none",
14897
+ "alarm.arming.body.skipped": "Excluded:\n{{names}}",
14898
+ "alarm.arm.body.bypassed": "Bypassed:\n{{bypassedNames}}\nActive:\n{{names}}",
14899
+ "alarm.arm.body.bypassedAll": "Bypassed:\n{{bypassedNames}}\nActive: all devices",
14900
+ "alarm.arming.body.bypassed": "Bypassed:\n{{bypassedNames}}\nActive:\n{{names}}",
14901
+ "alarm.arming.body.bypassedAll": "Bypassed:\n{{bypassedNames}}\nActive: all devices",
14902
+ "alarm.arming.body.bypassedOnly": "Bypassed:\n{{bypassedNames}}",
14903
+ "alarm.armRefused.body": "Open:\n{{names}}",
14904
+ "alarm.armRefused.body.hint": "Open:\n{{names}}\nTap “Arm anyway” to arm with them excluded.",
14781
14905
  "alarm.device.unnamed": "Device {{id}}",
14782
14906
  "alarm.combined.title": "Alarm: {{deviceName}}",
14783
14907
  "system.alarm-arm-refused.title.home": "Arming home refused",
14784
14908
  "system.alarm-arm-refused.title.away": "Arming away refused",
14785
14909
  "system.alarm-arm-refused.title.night": "Arming night refused",
14786
14910
  "system.alarm-arm-refused.body": "Arming refused",
14787
- "system.alarm-arming.title.home": "Arming home — {{seconds}}s",
14788
- "system.alarm-arming.title.away": "Arming away — {{seconds}}s",
14789
- "system.alarm-arming.title.night": "Arming night — {{seconds}}s",
14911
+ "system.alarm-arming.title.home": "Arming home — {{seconds}}s ({{active}} active, {{skipped}} skipped)",
14912
+ "system.alarm-arming.title.away": "Arming away — {{seconds}}s ({{active}} active, {{skipped}} skipped)",
14913
+ "system.alarm-arming.title.night": "Arming night — {{seconds}}s ({{active}} active, {{skipped}} skipped)",
14914
+ "system.alarm-arming.title.home.all": "Arming home — {{seconds}}s",
14915
+ "system.alarm-arming.title.away.all": "Arming away — {{seconds}}s",
14916
+ "system.alarm-arming.title.night.all": "Arming night — {{seconds}}s",
14790
14917
  "system.alarm-arming.body": "Arming",
14791
- "system.alarm-armed.title.home": "Alarm armed: home",
14792
- "system.alarm-armed.title.away": "Alarm armed: away",
14793
- "system.alarm-armed.title.night": "Alarm armed: night",
14918
+ "system.alarm-armed.title.home": "Alarm active: home ({{active}} active, {{skipped}} skipped)",
14919
+ "system.alarm-armed.title.away": "Alarm active: away ({{active}} active, {{skipped}} skipped)",
14920
+ "system.alarm-armed.title.night": "Alarm active: night ({{active}} active, {{skipped}} skipped)",
14921
+ "system.alarm-armed.title.home.all": "Alarm active: home (all)",
14922
+ "system.alarm-armed.title.away.all": "Alarm active: away (all)",
14923
+ "system.alarm-armed.title.night.all": "Alarm active: night (all)",
14794
14924
  "system.alarm-armed.body": "Alarm armed",
14795
14925
  "system.alarm-disarmed.title": "Disarmed",
14796
14926
  "system.alarm-disarmed.body": "Alarm disarmed",
@@ -14888,7 +15018,16 @@ var en_default = {
14888
15018
  "summary.line.update": "{{packageName}} {{currentVersion}} → {{latestVersion}}",
14889
15019
  "summary.line.update.onNode": "{{packageName}} {{currentVersion}} → {{latestVersion}} on {{nodeId}}",
14890
15020
  "summary.line.wrapper-update": "Container {{currentVersion}} → {{latestVersion}}",
14891
- "summary.line.tank-unread": "{{deviceName}} — the {{tank}} tank needs attention"
15021
+ "summary.line.tank-unread": "{{deviceName}} — the {{tank}} tank needs attention",
15022
+ "calendar-events.title": "{{rule}}: {{count}} upcoming",
15023
+ "calendar-events.empty": "No events in the next {{horizonDays}} day(s).",
15024
+ "calendar-events.day.today": "Today — {{date}}",
15025
+ "calendar-events.day.tomorrow": "★ Tomorrow — {{date}}",
15026
+ "calendar-events.day.other": "{{date}}",
15027
+ "calendar-events.line.timed": "• {{when}} {{title}}{{where}}",
15028
+ "calendar-events.line.allDay": "• All day {{title}}{{where}}",
15029
+ "calendar-events.where": " @ {{location}}",
15030
+ "calendar-events.more": "…and {{count}} more"
14892
15031
  };
14893
15032
  //#endregion
14894
15033
  //#region src/notification-center/texts/it.json
@@ -15092,34 +15231,47 @@ var it_default = {
15092
15231
  "snooze.scope.anyRule": "Una regola",
15093
15232
  "snooze.scope.rule": "“{{rule}}”",
15094
15233
  "alarm.arm.body.allDevices": "Attivi: tutti i dispositivi",
15095
- "alarm.arm.body.allDevicesExcept": "Attivi: tutti i dispositivi tranne {{names}}",
15234
+ "alarm.arm.body.allDevicesExcept": "Attivi: tutti i dispositivi tranne\n{{names}}",
15096
15235
  "alarm.arm.body.noDevices": "Attivi: nessuna restrizione",
15097
- "alarm.arm.body.devices": "Attivi: {{names}}",
15098
- "alarm.arm.body.devicesSkipped": "Attivi: {{names}}\nEsclusi: {{skippedNames}}",
15236
+ "alarm.arm.body.devices": "Attivi:\n{{names}}",
15237
+ "alarm.arm.body.devicesSkipped": "Esclusi:\n{{skippedNames}}\nAttivi:\n{{names}}",
15099
15238
  "alarm.arm.body.uncovered": "Attivi: nessuno — nessuna regola usa questa modalità",
15100
- "alarm.arm.body.allSkipped": "Attivi: nessuno\nEsclusi: {{names}}",
15239
+ "alarm.arm.body.allSkipped": "Esclusi:\n{{names}}\nAttivi: nessuno",
15101
15240
  "alarm.arming.body": "Nessuna esclusione",
15241
+ "alarm.arming.body.allDevices": "Attivi: tutti i dispositivi",
15242
+ "alarm.arming.body.allDevicesExcept": "Attivi: tutti i dispositivi tranne\n{{names}}",
15243
+ "alarm.arming.body.noDevices": "Attivi: nessuna restrizione",
15244
+ "alarm.arming.body.devices": "Attivi:\n{{names}}",
15245
+ "alarm.arming.body.devicesSkipped": "Esclusi:\n{{skippedNames}}\nAttivi:\n{{names}}",
15102
15246
  "alarm.arming.body.uncovered": "Attivi: nessuno — nessuna regola usa questa modalità",
15103
- "alarm.arming.body.allSkipped": "Attivi: nessuno\nEsclusi: {{names}}",
15104
- "alarm.arming.body.skipped": "Esclusi: {{names}}",
15105
- "alarm.arm.body.bypassed": "Attivi: {{names}}\nBypassati: {{bypassedNames}}",
15106
- "alarm.arm.body.bypassedAll": "Attivi: tutti i dispositivi\nBypassati: {{bypassedNames}}",
15107
- "alarm.arming.body.bypassed": "Bypassati: {{bypassedNames}}",
15108
- "alarm.armRefused.body": "Aperti: {{names}}",
15109
- "alarm.armRefused.body.hint": "Aperti: {{names}}\nTocca “Arma comunque” per armare escludendoli.",
15247
+ "alarm.arming.body.allSkipped": "Esclusi:\n{{names}}\nAttivi: nessuno",
15248
+ "alarm.arming.body.skipped": "Esclusi:\n{{names}}",
15249
+ "alarm.arm.body.bypassed": "Bypassati:\n{{bypassedNames}}\nAttivi:\n{{names}}",
15250
+ "alarm.arm.body.bypassedAll": "Bypassati:\n{{bypassedNames}}\nAttivi: tutti i dispositivi",
15251
+ "alarm.arming.body.bypassed": "Bypassati:\n{{bypassedNames}}\nAttivi:\n{{names}}",
15252
+ "alarm.arming.body.bypassedAll": "Bypassati:\n{{bypassedNames}}\nAttivi: tutti i dispositivi",
15253
+ "alarm.arming.body.bypassedOnly": "Bypassati:\n{{bypassedNames}}",
15254
+ "alarm.armRefused.body": "Aperti:\n{{names}}",
15255
+ "alarm.armRefused.body.hint": "Aperti:\n{{names}}\nTocca “Arma comunque” per armare escludendoli.",
15110
15256
  "alarm.device.unnamed": "Dispositivo {{id}}",
15111
15257
  "alarm.combined.title": "Allarme: {{deviceName}}",
15112
15258
  "system.alarm-arm-refused.title.home": "Inserimento Casa rifiutato",
15113
15259
  "system.alarm-arm-refused.title.away": "Inserimento Fuori rifiutato",
15114
15260
  "system.alarm-arm-refused.title.night": "Inserimento Notte rifiutato",
15115
15261
  "system.alarm-arm-refused.body": "Inserimento rifiutato",
15116
- "system.alarm-arming.title.home": "Inserimento Casa — {{seconds}}s",
15117
- "system.alarm-arming.title.away": "Inserimento Fuori — {{seconds}}s",
15118
- "system.alarm-arming.title.night": "Inserimento Notte — {{seconds}}s",
15262
+ "system.alarm-arming.title.home": "Inserimento Casa — {{seconds}}s ({{active}} attivi, {{skipped}} saltati)",
15263
+ "system.alarm-arming.title.away": "Inserimento Fuori — {{seconds}}s ({{active}} attivi, {{skipped}} saltati)",
15264
+ "system.alarm-arming.title.night": "Inserimento Notte — {{seconds}}s ({{active}} attivi, {{skipped}} saltati)",
15265
+ "system.alarm-arming.title.home.all": "Inserimento Casa — {{seconds}}s",
15266
+ "system.alarm-arming.title.away.all": "Inserimento Fuori — {{seconds}}s",
15267
+ "system.alarm-arming.title.night.all": "Inserimento Notte — {{seconds}}s",
15119
15268
  "system.alarm-arming.body": "Inserimento in corso",
15120
- "system.alarm-armed.title.home": "Allarme inserito: Casa",
15121
- "system.alarm-armed.title.away": "Allarme inserito: Fuori",
15122
- "system.alarm-armed.title.night": "Allarme inserito: Notte",
15269
+ "system.alarm-armed.title.home": "Allarme attivo: Casa ({{active}} attivi, {{skipped}} saltati)",
15270
+ "system.alarm-armed.title.away": "Allarme attivo: Fuori ({{active}} attivi, {{skipped}} saltati)",
15271
+ "system.alarm-armed.title.night": "Allarme attivo: Notte ({{active}} attivi, {{skipped}} saltati)",
15272
+ "system.alarm-armed.title.home.all": "Allarme attivo: Casa (tutti)",
15273
+ "system.alarm-armed.title.away.all": "Allarme attivo: Fuori (tutti)",
15274
+ "system.alarm-armed.title.night.all": "Allarme attivo: Notte (tutti)",
15123
15275
  "system.alarm-armed.body": "Allarme inserito",
15124
15276
  "system.alarm-disarmed.title": "Disinserito",
15125
15277
  "system.alarm-disarmed.body": "Allarme disinserito",
@@ -15217,7 +15369,16 @@ var it_default = {
15217
15369
  "summary.line.update": "{{packageName}} {{currentVersion}} → {{latestVersion}}",
15218
15370
  "summary.line.update.onNode": "{{packageName}} {{currentVersion}} → {{latestVersion}} su {{nodeId}}",
15219
15371
  "summary.line.wrapper-update": "Container {{currentVersion}} → {{latestVersion}}",
15220
- "summary.line.tank-unread": "{{deviceName}} — il serbatoio {{tank}} richiede attenzione"
15372
+ "summary.line.tank-unread": "{{deviceName}} — il serbatoio {{tank}} richiede attenzione",
15373
+ "calendar-events.title": "{{rule}}: {{count}} in arrivo",
15374
+ "calendar-events.empty": "Nessun evento nei prossimi {{horizonDays}} giorno/i.",
15375
+ "calendar-events.day.today": "Oggi — {{date}}",
15376
+ "calendar-events.day.tomorrow": "★ Domani — {{date}}",
15377
+ "calendar-events.day.other": "{{date}}",
15378
+ "calendar-events.line.timed": "• {{when}} {{title}}{{where}}",
15379
+ "calendar-events.line.allDay": "• Tutto il giorno {{title}}{{where}}",
15380
+ "calendar-events.where": " @ {{location}}",
15381
+ "calendar-events.more": "…e altri {{count}}"
15221
15382
  };
15222
15383
  //#endregion
15223
15384
  //#region src/notification-center/text-catalog.ts
@@ -15471,12 +15632,12 @@ var NC_RULE_AI_WORDS_FALLBACK = 24;
15471
15632
  *
15472
15633
  * | kind (declared `bodyMaxLen`) | words |
15473
15634
  * | --- | --- |
15474
- * | zentik (500) | 12 |
15635
+ * | (legacy 500-char push) | 12 |
15475
15636
  * | pushover (1024) | 24 |
15476
15637
  * | discord (2000) | 36 |
15477
15638
  * | home-assistant (3500) | 46 |
15478
15639
  * | ntfy · gotify · telegram · whatsapp (4096) | 48 |
15479
- * | webhook (1 000 000) | 120 |
15640
+ * | webhook · zentik (1 000 000) | 120 |
15480
15641
  *
15481
15642
  * Which is the operator's sentence, arrived at from a declared number: a push
15482
15643
  * notification is truncated by the screen and wants few words; a webhook or a
@@ -29821,7 +29982,7 @@ function buildRuleAiSystemPrompt(shape, language, maxWords, grouped = false) {
29821
29982
  ].join("");
29822
29983
  }
29823
29984
  /** `HH:MM` in the host timezone. */
29824
- function clockOf$6(atMs) {
29985
+ function clockOf$5(atMs) {
29825
29986
  const d = new Date(atMs);
29826
29987
  return `${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}`;
29827
29988
  }
@@ -29859,7 +30020,7 @@ function buildRuleAiPrompt(input) {
29859
30020
  if (shape === "detection-track") {
29860
30021
  lines.push(`Camera: ${camera}.${located} Subject: ${subject}. The stills, in order:`);
29861
30022
  for (const [i, frame] of input.frames.entries()) {
29862
- const when = frame.atMs !== void 0 ? ` · ${clockOf$6(frame.atMs)}` : "";
30023
+ const when = frame.atMs !== void 0 ? ` · ${clockOf$5(frame.atMs)}` : "";
29863
30024
  lines.push(`${String(i + 1)}.${when}`);
29864
30025
  }
29865
30026
  return lines.join("\n");
@@ -31630,7 +31791,7 @@ var DEFAULT_PROMPT_EN = "What happened during this period? Summarise it for a ho
31630
31791
  */
31631
31792
  var SummaryAnswerSchema = object({ summary: string() });
31632
31793
  /** `HH:MM` in the host timezone — the two numbers a frame label needs. */
31633
- function clockOf$5(atMs) {
31794
+ function clockOf$4(atMs) {
31634
31795
  const d = new Date(atMs);
31635
31796
  return `${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}`;
31636
31797
  }
@@ -31653,7 +31814,7 @@ function buildSummaryAiPrompt(input, frames) {
31653
31814
  const roster = frames.map((f, i) => {
31654
31815
  const location = plainLabel(f.location ?? "");
31655
31816
  const where = location.length > 0 ? ` (${location})` : "";
31656
- return `${String(i + 1)}. ${plainLabel(f.deviceName)}${where} · ${clockOf$5(f.atMs)} · ${plainVocabulary(f.className) || "subject"}`;
31817
+ return `${String(i + 1)}. ${plainLabel(f.deviceName)}${where} · ${clockOf$4(f.atMs)} · ${plainVocabulary(f.className) || "subject"}`;
31657
31818
  }).join("\n");
31658
31819
  const described = /* @__PURE__ */ new Map();
31659
31820
  for (const f of frames) {
@@ -32256,7 +32417,7 @@ var NO_DETECTIONS$1 = {
32256
32417
  * has to check is a key somebody can get wrong. */
32257
32418
  var BODY_SEPARATOR$1 = " · ";
32258
32419
  /** `HH:MM` in the host timezone. */
32259
- function clockOf$4(atMs) {
32420
+ function clockOf$3(atMs) {
32260
32421
  const d = new Date(atMs);
32261
32422
  return `${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}`;
32262
32423
  }
@@ -32270,8 +32431,8 @@ function clockOf$4(atMs) {
32270
32431
  function summaryTemplateVars(input) {
32271
32432
  return {
32272
32433
  rule: input.ruleName,
32273
- from: clockOf$4(input.window.startMs),
32274
- to: clockOf$4(input.window.endMs),
32434
+ from: clockOf$3(input.window.startMs),
32435
+ to: clockOf$3(input.window.endMs),
32275
32436
  time: ncClockText(input.texts.language, input.generatedAt),
32276
32437
  events: String(input.tileCount),
32277
32438
  cameras: String(input.cameraCount),
@@ -32366,11 +32527,11 @@ function summaryRecordId(ruleId, windowEndMs) {
32366
32527
  /** The caption burned onto the mosaic's title band, with the same `{{var}}`
32367
32528
  * vocabulary the title and body use. `''` is a real answer ("no caption"). */
32368
32529
  function summaryCaptionText(input) {
32369
- if (input.captionText === void 0) return `${input.ruleName} — ${clockOf$4(input.window.startMs)}→${clockOf$4(input.window.endMs)}`;
32530
+ if (input.captionText === void 0) return `${input.ruleName} — ${clockOf$3(input.window.startMs)}→${clockOf$3(input.window.endMs)}`;
32370
32531
  return renderTemplate(input.captionText, {
32371
32532
  rule: input.ruleName,
32372
- from: clockOf$4(input.window.startMs),
32373
- to: clockOf$4(input.window.endMs)
32533
+ from: clockOf$3(input.window.startMs),
32534
+ to: clockOf$3(input.window.endMs)
32374
32535
  }) ?? "";
32375
32536
  }
32376
32537
  /**
@@ -33302,7 +33463,7 @@ var NcSummaryProducer = class {
33302
33463
  purpose: "summary",
33303
33464
  tiles: tiles.map(({ candidate, jpeg }) => ({
33304
33465
  jpeg,
33305
- ...plan.tileLabels ? { label: `${names.get(candidate.deviceId) ?? candidate.deviceId} · ${clockOf$3(candidate.firstSeen)} · ${candidate.className}` } : {},
33466
+ ...plan.tileLabels ? { label: `${names.get(candidate.deviceId) ?? candidate.deviceId} · ${clockOf$2(candidate.firstSeen)} · ${candidate.className}` } : {},
33306
33467
  deviceId: candidate.deviceId,
33307
33468
  sourceId: candidate.trackId
33308
33469
  })),
@@ -33525,7 +33686,7 @@ var NcSummaryProducer = class {
33525
33686
  }
33526
33687
  };
33527
33688
  /** `HH:MM` in the host timezone — the two numbers a tile label needs. */
33528
- function clockOf$3(atMs) {
33689
+ function clockOf$2(atMs) {
33529
33690
  const d = new Date(atMs);
33530
33691
  return `${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}`;
33531
33692
  }
@@ -34026,8 +34187,6 @@ var NcCalendarEventsStore = class {
34026
34187
  };
34027
34188
  //#endregion
34028
34189
  //#region src/notification-center/calendar-events/calendar-events-body.ts
34029
- /** Visual weight on the tomorrow header — Telegram/Zentik plain text. */
34030
- var CALENDAR_EVENTS_TOMORROW_MARK = "★";
34031
34190
  /** Separates the tomorrow block from the rest of the horizon. */
34032
34191
  var CALENDAR_EVENTS_DAY_DELIMITER = "────────";
34033
34192
  var MAX_EVENT_LINES = 40;
@@ -34048,26 +34207,50 @@ function addLocalDays(atMs, days) {
34048
34207
  d.setDate(d.getDate() + days);
34049
34208
  return d.getTime();
34050
34209
  }
34051
- function dayHeadingFor(dayStartMs, todayKey, tomorrowKey) {
34052
- const key = localDayKey(dayStartMs);
34053
- const label = new Date(dayStartMs).toLocaleDateString(void 0, {
34210
+ function dayDateLabel(texts, dayStartMs) {
34211
+ return new Intl.DateTimeFormat(texts.language, {
34054
34212
  weekday: "short",
34055
34213
  month: "short",
34056
34214
  day: "numeric"
34057
- });
34058
- if (key === todayKey) return `Today — ${label}`;
34059
- if (key === tomorrowKey) return `${CALENDAR_EVENTS_TOMORROW_MARK} Tomorrow — ${label}`;
34060
- return label;
34215
+ }).format(new Date(dayStartMs));
34061
34216
  }
34062
- /** `HH:MM` local, or `YYYY-MM-DD HH:MM` when the end falls on another day. */
34063
- function clockOf$2(atMs) {
34064
- const d = new Date(atMs);
34065
- return `${pad2(d.getHours())}:${pad2(d.getMinutes())}`;
34217
+ function dayHeadingFor(texts, dayStartMs, todayKey, tomorrowKey) {
34218
+ const key = localDayKey(dayStartMs);
34219
+ const date = dayDateLabel(texts, dayStartMs);
34220
+ if (key === todayKey) return texts.text({
34221
+ key: "calendar-events.day.today",
34222
+ vars: { date }
34223
+ });
34224
+ if (key === tomorrowKey) return texts.text({
34225
+ key: "calendar-events.day.tomorrow",
34226
+ vars: { date }
34227
+ });
34228
+ return texts.text({
34229
+ key: "calendar-events.day.other",
34230
+ vars: { date }
34231
+ });
34066
34232
  }
34067
- function formatEventLine(event) {
34068
- const when = event.allDay === true ? "All day" : `${clockOf$2(event.startMs)}–${clockOf$2(event.endMs)}`;
34069
- const where = event.location !== void 0 && event.location.length > 0 ? ` @ ${event.location}` : "";
34070
- return `• ${when} ${event.title}${where}`;
34233
+ function formatEventLine(texts, event) {
34234
+ const where = event.location !== void 0 && event.location.length > 0 ? texts.text({
34235
+ key: "calendar-events.where",
34236
+ vars: { location: event.location }
34237
+ }) : "";
34238
+ if (event.allDay === true) return texts.text({
34239
+ key: "calendar-events.line.allDay",
34240
+ vars: {
34241
+ title: event.title,
34242
+ where
34243
+ }
34244
+ });
34245
+ const when = `${ncClockText(texts.language, event.startMs)}–${ncClockText(texts.language, event.endMs)}`;
34246
+ return texts.text({
34247
+ key: "calendar-events.line.timed",
34248
+ vars: {
34249
+ when,
34250
+ title: event.title,
34251
+ where
34252
+ }
34253
+ });
34071
34254
  }
34072
34255
  function groupByLocalDay(events) {
34073
34256
  const map = /* @__PURE__ */ new Map();
@@ -34091,8 +34274,11 @@ function groupByLocalDay(events) {
34091
34274
  *
34092
34275
  * @param nowMs — fire time; tomorrow is the local calendar day after this.
34093
34276
  */
34094
- function formatCalendarEventsBody(events, nowMs, horizonDays) {
34095
- if (events.length === 0) return `No events in the next ${String(horizonDays)} day(s).`;
34277
+ function formatCalendarEventsBody(texts, events, nowMs, horizonDays) {
34278
+ if (events.length === 0) return texts.text({
34279
+ key: "calendar-events.empty",
34280
+ vars: { horizonDays: String(horizonDays) }
34281
+ });
34096
34282
  const todayKey = localDayKey(nowMs);
34097
34283
  const tomorrowKey = localDayKey(addLocalDays(startOfLocalDay(nowMs), 1));
34098
34284
  const buckets = groupByLocalDay(events);
@@ -34104,17 +34290,20 @@ function formatCalendarEventsBody(events, nowMs, horizonDays) {
34104
34290
  let emitted = 0;
34105
34291
  const pushBucket = (bucket) => {
34106
34292
  if (emitted >= MAX_EVENT_LINES) return;
34107
- lines.push(dayHeadingFor(bucket.dayStartMs, todayKey, tomorrowKey));
34293
+ lines.push(dayHeadingFor(texts, bucket.dayStartMs, todayKey, tomorrowKey));
34108
34294
  for (const ev of bucket.events) {
34109
34295
  if (emitted >= MAX_EVENT_LINES) break;
34110
- lines.push(formatEventLine(ev));
34296
+ lines.push(formatEventLine(texts, ev));
34111
34297
  emitted += 1;
34112
34298
  }
34113
34299
  };
34114
34300
  for (const bucket of lead) pushBucket(bucket);
34115
34301
  if (lead.length > 0 && later.length > 0) lines.push(CALENDAR_EVENTS_DAY_DELIMITER);
34116
34302
  for (const bucket of later) pushBucket(bucket);
34117
- if (events.length > MAX_EVENT_LINES) lines.push(`…and ${String(events.length - MAX_EVENT_LINES)} more`);
34303
+ if (events.length > MAX_EVENT_LINES) lines.push(texts.text({
34304
+ key: "calendar-events.more",
34305
+ vars: { count: String(events.length - MAX_EVENT_LINES) }
34306
+ }));
34118
34307
  return lines.join("\n");
34119
34308
  }
34120
34309
  //#endregion
@@ -34128,7 +34317,7 @@ function clockOf$1(atMs) {
34128
34317
  return `${`${String(d.getFullYear())}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`} ${`${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}`}`;
34129
34318
  }
34130
34319
  function derivedBody$1(input) {
34131
- return formatCalendarEventsBody(input.events, input.eventWindow.startMs, input.horizonDays);
34320
+ return formatCalendarEventsBody(input.texts, input.events, input.eventWindow.startMs, input.horizonDays);
34132
34321
  }
34133
34322
  function templateVars(input) {
34134
34323
  return {
@@ -34154,7 +34343,11 @@ function paramsFor$1(target) {
34154
34343
  function buildCalendarEventsOutboxInputs(input) {
34155
34344
  const vars = templateVars(input);
34156
34345
  const optedOut = new Set(input.disabledTargetIds ?? []);
34157
- const title = renderTemplate(input.template?.title, vars) ?? `${input.ruleName}: ${String(input.events.length)} upcoming`;
34346
+ const title = input.texts.text({
34347
+ key: "calendar-events.title",
34348
+ template: input.template?.title,
34349
+ vars
34350
+ });
34158
34351
  const body = renderTemplate(input.template?.body, vars) ?? derivedBody$1(input);
34159
34352
  const recordId = calendarEventsRecordId(input.ruleId, input.scheduleWindow.endMs);
34160
34353
  return input.targets.filter((t) => !optedOut.has(t.targetId)).map((target) => {
@@ -34303,6 +34496,7 @@ var NcCalendarEventsProducer = class {
34303
34496
  }
34304
34497
  async enqueue(rule, scheduleWindow, eventWindow, calendarIds, events) {
34305
34498
  const inputs = buildCalendarEventsOutboxInputs({
34499
+ texts: this.deps.texts,
34306
34500
  ruleId: rule.id,
34307
34501
  ruleName: rule.name,
34308
34502
  scheduleWindow,
@@ -34317,7 +34511,7 @@ var NcCalendarEventsProducer = class {
34317
34511
  priority: rule.priority,
34318
34512
  ...rule.template !== void 0 ? { template: rule.template } : {}
34319
34513
  });
34320
- for (const input of inputs) await this.deps.outbox.enqueue(input);
34514
+ await this.deps.outbox.enqueue(inputs);
34321
34515
  this.deps.logger.info("calendar-events delivered", { meta: {
34322
34516
  ruleId: rule.id,
34323
34517
  events: events.length,
@@ -35676,16 +35870,25 @@ function stateItem(row, lookup) {
35676
35870
  /**
35677
35871
  * THE RECONCILE (D647 review I2, D8): a ledger row is a memory, never the
35678
35872
  * truth. A device item is listed only while the device EXISTS in the directory
35679
- * and is not an accessory — and an "offline" one only while the device
35680
- * manager does not call it online. A node item only while the node is known
35681
- * to a complete node directory (a forgotten node is gone), and an "offline"
35682
- * one only while the directory does not call it online.
35873
+ * — and an "offline" one only while the device manager does not call it
35874
+ * online. A node item only while the node is known to a complete node
35875
+ * directory (a forgotten node is gone), and an "offline" one only while the
35876
+ * directory does not call it online.
35877
+ *
35878
+ * Accessories are silenced only on the liveness / switched-off section
35879
+ * (`device-state`). Battery, consumable and tank alerts ride the SAME
35880
+ * reconcile, and a vacuum that is a child of its Dreame container
35881
+ * (`parentDeviceId` set) still owns those alerts — filtering every accessory
35882
+ * here dropped every tank line of the 2026-10-05 evening summary for
35883
+ * deviceId 4385 while the episode was open and the instant path had already
35884
+ * held `device-tank-low` for that rule.
35683
35885
  */
35684
35886
  function stillTrue(item, ports) {
35685
35887
  const deviceId = item.subject.deviceId;
35686
35888
  if (deviceId !== void 0) {
35687
35889
  const identity = ports.lookup(deviceId);
35688
- if (identity === void 0 || isAccessoryIdentity(identity)) return false;
35890
+ if (identity === void 0) return false;
35891
+ if (item.section === "device-state" && isAccessoryIdentity(identity)) return false;
35689
35892
  if (item.subject.kind === "device-offline" && identity.online === true) return false;
35690
35893
  }
35691
35894
  const nodeId = item.subject.nodeId;
@@ -38794,6 +38997,7 @@ var NotificationCenter = class {
38794
38997
  store: this.calendarEventsRules,
38795
38998
  outbox: this.outbox,
38796
38999
  calendars: calendarsPort,
39000
+ texts: this.texts,
38797
39001
  logger: this.logger.child("calendar-events"),
38798
39002
  now: this.now
38799
39003
  });
@@ -40964,8 +41168,14 @@ var NotificationCenter = class {
40964
41168
  }
40965
41169
  if (kind !== "alarm-armed" && kind !== "alarm-arming") return null;
40966
41170
  if (mode === void 0) return null;
40967
- const coverage = this.coverageFor(panel).find((c) => c.mode === mode);
40968
- if (coverage === void 0) return null;
41171
+ const coverage = this.coverageFor(panel).find((c) => c.mode === mode) ?? {
41172
+ mode,
41173
+ ruleCount: 0,
41174
+ allDevices: false,
41175
+ allDevicesExcept: [],
41176
+ deviceIds: [],
41177
+ skippedDevices: []
41178
+ };
40969
41179
  const name = this.deviceNameFromDirectory();
40970
41180
  const bypassed = panel.excludedDevices();
40971
41181
  const text = kind === "alarm-arming" ? buildArmingAnnouncement(coverage, transition.seconds ?? 0, name, bypassed) : buildArmAnnouncement(coverage, name, bypassed);
@@ -57616,36 +57826,68 @@ var DEFAULT_STATE_ANALYZER_CONFIG = {
57616
57826
  stillWindowMs: DEFAULT_STILL_WINDOW_MS,
57617
57827
  stillSpanFraction: DEFAULT_STILL_SPAN_FRACTION
57618
57828
  };
57829
+ function shortSideOf(bbox) {
57830
+ return Math.min(bbox.w, bbox.h);
57831
+ }
57619
57832
  var StateAnalyzer = class {
57620
57833
  config;
57621
57834
  states = /* @__PURE__ */ new Map();
57835
+ parked = /* @__PURE__ */ new Map();
57622
57836
  constructor(config = {}) {
57623
57837
  this.config = {
57624
57838
  ...DEFAULT_STATE_ANALYZER_CONFIG,
57625
57839
  ...config
57626
57840
  };
57627
57841
  }
57628
- /** D683: a fused continuation keeps its movement state under the survivor id. */
57842
+ /**
57843
+ * D683: a fused continuation keeps its movement state under the survivor id.
57844
+ *
57845
+ * The absorbed piece is the live continuation, but a still subject that L1
57846
+ * split must not lose the dwell the survivor already paid — `rename` MERGES
57847
+ * the stillness clocks when the geometry across the gap is still still, and
57848
+ * only takes the live piece alone when the subject actually moved.
57849
+ */
57629
57850
  rename(from, to) {
57630
- renameKey(this.states, from, to);
57851
+ const live = this.states.get(from) ?? this.parked.get(from)?.state;
57852
+ if (live === void 0) return;
57853
+ this.states.delete(from);
57854
+ this.parked.delete(from);
57855
+ const survivor = this.states.get(to) ?? this.parked.get(to)?.state;
57856
+ this.parked.delete(to);
57857
+ if (survivor === void 0) {
57858
+ this.states.set(to, live);
57859
+ return;
57860
+ }
57861
+ this.states.set(to, this.mergeContinuation(survivor, live));
57631
57862
  }
57632
57863
  analyze(tracks, timestamp) {
57864
+ this.pruneParked(timestamp);
57633
57865
  const currentIds = new Set(tracks.map((t) => t.trackId));
57634
57866
  const results = [];
57635
57867
  for (const track of tracks) {
57636
57868
  let ts = this.states.get(track.trackId);
57869
+ if (ts === void 0) {
57870
+ const parked = this.parked.get(track.trackId);
57871
+ if (parked !== void 0) {
57872
+ this.parked.delete(track.trackId);
57873
+ ts = parked.state;
57874
+ this.states.set(track.trackId, ts);
57875
+ }
57876
+ }
57637
57877
  const center = bboxCentroid({
57638
57878
  x: track.bbox.x,
57639
57879
  y: track.bbox.y,
57640
57880
  w: track.bbox.w,
57641
57881
  h: track.bbox.h
57642
57882
  });
57883
+ const shortSide = shortSideOf(track.bbox);
57643
57884
  if (!ts) {
57644
57885
  ts = {
57645
57886
  enteredAt: timestamp,
57646
57887
  stationarySince: void 0,
57647
57888
  totalDistancePx: 0,
57648
57889
  lastPosition: center,
57890
+ lastShortSide: shortSide,
57649
57891
  frameCount: 1,
57650
57892
  recent: [{
57651
57893
  x: center.x,
@@ -57660,14 +57902,14 @@ var StateAnalyzer = class {
57660
57902
  const dist = Math.sqrt(dx * dx + dy * dy);
57661
57903
  ts.totalDistancePx += dist;
57662
57904
  ts.lastPosition = center;
57905
+ ts.lastShortSide = shortSide;
57663
57906
  ts.frameCount++;
57664
57907
  ts.recent.push({
57665
57908
  x: center.x,
57666
57909
  y: center.y,
57667
57910
  at: timestamp
57668
57911
  });
57669
- const cutoff = timestamp - this.config.stillWindowMs;
57670
- while (ts.recent.length > 2 && ts.recent[1].at < cutoff) ts.recent.shift();
57912
+ this.trimRecent(ts, timestamp);
57671
57913
  }
57672
57914
  const velocity = track.velocity ? Math.sqrt(track.velocity.dx ** 2 + track.velocity.dy ** 2) : 0;
57673
57915
  const dwellTimeMs = timestamp - ts.enteredAt;
@@ -57703,10 +57945,62 @@ var StateAnalyzer = class {
57703
57945
  dwellTimeMs: timestamp - ts.enteredAt
57704
57946
  });
57705
57947
  this.states.delete(trackId);
57948
+ this.parked.set(trackId, {
57949
+ state: ts,
57950
+ parkedAt: timestamp
57951
+ });
57706
57952
  }
57707
57953
  return results;
57708
57954
  }
57709
57955
  /**
57956
+ * Fold a live continuation into its ended survivor.
57957
+ *
57958
+ * Stillness is what the parked-object ledger votes on (`idle` only). A gap
57959
+ * past L1's coast budget that L2 later heals must not restart that clock
57960
+ * when the centroid never left the still span. A continuation that DID move
57961
+ * drops the survivor's `stationarySince` so a walk after a pause is still
57962
+ * `moving`.
57963
+ */
57964
+ mergeContinuation(survivor, live) {
57965
+ const gapTravel = Math.hypot(live.lastPosition.x - survivor.lastPosition.x, live.lastPosition.y - survivor.lastPosition.y);
57966
+ const subject = Math.max(survivor.lastShortSide, live.lastShortSide);
57967
+ const stillAcrossGap = survivor.stationarySince !== void 0 && subject > 0 && gapTravel <= subject * this.config.stillSpanFraction;
57968
+ const lastAt = live.recent[live.recent.length - 1]?.at;
57969
+ if (stillAcrossGap && survivor.stationarySince !== void 0 && lastAt !== void 0) {
57970
+ const anchorAt = lastAt - this.config.stillWindowMs;
57971
+ const recent = [{
57972
+ x: live.lastPosition.x,
57973
+ y: live.lastPosition.y,
57974
+ at: anchorAt
57975
+ }, ...live.recent.filter((s) => s.at > anchorAt)];
57976
+ return {
57977
+ enteredAt: Math.min(survivor.enteredAt, live.enteredAt),
57978
+ stationarySince: live.stationarySince !== void 0 ? Math.min(survivor.stationarySince, live.stationarySince) : survivor.stationarySince,
57979
+ totalDistancePx: survivor.totalDistancePx + live.totalDistancePx,
57980
+ lastPosition: live.lastPosition,
57981
+ lastShortSide: live.lastShortSide,
57982
+ frameCount: Math.max(survivor.frameCount + live.frameCount, this.config.enteringFrames + 1),
57983
+ recent
57984
+ };
57985
+ }
57986
+ return {
57987
+ enteredAt: live.enteredAt,
57988
+ stationarySince: live.stationarySince,
57989
+ totalDistancePx: survivor.totalDistancePx + live.totalDistancePx,
57990
+ lastPosition: live.lastPosition,
57991
+ lastShortSide: live.lastShortSide,
57992
+ frameCount: live.frameCount,
57993
+ recent: [...live.recent]
57994
+ };
57995
+ }
57996
+ trimRecent(ts, timestamp) {
57997
+ const cutoff = timestamp - this.config.stillWindowMs;
57998
+ while (ts.recent.length > 2 && ts.recent[1].at < cutoff) ts.recent.shift();
57999
+ }
58000
+ pruneParked(timestamp) {
58001
+ for (const [id, parked] of this.parked) if (timestamp - parked.parkedAt > LINK_WINDOW_MS) this.parked.delete(id);
58002
+ }
58003
+ /**
57710
58004
  * The SPEED half of the settled test.
57711
58005
  *
57712
58006
  * Uncalibrated (`stillSpeedFracPerSec` undefined) this is the original line
@@ -57768,6 +58062,7 @@ var StateAnalyzer = class {
57768
58062
  }
57769
58063
  reset() {
57770
58064
  this.states.clear();
58065
+ this.parked.clear();
57771
58066
  }
57772
58067
  };
57773
58068
  //#endregion
@@ -86906,23 +87201,19 @@ function sweepOne(deps, deviceId) {
86906
87201
  }
86907
87202
  //#endregion
86908
87203
  //#region src/pipeline-analytics/pipeline/stationary/stationary-zones.ts
87204
+ /** Shared with every call — ZoneEngine is stateless for annotateDetection. */
87205
+ var ENGINE = new ZoneEngine();
86909
87206
  /**
86910
- * Zone ids whose 0–1 polygon contains the entry's normalised bbox centroid.
86911
- * Empty when the entry has no frame dims (can't normalise) or no zone matches.
87207
+ * Zone ids whose bbox overlap exceeds `minOverlap` (0–1 fraction of the
87208
+ * detection's own area) — identical to {@link ZoneEngine.annotateDetection}.
87209
+ * Empty when the entry has no frame dims or no zone meets the bar.
87210
+ *
87211
+ * `minOverlap` is required: callers pass `TrackingSettings.zoneMembershipMinOverlap`
87212
+ * so parked and live never disagree on the threshold.
86912
87213
  */
86913
- function computeStationaryEntryZones(entry, zones) {
87214
+ function computeStationaryEntryZones(entry, zones, minOverlap) {
86914
87215
  if (entry.frameWidth <= 0 || entry.frameHeight <= 0 || zones.length === 0) return [];
86915
- const centroidPx = bboxCentroid(entry.bbox);
86916
- const point = {
86917
- x: centroidPx.x / entry.frameWidth,
86918
- y: centroidPx.y / entry.frameHeight
86919
- };
86920
- const matched = [];
86921
- for (const zone of zones) {
86922
- if (zone.polygon.length < 3) continue;
86923
- if (pointInPolygon$1(point, zone.polygon)) matched.push(zone.id);
86924
- }
86925
- return matched;
87216
+ return ENGINE.annotateDetection(entry.bbox, zones, entry.frameWidth, entry.frameHeight, void 0, void 0, void 0, minOverlap).map((m) => m.zoneId);
86926
87217
  }
86927
87218
  //#endregion
86928
87219
  //#region src/pipeline-analytics/pipeline/package-drop-detector.ts
@@ -87022,15 +87313,17 @@ var PackageDropDetector = class {
87022
87313
  }
87023
87314
  const ruleZoneIds = new Set(rules.flatMap((r) => r.zoneIds));
87024
87315
  const zones = await this.deps.resolveZones(entry.deviceId);
87025
- const entryZones = computeStationaryEntryZones(entry, zones);
87316
+ const minOverlap = await this.deps.resolveZoneMembershipMinOverlap(entry.deviceId);
87317
+ const entryZones = computeStationaryEntryZones(entry, zones, minOverlap);
87026
87318
  const hitZones = entryZones.filter((z) => ruleZoneIds.has(z));
87027
87319
  if (hitZones.length === 0) {
87028
- this.deps.logger.info("package gate 2/5 zone — centroid outside every package zone", {
87320
+ this.deps.logger.info("package gate 2/5 zone — no package zone meets membership overlap", {
87029
87321
  tags,
87030
87322
  meta: {
87031
87323
  entryId: entry.id,
87032
87324
  bbox: entry.bbox,
87033
87325
  entryZones,
87326
+ minOverlap,
87034
87327
  ruleZoneIds: [...ruleZoneIds],
87035
87328
  zonesOnDevice: zones.length
87036
87329
  }
@@ -100802,7 +101095,7 @@ function buildDetectionSettingsSections() {
100802
101095
  type: "number",
100803
101096
  key: "zoneMembershipMinOverlap",
100804
101097
  label: "Zone membership minimum overlap",
100805
- description: "How much of a detection box must fall inside a zone for the detection to be STAMPED with it — as a fraction of the BOX area, not of the zone. 0 stamps on any touch, which is what let a plant brushing a zone by 6% fire a \"person on the doorstep\" rule. Only a stamped zone can match a zone-scoped notification rule. Read the \"zone membership stamped on event\" log line to choose the value: it prints the achieved percentage per zone.",
101098
+ description: "How much of a detection box must fall inside a zone for the detection to be STAMPED with it — as a fraction of the BOX area, not of the zone. Same bar for live tracks and parked (stationary) occupancy. 0 stamps on any touch, which is what let a plant brushing a zone by 6% fire a \"person on the doorstep\" rule. Only a stamped zone can match a zone-scoped notification rule. Read the \"zone membership stamped on event\" log line to choose the value: it prints the achieved percentage per zone.",
100806
101099
  min: 0,
100807
101100
  max: 1,
100808
101101
  step: .05,
@@ -104023,14 +104316,21 @@ var ZoneAnalyticsProvider = class {
104023
104316
  meta: { error: err instanceof Error ? err.message : String(err) }
104024
104317
  });
104025
104318
  }
104319
+ const minOverlap = await this.resolveMinOverlap(deviceId);
104026
104320
  const ts = mostRecentStationaryConfirmedAt(entries) || Date.now();
104027
104321
  return buildStationarySnapshot({
104028
104322
  deviceId,
104029
104323
  entries,
104030
104324
  zones,
104031
- timestamp: ts
104325
+ timestamp: ts,
104326
+ minOverlap
104032
104327
  });
104033
104328
  }
104329
+ /** Tracking membership bar — schema default when the host did not wire it. */
104330
+ async resolveMinOverlap(deviceId) {
104331
+ if (this.ctx.resolveZoneMembershipMinOverlap === void 0) return TRACKING_DEFAULTS.zoneMembershipMinOverlap;
104332
+ return this.ctx.resolveZoneMembershipMinOverlap(deviceId);
104333
+ }
104034
104334
  /**
104035
104335
  * Baseline sampler tick: for every device with parked objects, append a
104036
104336
  * hydrated sample to the history ring at the CURRENT time — but only when a
@@ -104048,11 +104348,13 @@ var ZoneAnalyticsProvider = class {
104048
104348
  try {
104049
104349
  zones = await this.ctx.resolveZones?.(deviceId) ?? [];
104050
104350
  } catch {}
104351
+ const minOverlap = await this.resolveMinOverlap(deviceId);
104051
104352
  const snapshot = buildStationarySnapshot({
104052
104353
  deviceId,
104053
104354
  entries,
104054
104355
  zones,
104055
- timestamp: now
104356
+ timestamp: now,
104357
+ minOverlap
104056
104358
  });
104057
104359
  if (snapshot) {
104058
104360
  this.appendHistory(deviceId, snapshot);
@@ -104209,18 +104511,18 @@ function mostRecentStationaryConfirmedAt(entries) {
104209
104511
  /**
104210
104512
  * Build an occupancy snapshot from parked-object registry entries alone —
104211
104513
  * used when no live inference frame is available (fresh respawn, camera
104212
- * detached). Each entry is folded into the frame aggregate AND attributed to
104213
- * the zones its normalised bbox centroid falls inside (via
104214
- * {@link computeStationaryEntryZones}), so a zone drawn over a parked car
104215
- * reports a count of 1. Returns `null` for an empty entry list. Reuses
104216
- * {@link computeSnapshot} — the SAME aggregation the live frame path runs.
104514
+ * detached). Each entry is folded into the frame aggregate AND attributed via
104515
+ * {@link computeStationaryEntryZones} (same `ZoneEngine` + `minOverlap` as
104516
+ * live stamps), so a zone drawn over a parked car reports a count of 1.
104517
+ * Returns `null` for an empty entry list. Reuses {@link computeSnapshot} —
104518
+ * the SAME aggregation the live frame path runs.
104217
104519
  */
104218
104520
  function buildStationarySnapshot(input) {
104219
104521
  if (input.entries.length === 0) return null;
104220
104522
  const tracked = input.entries.map((e) => ({
104221
104523
  trackId: `stationary:${e.id}`,
104222
104524
  className: e.className,
104223
- zones: computeStationaryEntryZones(e, input.zones),
104525
+ zones: computeStationaryEntryZones(e, input.zones, input.minOverlap),
104224
104526
  standing: true
104225
104527
  }));
104226
104528
  const first = input.entries[0];
@@ -107043,6 +107345,7 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
107043
107345
  resolveZones: (deviceId) => this.resolveDeviceZones(deviceId),
107044
107346
  resolvePackageRules: (deviceId) => this.resolveDevicePackageRules(deviceId),
107045
107347
  resolveSettings: (deviceId) => this.resolveDevicePackageDropSettings(deviceId),
107348
+ resolveZoneMembershipMinOverlap: async (deviceId) => (await this.resolveDeviceTrackingSettings(deviceId)).zoneMembershipMinOverlap,
107046
107349
  onPersisted: (ev, phase) => this.notificationCenter?.onPackageEventPersisted(ev, phase),
107047
107350
  onError: (scope, err) => logger.warn("package-drop detector error", { meta: {
107048
107351
  scope,
@@ -107338,6 +107641,7 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
107338
107641
  listStationaryObjects: (deviceId) => this.stationaryRegistry?.listViews(deviceId) ?? [],
107339
107642
  listStationaryDeviceIds: () => this.stationaryRegistry?.deviceIds() ?? [],
107340
107643
  resolveZones: (deviceId) => this.resolveDeviceZones(deviceId),
107644
+ resolveZoneMembershipMinOverlap: async (deviceId) => (await this.resolveDeviceTrackingSettings(deviceId)).zoneMembershipMinOverlap,
107341
107645
  onSnapshot: (deviceId, snapshot, evidence) => this.notificationCenter?.observeOccupancy(deviceId, snapshot, evidence)
107342
107646
  });
107343
107647
  this.zoneAnalytics = zoneAnalytics;
@@ -109674,7 +109978,7 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
109674
109978
  const stationaryAsTracked = stationaryViews.map((v) => ({
109675
109979
  trackId: `stationary:${v.id}`,
109676
109980
  className: v.className,
109677
- zones: computeStationaryEntryZones(v, liveZones),
109981
+ zones: computeStationaryEntryZones(v, liveZones, trk.zoneMembershipMinOverlap),
109678
109982
  standing: true
109679
109983
  }));
109680
109984
  this.zoneAnalytics?.recordFrame({