@camstack/addon-post-analysis 1.2.275 → 1.2.276
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.
- package/dist/{dist-D3Yub96m.mjs → dist-58Om1e5O.mjs} +69 -19
- package/dist/{dist-Br0p61E2.js → dist-BZaBVEUL.js} +98 -18
- package/dist/embedding-encoder/index.js +1 -1
- package/dist/embedding-encoder/index.mjs +1 -1
- package/dist/pipeline-analytics/{_virtual_mf-localSharedImportMap___mfe_internal__addon_pipeline_analytics_widgets-Byz9Hzo0.mjs → _virtual_mf-localSharedImportMap___mfe_internal__addon_pipeline_analytics_widgets-CXIsZS8q.mjs} +2 -2
- package/dist/pipeline-analytics/{hostInit-Dza3GRpt.mjs → hostInit-DlwUheDb.mjs} +2 -2
- package/dist/pipeline-analytics/index.js +2521 -916
- package/dist/pipeline-analytics/index.mjs +2521 -916
- package/dist/pipeline-analytics/remoteEntry.js +1 -1
- package/package.json +1 -1
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { $ as
|
|
1
|
+
import { $ as RECORDING_SOURCE_CAMSTACK_ADDON, $t as CamProfileSchema, At as isScheduleActive, B as NcRuleUpdateInputSchema, Bt as readDeviceStateFrom, C as MAX_BIRTH_DECISION_RECORDS, Ct as faceGalleryCapability, D as NC_ALARM_SYSTEM_EVENT_KINDS, Dt as isEventOwnerType, E as MediaFileKindEnum, Et as isDetectionMacroClass, F as NcConditionDescriptorSchema, Ft as occupancyScope, G as NcSystemEventKindSchema, Gt as storageOccupancyCapability, H as NcSnoozeInputSchema, Ht as resolveLocationMode, I as NcRuleInputSchema, J as NcTemplatePreviewSchema, Jt as templateVarsFor, K as NcTaxonomySchema, Kt as subKindsOf, L as NcRulePatchSchema, Lt as pickClusterStepModels, M as NC_TEMPLATE_VARS, Mt as kebabToCamel, N as NcAlarmConfigSchema, Nt as mayWriteToLocation, O as NC_CONDITION_CATALOG, Ot as isKnownTemplateVar, P as NcAlarmSettingsPatchSchema, Pt as notificationRulesCapability, Q as RECORDING_EXPORT_MAX_READ_BYTES, Qt as BaseAddon, R as NcRuleSchema, Rt as pipelineAnalyticsCapability, S as MAX_ARCHIVED_DEBUG_NOTES, St as evictionPolicyOfLocation, T as MOTION_CLOSE_AFTER_MS, U as NcSnoozeSchema, V as NcScheduleSchema, Vt as readTimelapseGeneratedAt, W as NcSnoozeSuppressedSchema, Wt as sceneMonitorCapability, X as OpsLogEntrySchema, Xt as zoneAnalyticsCapability, Y as NcTemplateVarDescriptorSchema, Yt as vectorDimFromBase64, Zt as errMsg$1, _ as FIRST_LEVEL_MACRO_CLASSES, _n as string, _t as cosineSimilarity$1, a as BirthDecisionRecordSchema, an as isDeviceScopedCap, at as SceneMonitorSchema, b as LabelAttributionSchema, bt as encodeVectorBase64, c as CoverStateSchema, cn as _enum, ct as TimelapseRulePatchSchema, dn as discriminatedUnion, dt as addonWidgetsSourceCapability, en as DeviceType, et as RetrainStatusSchema, f as DETECTION_MACRO_CLASSES, fn as literal, ft as alarmPanelCapability, g as EVENT_OWNER_TYPES, gn as record, gt as buildEventKindDescriptor, h as EVENT_KIND_BY_CAP, hn as partialRecord, ht as audioModeOf, i as BaseDevice, in as hydrateSchema, it as SCENE_DIVERGED, j as NC_TAXONOMY, jt as isSourceCap, k as NC_DEFAULT_SNOOZE_MINUTES, kt as isMediaOwnerType, l as DEFAULT_EVENT_COLOR, ln as array, lt as TimelapseRuleSchema, m as DeclaredDevices, mn as object, mt as audioMetricsCapability, n as AlarmArmModeSchema, nn as customAction, nt as SCENE_DEFAULT_ANCHOR_THRESHOLD, o as CLUSTER_MODEL_SCOPED_STEPS, on as nodePin, ot as TIMELAPSE_DENSE_FLOOR_SEC, pn as number, pt as assertTimelapseCadences, q as NcTemplatePreviewInputSchema, qt as systemEventFilterApplies, r as ArchivedDebugNoteSchema, rn as defineCustomActions, rt as SCENE_DEFAULT_UNCOVERED_POLICY, s as COCO_TO_MACRO, sn as sleep$1, st as TimelapseRuleInputSchema, t as AUDIO_MACRO_LABELS, tn as createEvent, un as boolean, ut as TrackSourceSchema, v as FULL_IMAGE_BBOX, vn as unknown, vt as deriveRecordingMode, wt as failureContributionCapability, x as LockStateSchema, xt as evaluateSensorEdge, y as FailureCounters, yn as EventCategory, z as NcRuleTargetSchema, zt as plateGalleryCapability } from "../dist-58Om1e5O.mjs";
|
|
2
2
|
import { t as __exportAll } from "../embedding-encoder/index.mjs";
|
|
3
3
|
import * as fs from "node:fs";
|
|
4
4
|
import { promises } from "node:fs";
|
|
@@ -227,6 +227,434 @@ var MotionEpisodeTracker = class {
|
|
|
227
227
|
}
|
|
228
228
|
};
|
|
229
229
|
//#endregion
|
|
230
|
+
//#region src/notification-center/alarm/alarm-mode-coverage.ts
|
|
231
|
+
/**
|
|
232
|
+
* Whether the panel just BECAME armed, in the sense a person means.
|
|
233
|
+
*
|
|
234
|
+
* Two transitions reach an `armed_*` state and only one of them is an arm:
|
|
235
|
+
*
|
|
236
|
+
* - `arming`/`disarmed` → `armed_away` is somebody arming the alarm. Announce.
|
|
237
|
+
* - `triggered` → `armed_away` is the siren's duration ending and the panel
|
|
238
|
+
* re-arming itself. It is told too (D630), but as `alarm-rearmed`, never as
|
|
239
|
+
* an arm: "Away armed" in the middle of a break-in would read as somebody
|
|
240
|
+
* arming it. `alarmTransition` routes it there before asking this.
|
|
241
|
+
*
|
|
242
|
+
* `armed_home` → `armed_away` IS an arm: the operator changed mode, and the
|
|
243
|
+
* set of devices covered just changed with it — which is the whole content of
|
|
244
|
+
* the message.
|
|
245
|
+
*
|
|
246
|
+
* A null `previous` is the FIRST publish and never announces. A hub restarting
|
|
247
|
+
* while armed re-publishes `armed_away`, and nobody armed anything — every
|
|
248
|
+
* deploy would otherwise send one. Handled here rather than by the caller
|
|
249
|
+
* happening to wire the hook late, because "it works because of the order two
|
|
250
|
+
* unrelated things run in" is how this stops working.
|
|
251
|
+
*/
|
|
252
|
+
function shouldAnnounceArm(previous, next) {
|
|
253
|
+
if (previous === null) return false;
|
|
254
|
+
if (!next.startsWith("armed_")) return false;
|
|
255
|
+
if (previous === next) return false;
|
|
256
|
+
if (previous === "triggered") return false;
|
|
257
|
+
return true;
|
|
258
|
+
}
|
|
259
|
+
/** The mode inside an `armed_<mode>` state, or null for any other state. */
|
|
260
|
+
function armModeOf(state, modes) {
|
|
261
|
+
for (const mode of modes) if (state === armedStateFor(mode)) return mode;
|
|
262
|
+
return null;
|
|
263
|
+
}
|
|
264
|
+
/** The `deviceState` word a rule must gate on for `mode` to cover it. */
|
|
265
|
+
function armedStateFor(mode) {
|
|
266
|
+
return `armed_${mode}`;
|
|
267
|
+
}
|
|
268
|
+
/**
|
|
269
|
+
* One entry per mode the panel offers, including modes nothing is gated on.
|
|
270
|
+
*
|
|
271
|
+
* Empty modes are KEPT rather than filtered: "Night arms nothing" is the single
|
|
272
|
+
* most useful thing this can tell an operator, and a list that omits it looks
|
|
273
|
+
* identical to a list where night is covered.
|
|
274
|
+
*/
|
|
275
|
+
function alarmModeCoverage(rules, panelDeviceId, modes, skipProbe) {
|
|
276
|
+
return modes.map((mode) => coverageFor(rules, panelDeviceId, mode, skipProbe));
|
|
277
|
+
}
|
|
278
|
+
function coverageFor(rules, panelDeviceId, mode, skipProbe) {
|
|
279
|
+
const wanted = armedStateFor(mode);
|
|
280
|
+
const ids = /* @__PURE__ */ new Set();
|
|
281
|
+
let ruleCount = 0;
|
|
282
|
+
let allDevices = false;
|
|
283
|
+
for (const rule of rules) {
|
|
284
|
+
if (!rule.enabled) continue;
|
|
285
|
+
const gate = rule.conditions.deviceState;
|
|
286
|
+
if (gate === void 0 || gate.deviceId !== panelDeviceId) continue;
|
|
287
|
+
if (!gate.states.includes(wanted)) continue;
|
|
288
|
+
ruleCount += 1;
|
|
289
|
+
const scope = rule.conditions.devices;
|
|
290
|
+
if (scope === void 0 || scope.length === 0) {
|
|
291
|
+
allDevices = true;
|
|
292
|
+
continue;
|
|
293
|
+
}
|
|
294
|
+
for (const id of scope) ids.add(id);
|
|
295
|
+
}
|
|
296
|
+
const deviceIds = [...ids].toSorted((a, b) => a - b);
|
|
297
|
+
return {
|
|
298
|
+
mode,
|
|
299
|
+
ruleCount,
|
|
300
|
+
allDevices,
|
|
301
|
+
deviceIds,
|
|
302
|
+
skippedDevices: skippedFor(deviceIds, skipProbe)
|
|
303
|
+
};
|
|
304
|
+
}
|
|
305
|
+
function skippedFor(deviceIds, skipProbe) {
|
|
306
|
+
if (skipProbe === void 0) return [];
|
|
307
|
+
const out = [];
|
|
308
|
+
for (const deviceId of deviceIds) {
|
|
309
|
+
const reason = skipProbe(deviceId);
|
|
310
|
+
if (reason !== void 0) out.push({
|
|
311
|
+
deviceId,
|
|
312
|
+
reason
|
|
313
|
+
});
|
|
314
|
+
}
|
|
315
|
+
return out;
|
|
316
|
+
}
|
|
317
|
+
/**
|
|
318
|
+
* Whether this mode arms anything, and if not, which way it fails.
|
|
319
|
+
*
|
|
320
|
+
* An `allDevices` mode is never a gap: `skippedDevices` only ever probes the
|
|
321
|
+
* ids a rule wrote down, so it cannot answer for a mode that covers every
|
|
322
|
+
* camera on the hub, and reporting "all excluded" from a subset would be a
|
|
323
|
+
* warning about protection that exists.
|
|
324
|
+
*/
|
|
325
|
+
function alarmCoverageGap(coverage) {
|
|
326
|
+
if (coverage.ruleCount === 0) return "uncovered";
|
|
327
|
+
if (coverage.allDevices) return null;
|
|
328
|
+
if (coverage.deviceIds.length === 0) return null;
|
|
329
|
+
if (coverage.skippedDevices.length < coverage.deviceIds.length) return null;
|
|
330
|
+
return "all-skipped";
|
|
331
|
+
}
|
|
332
|
+
/**
|
|
333
|
+
* "Attivi: Cancello, Garage — Esclusi: Porta retro."
|
|
334
|
+
*
|
|
335
|
+
* A LIST, not a sentence. The mode is in the title and the body is one
|
|
336
|
+
* `Label: names` line per device group, because this is read on a lock screen
|
|
337
|
+
* and the question is always "which one" — the prose version ("3 dispositivi
|
|
338
|
+
* attivi: …. Esclusi: ….") made the operator parse a paragraph to find a
|
|
339
|
+
* window. Each variant still owns its own catalog key, so the Texts tab can
|
|
340
|
+
* re-word "nothing is armed" without touching "these are armed".
|
|
341
|
+
*
|
|
342
|
+
* Names, not ids: this is read by a person standing at a door. An id that has
|
|
343
|
+
* no name falls back to a NAMED, translated placeholder rather than being
|
|
344
|
+
* dropped — a silently shorter list would understate what is armed, which is
|
|
345
|
+
* the one error this message must not make. (It used to hard-code the English
|
|
346
|
+
* `Device <id>` into both languages.)
|
|
347
|
+
*
|
|
348
|
+
* **Always a sentence.** It used to return null for a mode no rule is gated on,
|
|
349
|
+
* on the grounds that "Night armed" followed by an unwatched night is a false
|
|
350
|
+
* assurance — true, but the caller then emitted `alarm-armed` anyway with a
|
|
351
|
+
* fallback body key that existed in NEITHER locale, so the operator received a
|
|
352
|
+
* title and an empty line. Silence is not the third option here: SAYING that
|
|
353
|
+
* the mode arms nothing is.
|
|
354
|
+
*/
|
|
355
|
+
function buildArmAnnouncement(coverage, deviceName, bypassed = []) {
|
|
356
|
+
const rules = `${coverage.ruleCount}`;
|
|
357
|
+
const mode = coverage.mode;
|
|
358
|
+
if (bypassed.length > 0) return armedWithBypass(coverage, bypassed, deviceName);
|
|
359
|
+
const gap = alarmCoverageGap(coverage);
|
|
360
|
+
if (gap === "uncovered") return {
|
|
361
|
+
bodyKey: "alarm.arm.body.uncovered",
|
|
362
|
+
textParams: { mode },
|
|
363
|
+
textCount: 0
|
|
364
|
+
};
|
|
365
|
+
if (gap === "all-skipped") {
|
|
366
|
+
const names = coverage.skippedDevices.map((s) => deviceName(s.deviceId));
|
|
367
|
+
return {
|
|
368
|
+
bodyKey: "alarm.arm.body.allSkipped",
|
|
369
|
+
textParams: {
|
|
370
|
+
rules,
|
|
371
|
+
mode,
|
|
372
|
+
devices: `${names.length}`,
|
|
373
|
+
names: names.join(", ")
|
|
374
|
+
},
|
|
375
|
+
textCount: names.length
|
|
376
|
+
};
|
|
377
|
+
}
|
|
378
|
+
if (coverage.allDevices) return {
|
|
379
|
+
bodyKey: "alarm.arm.body.allDevices",
|
|
380
|
+
textParams: {
|
|
381
|
+
rules,
|
|
382
|
+
mode
|
|
383
|
+
},
|
|
384
|
+
textCount: coverage.ruleCount
|
|
385
|
+
};
|
|
386
|
+
if (coverage.deviceIds.length === 0) return {
|
|
387
|
+
bodyKey: "alarm.arm.body.noDevices",
|
|
388
|
+
textParams: {
|
|
389
|
+
rules,
|
|
390
|
+
mode
|
|
391
|
+
},
|
|
392
|
+
textCount: coverage.ruleCount
|
|
393
|
+
};
|
|
394
|
+
const skipped = new Set(coverage.skippedDevices.map((s) => s.deviceId));
|
|
395
|
+
const names = coverage.deviceIds.filter((id) => !skipped.has(id)).map(deviceName);
|
|
396
|
+
if (skipped.size === 0) return {
|
|
397
|
+
bodyKey: "alarm.arm.body.devices",
|
|
398
|
+
textParams: {
|
|
399
|
+
rules,
|
|
400
|
+
mode,
|
|
401
|
+
devices: `${names.length}`,
|
|
402
|
+
names: names.join(", ")
|
|
403
|
+
},
|
|
404
|
+
textCount: names.length
|
|
405
|
+
};
|
|
406
|
+
return {
|
|
407
|
+
bodyKey: "alarm.arm.body.devicesSkipped",
|
|
408
|
+
textParams: {
|
|
409
|
+
rules,
|
|
410
|
+
mode,
|
|
411
|
+
devices: `${names.length}`,
|
|
412
|
+
names: names.join(", "),
|
|
413
|
+
skippedNames: coverage.skippedDevices.map((s) => deviceName(s.deviceId)).join(", ")
|
|
414
|
+
},
|
|
415
|
+
textCount: names.length
|
|
416
|
+
};
|
|
417
|
+
}
|
|
418
|
+
/**
|
|
419
|
+
* "Attivi: 2 — Bypassati: Finestra veranda."
|
|
420
|
+
*
|
|
421
|
+
* The bypassed sensors are named and the armed ones are COUNTED, not listed:
|
|
422
|
+
* this sentence's subject is the exception, and burying it under thirteen
|
|
423
|
+
* camera names is how an operator stops reading the arm notification. The
|
|
424
|
+
* count pluralises on what IS armed, like every other arm variant.
|
|
425
|
+
*/
|
|
426
|
+
function armedWithBypass(coverage, bypassed, deviceName) {
|
|
427
|
+
const skipped = new Set(coverage.skippedDevices.map((s) => s.deviceId));
|
|
428
|
+
const excluded = new Set(bypassed);
|
|
429
|
+
const armed = coverage.allDevices ? coverage.ruleCount : coverage.deviceIds.filter((id) => !skipped.has(id) && !excluded.has(id)).length;
|
|
430
|
+
return {
|
|
431
|
+
bodyKey: "alarm.arm.body.bypassed",
|
|
432
|
+
textParams: {
|
|
433
|
+
rules: `${coverage.ruleCount}`,
|
|
434
|
+
mode: coverage.mode,
|
|
435
|
+
devices: `${armed}`,
|
|
436
|
+
bypassedNames: bypassed.map(deviceName).join(", ")
|
|
437
|
+
},
|
|
438
|
+
textCount: armed
|
|
439
|
+
};
|
|
440
|
+
}
|
|
441
|
+
/**
|
|
442
|
+
* "Aperti: Finestra veranda, Porta retro" + where the Arm anyway is.
|
|
443
|
+
*
|
|
444
|
+
* The one alarm sentence about something that did not happen, so it has to
|
|
445
|
+
* carry both halves an operator needs to act: WHICH devices are open, and —
|
|
446
|
+
* when the notification carries the button — that its Arm anyway arms without
|
|
447
|
+
* them (D630: pressing arm again is refused again; there is no second-press
|
|
448
|
+
* bypass any more, so the sentence never says "repeat within N seconds").
|
|
449
|
+
* Naming them is the whole message, so the names are the FIRST line.
|
|
450
|
+
*/
|
|
451
|
+
function buildArmRefusedAnnouncement(mode, names, withArmAnyway) {
|
|
452
|
+
return {
|
|
453
|
+
bodyKey: withArmAnyway ? "alarm.armRefused.body.hint" : "alarm.armRefused.body",
|
|
454
|
+
textParams: {
|
|
455
|
+
mode,
|
|
456
|
+
devices: `${names.length}`,
|
|
457
|
+
names: names.join(", ")
|
|
458
|
+
},
|
|
459
|
+
textCount: names.length
|
|
460
|
+
};
|
|
461
|
+
}
|
|
462
|
+
/**
|
|
463
|
+
* The EXCEPTIONS to the same coverage, said 30 seconds earlier.
|
|
464
|
+
*
|
|
465
|
+
* The exit-delay sentence is deliberately SHORTER than the arm one — it is read
|
|
466
|
+
* on the way out of a door — and it carries exactly one thing the arm sentence
|
|
467
|
+
* cannot: the warning arrives while the operator can still disarm and fix it.
|
|
468
|
+
* A mode that arms nothing is worth interrupting somebody for at second 0 and
|
|
469
|
+
* merely worth recording at second 30.
|
|
470
|
+
*
|
|
471
|
+
* **It never repeats the device list.** The countdown lives in the TITLE
|
|
472
|
+
* (`system.alarm-arming.title.<mode>` renders `{{seconds}}`), and the armed
|
|
473
|
+
* list arrives thirty seconds later in the arm announcement — printing it
|
|
474
|
+
* twice is the verbosity that made the operator stop reading both. So the body
|
|
475
|
+
* is what is WRONG: what was bypassed, what is excluded, or that nothing is.
|
|
476
|
+
*
|
|
477
|
+
* `seconds` stays in the params because the title asks for it, and `textCount`
|
|
478
|
+
* stays with it: a row enqueued before this deploy still pluralises on the
|
|
479
|
+
* countdown it was written for.
|
|
480
|
+
*/
|
|
481
|
+
function buildArmingAnnouncement(coverage, seconds, deviceName, bypassed = []) {
|
|
482
|
+
const textParams = {
|
|
483
|
+
mode: coverage.mode,
|
|
484
|
+
seconds: `${seconds}`
|
|
485
|
+
};
|
|
486
|
+
if (bypassed.length > 0) return {
|
|
487
|
+
bodyKey: "alarm.arming.body.bypassed",
|
|
488
|
+
textParams: {
|
|
489
|
+
...textParams,
|
|
490
|
+
bypassedNames: bypassed.map(deviceName).join(", ")
|
|
491
|
+
},
|
|
492
|
+
textCount: seconds
|
|
493
|
+
};
|
|
494
|
+
const skippedNames = coverage.skippedDevices.map((s) => deviceName(s.deviceId)).join(", ");
|
|
495
|
+
const gap = alarmCoverageGap(coverage);
|
|
496
|
+
if (gap === "uncovered") return {
|
|
497
|
+
bodyKey: "alarm.arming.body.uncovered",
|
|
498
|
+
textParams,
|
|
499
|
+
textCount: seconds
|
|
500
|
+
};
|
|
501
|
+
if (gap === "all-skipped") return {
|
|
502
|
+
bodyKey: "alarm.arming.body.allSkipped",
|
|
503
|
+
textParams: {
|
|
504
|
+
...textParams,
|
|
505
|
+
names: skippedNames
|
|
506
|
+
},
|
|
507
|
+
textCount: seconds
|
|
508
|
+
};
|
|
509
|
+
if (coverage.skippedDevices.length > 0) return {
|
|
510
|
+
bodyKey: "alarm.arming.body.skipped",
|
|
511
|
+
textParams: {
|
|
512
|
+
...textParams,
|
|
513
|
+
names: skippedNames
|
|
514
|
+
},
|
|
515
|
+
textCount: coverage.skippedDevices.length
|
|
516
|
+
};
|
|
517
|
+
return {
|
|
518
|
+
bodyKey: "alarm.arming.body",
|
|
519
|
+
textParams,
|
|
520
|
+
textCount: seconds
|
|
521
|
+
};
|
|
522
|
+
}
|
|
523
|
+
//#endregion
|
|
524
|
+
//#region src/notification-center/alarm/alarm-arm-guard.ts
|
|
525
|
+
/**
|
|
526
|
+
* How long an Arm anyway stays pressable — the panel's refusal and the
|
|
527
|
+
* button's grant TTL (`action-token.ts`) use this one constant. It lives here,
|
|
528
|
+
* with the arm decision, so the token module does not import the device.
|
|
529
|
+
*/
|
|
530
|
+
var ARM_ANYWAY_TTL_MS = 5 * 6e4;
|
|
531
|
+
/**
|
|
532
|
+
* One covered device that reads OPEN, with the name a person will read and the
|
|
533
|
+
* word its cap reported (`open`, `stopped`, `unlocked`, …) — not only contacts:
|
|
534
|
+
* a cover or a lock that is not secure is an opening too (D630).
|
|
535
|
+
*/
|
|
536
|
+
var NcAlarmOpenDeviceSchema = object({
|
|
537
|
+
deviceId: number().int(),
|
|
538
|
+
name: string(),
|
|
539
|
+
state: string()
|
|
540
|
+
}).readonly();
|
|
541
|
+
/** Why an Arm anyway was not honoured — see `NcAlarmPanelDevice.armAnyway`. */
|
|
542
|
+
var NcAlarmArmAnywayRejectionSchema = _enum([
|
|
543
|
+
"unknown-refusal",
|
|
544
|
+
"stale",
|
|
545
|
+
"expired",
|
|
546
|
+
"used"
|
|
547
|
+
]);
|
|
548
|
+
/**
|
|
549
|
+
* What one arm attempt — plain or Arm anyway — came to.
|
|
550
|
+
*
|
|
551
|
+
* A schema, and it lives HERE, in the addon, because it is the response of the
|
|
552
|
+
* `nc.armAlarm` / `nc.armAlarmAnyway` BRIDGE actions: the bridge validates
|
|
553
|
+
* against the addon's own copy, so a refusal can travel as a result (its id and
|
|
554
|
+
* the names of what is open) and the viewer can offer the Arm anyway bound to
|
|
555
|
+
* it. The cap cannot: `alarm-panel.arm` answers `z.void()` on the HOST's router,
|
|
556
|
+
* which is why the cap throws {@link NcAlarmArmRefusedError} instead.
|
|
557
|
+
*/
|
|
558
|
+
var NcAlarmArmOutcomeSchema = discriminatedUnion("outcome", [
|
|
559
|
+
object({
|
|
560
|
+
outcome: _enum(["armed", "arming"]),
|
|
561
|
+
excluded: array(NcAlarmOpenDeviceSchema).readonly()
|
|
562
|
+
}).readonly(),
|
|
563
|
+
object({
|
|
564
|
+
outcome: literal("refused"),
|
|
565
|
+
refusalId: string(),
|
|
566
|
+
blocking: array(NcAlarmOpenDeviceSchema).readonly()
|
|
567
|
+
}).readonly(),
|
|
568
|
+
object({
|
|
569
|
+
outcome: literal("rejected"),
|
|
570
|
+
reason: NcAlarmArmAnywayRejectionSchema
|
|
571
|
+
}).readonly()
|
|
572
|
+
]);
|
|
573
|
+
/**
|
|
574
|
+
* The devices a mode covers, ascending and de-duplicated.
|
|
575
|
+
*
|
|
576
|
+
* A rule qualifies when it is enabled and gated on THIS panel in THIS mode;
|
|
577
|
+
* every device it names is covered, whatever its kind. A camera it names is
|
|
578
|
+
* covered too, and is simply never an opening (`alarmOpeningFrom` answers
|
|
579
|
+
* `not-opening` for it, and the arm check skips it without a word).
|
|
580
|
+
*
|
|
581
|
+
* A rule with NO device scope contributes nothing: it covers every device on
|
|
582
|
+
* the hub, and blocking on a set the operator never wrote down produces a
|
|
583
|
+
* refusal naming devices they never associated with the alarm. The caller says
|
|
584
|
+
* so in a log line rather than inventing the list.
|
|
585
|
+
*/
|
|
586
|
+
function alarmCoveredDevices(rules, panelDeviceId, mode) {
|
|
587
|
+
const wanted = armedStateFor(mode);
|
|
588
|
+
const ids = /* @__PURE__ */ new Set();
|
|
589
|
+
for (const rule of rules) {
|
|
590
|
+
if (!rule.enabled) continue;
|
|
591
|
+
const gate = rule.conditions.deviceState;
|
|
592
|
+
if (gate === void 0 || gate.deviceId !== panelDeviceId) continue;
|
|
593
|
+
if (!gate.states.includes(wanted)) continue;
|
|
594
|
+
const scope = rule.conditions.devices;
|
|
595
|
+
if (scope === void 0 || scope.length === 0) continue;
|
|
596
|
+
for (const id of scope) ids.add(id);
|
|
597
|
+
}
|
|
598
|
+
return [...ids].toSorted((a, b) => a - b);
|
|
599
|
+
}
|
|
600
|
+
/**
|
|
601
|
+
* Refuse or arm — the whole decision, as one partition of the open devices.
|
|
602
|
+
*
|
|
603
|
+
* A device may be open at arm time for exactly two reasons: the mode TOLERATES
|
|
604
|
+
* it (its non-blocking list), or the operator was shown it in a refusal and
|
|
605
|
+
* pressed that refusal's Arm anyway (`acknowledged`). Anything else open
|
|
606
|
+
* refuses — including a device that opened AFTER the refusal the operator is
|
|
607
|
+
* confirming: they were never shown it, and excluding it would arm the alarm
|
|
608
|
+
* around an opening nobody agreed to.
|
|
609
|
+
*
|
|
610
|
+
* There is no memory here. A plain arm pressed twice is the same question
|
|
611
|
+
* twice and gets the same answer; the only way past a refusal is the explicit
|
|
612
|
+
* Arm anyway, whose lifecycle (one-shot, stale, expired) the panel owns.
|
|
613
|
+
*
|
|
614
|
+
* Excluded is EVERY open device — tolerated and acknowledged alike — and the
|
|
615
|
+
* exclusion is two-phase (`alarm-exclusions.ts`): each one is watched again
|
|
616
|
+
* once it has closed and opened.
|
|
617
|
+
*/
|
|
618
|
+
function decideArm(input) {
|
|
619
|
+
const tolerated = new Set([...input.nonBlocking, ...input.acknowledged ?? []]);
|
|
620
|
+
const blocking = input.open.filter((d) => !tolerated.has(d.deviceId));
|
|
621
|
+
if (blocking.length > 0) return {
|
|
622
|
+
kind: "refused",
|
|
623
|
+
blocking
|
|
624
|
+
};
|
|
625
|
+
return {
|
|
626
|
+
kind: "arm",
|
|
627
|
+
excluded: input.open
|
|
628
|
+
};
|
|
629
|
+
}
|
|
630
|
+
/**
|
|
631
|
+
* The error `alarmPanel.arm` throws when it refuses.
|
|
632
|
+
*
|
|
633
|
+
* A THROW and not a return value, and the reason is a train: the hub's
|
|
634
|
+
* generated router validates this method's response against the HOST's copy of
|
|
635
|
+
* the cap, which says `z.void()`. An addon that answered a structured refusal
|
|
636
|
+
* would have it rejected by the router in front of it — so the refusal reaches
|
|
637
|
+
* the caller as the one channel that already works across that boundary, and
|
|
638
|
+
* the operator's PHONE gets the sentence (`alarm-arm-refused`, through the
|
|
639
|
+
* ordinary outbox, with its own Arm anyway) rather than a UI error being the
|
|
640
|
+
* only trace.
|
|
641
|
+
*
|
|
642
|
+
* The message is deliberately actionable and English (an exception message is
|
|
643
|
+
* a developer/API surface; the notification is the operator's).
|
|
644
|
+
*/
|
|
645
|
+
var NcAlarmArmRefusedError = class extends Error {
|
|
646
|
+
code = "alarm-arm-refused";
|
|
647
|
+
mode;
|
|
648
|
+
blocking;
|
|
649
|
+
constructor(input) {
|
|
650
|
+
const names = input.blocking.map((d) => d.name).join(", ");
|
|
651
|
+
super(`arm refused — open: ${names}. Use "Arm anyway" to arm with them excluded.`);
|
|
652
|
+
this.name = "NcAlarmArmRefusedError";
|
|
653
|
+
this.mode = input.mode;
|
|
654
|
+
this.blocking = input.blocking;
|
|
655
|
+
}
|
|
656
|
+
};
|
|
657
|
+
//#endregion
|
|
230
658
|
//#region src/notification-center/action-token.ts
|
|
231
659
|
/**
|
|
232
660
|
* The authority behind a notification button.
|
|
@@ -257,6 +685,18 @@ var MotionEpisodeTracker = class {
|
|
|
257
685
|
* accepted: a deploy kills the buttons on notifications already delivered,
|
|
258
686
|
* which with a short TTL were nearly dead anyway.
|
|
259
687
|
*/
|
|
688
|
+
/**
|
|
689
|
+
* A granted action that ran and was REFUSED for a reason the person holding
|
|
690
|
+
* the phone can act on — "the alarm changed since this notification". Its
|
|
691
|
+
* message is shown on the page the tap lands on; any other throw stays the
|
|
692
|
+
* generic "it did not work".
|
|
693
|
+
*/
|
|
694
|
+
var NcActionRefusedError = class extends Error {
|
|
695
|
+
constructor(message) {
|
|
696
|
+
super(message);
|
|
697
|
+
this.name = "NcActionRefusedError";
|
|
698
|
+
}
|
|
699
|
+
};
|
|
260
700
|
/** Sign `(id, exp)` — the exact string the verifier recomputes. Same shape as
|
|
261
701
|
* the artifact plane's, deliberately: one signing convention in this addon. */
|
|
262
702
|
function signActionToken(secret, id, expMs) {
|
|
@@ -322,7 +762,9 @@ var ALARM_DISARM_TTL_MS = 5 * 6e4;
|
|
|
322
762
|
function actionTtlMs(subject) {
|
|
323
763
|
switch (subject.kind) {
|
|
324
764
|
case "snooze": return SNOOZE_ACTION_TTL_MS;
|
|
325
|
-
case "alarm":
|
|
765
|
+
case "alarm":
|
|
766
|
+
if (subject.action === "arm-anyway") return ARM_ANYWAY_TTL_MS;
|
|
767
|
+
return subject.action === "disarm" ? ALARM_DISARM_TTL_MS : DEFAULT_ACTION_TTL_MS;
|
|
326
768
|
case "sequence": return DEFAULT_ACTION_TTL_MS;
|
|
327
769
|
case "addon-update": return DEFAULT_ACTION_TTL_MS;
|
|
328
770
|
case "server-update": return DEFAULT_ACTION_TTL_MS;
|
|
@@ -404,7 +846,9 @@ function describeGrant(grant) {
|
|
|
404
846
|
switch (grant.kind) {
|
|
405
847
|
case "sequence": return `${grant.ruleName} — ${grant.sequence}`;
|
|
406
848
|
case "snooze": return grant.classes === void 0 || grant.classes.length === 0 ? `Silenced for ${grant.minutes} min` : `Silenced for ${grant.minutes} min — ${grant.classes.join(", ")}`;
|
|
407
|
-
case "alarm":
|
|
849
|
+
case "alarm":
|
|
850
|
+
if (grant.action === "arm-anyway") return "Alarm armed, around the open devices";
|
|
851
|
+
return grant.action === "disarm" ? "Alarm disarmed" : `Alarm armed${grant.mode !== void 0 ? ` (${grant.mode})` : ""}`;
|
|
408
852
|
case "addon-update": return `${grant.packages.length} addon update(s) on ${grant.nodeId}`;
|
|
409
853
|
case "server-update": return `Server update on ${grant.nodeId}`;
|
|
410
854
|
}
|
|
@@ -431,7 +875,12 @@ function grantMeta(grant) {
|
|
|
431
875
|
minutes: grant.minutes,
|
|
432
876
|
classes: grant.classes ?? null
|
|
433
877
|
};
|
|
434
|
-
case "alarm": return {
|
|
878
|
+
case "alarm": return grant.action === "arm-anyway" ? {
|
|
879
|
+
...common,
|
|
880
|
+
alarm: grant.action,
|
|
881
|
+
refusalId: grant.refusalId,
|
|
882
|
+
panelDeviceId: grant.panelDeviceId
|
|
883
|
+
} : {
|
|
435
884
|
...common,
|
|
436
885
|
alarm: grant.action,
|
|
437
886
|
mode: grant.mode ?? null,
|
|
@@ -501,6 +950,18 @@ var NcActionPlane = class {
|
|
|
501
950
|
});
|
|
502
951
|
this.reply(res, background, 200, "Done", describeGrant(grant));
|
|
503
952
|
} catch (err) {
|
|
953
|
+
if (err instanceof NcActionRefusedError) {
|
|
954
|
+
this.deps.logger.info("nc action refused after the token was spent", {
|
|
955
|
+
tags: { deviceId: grant.deviceId },
|
|
956
|
+
meta: {
|
|
957
|
+
kind: grant.kind,
|
|
958
|
+
...grantMeta(grant),
|
|
959
|
+
reason: err.message
|
|
960
|
+
}
|
|
961
|
+
});
|
|
962
|
+
this.reply(res, background, 200, "Nothing was done", err.message);
|
|
963
|
+
return;
|
|
964
|
+
}
|
|
504
965
|
this.deps.logger.warn("nc action failed after the token was spent", {
|
|
505
966
|
tags: { deviceId: grant.deviceId },
|
|
506
967
|
meta: {
|
|
@@ -530,6 +991,334 @@ var NcActionPlane = class {
|
|
|
530
991
|
}
|
|
531
992
|
};
|
|
532
993
|
//#endregion
|
|
994
|
+
//#region src/notification-center/alarm/alarm-openings.ts
|
|
995
|
+
/**
|
|
996
|
+
* What "open" means to the alarm — D630's table, in one place.
|
|
997
|
+
*
|
|
998
|
+
* Read from the RAW runtime slices, not from the collapsed `deviceState`
|
|
999
|
+
* word: the collapse is first-match over every cap, so it cannot say whether a
|
|
1000
|
+
* device is an opening at all, and a camera and an unreadable window would both
|
|
1001
|
+
* come back `undefined`.
|
|
1002
|
+
*
|
|
1003
|
+
* Three answers, never two (D49 — an unreadable state is unknown, never closed
|
|
1004
|
+
* and never open):
|
|
1005
|
+
*
|
|
1006
|
+
* - `not-opening` — no opening slice at all: a camera named by an intrusion
|
|
1007
|
+
* rule, a switch. It takes no part in the arm decision.
|
|
1008
|
+
* - `unknown` — an opening cap's slice IS there, but its field is missing or
|
|
1009
|
+
* is not a word that cap defines, or the slice is a provider's PLACEHOLDER
|
|
1010
|
+
* (`lastChangedAt === 0`: written by a constructor before anybody read the
|
|
1011
|
+
* device — an HA lock's `unlocked`, an HA cover's `closed`, a binary
|
|
1012
|
+
* sensor's `entryOpen: false`). It carries the cap and the raw value (for a
|
|
1013
|
+
* placeholder, the whole slice) so the caller's log line can say what it
|
|
1014
|
+
* saw. It never blocks and is never armed as secure on a guess — and a
|
|
1015
|
+
* placeholder is never taken for a state, so neither the reconcile tick,
|
|
1016
|
+
* the arm check nor an exclusion lift acts on one.
|
|
1017
|
+
* - `reading` — open or secure, with the state word a person will read.
|
|
1018
|
+
* ANY non-zero `lastChangedAt` is a reading, however old — including `1`,
|
|
1019
|
+
* the stamp a provider writes on a value it REPLAYED after a respawn with
|
|
1020
|
+
* no upstream change time (Homematic `HM_SEEDED_CHANGED_AT`): the value is
|
|
1021
|
+
* known, only its change time is not. The transition gate
|
|
1022
|
+
* (`sensor-transition-gate.ts`, "A placeholder slice is not a state") reads
|
|
1023
|
+
* the same stamp as "not a fresh event"; `0` alone means "not read yet".
|
|
1024
|
+
*
|
|
1025
|
+
* A read that FAILED never reaches {@link alarmOpeningFrom}: the caller leaves
|
|
1026
|
+
* that id out of its answer (see {@link readAlarmOpenings}).
|
|
1027
|
+
*
|
|
1028
|
+
* Anything short of fully secure is open: a cover stopped half-way, a lock that
|
|
1029
|
+
* jammed, a cover still closing when the arm is decided.
|
|
1030
|
+
*
|
|
1031
|
+
* PRECEDENCE, when one device carries more than one opening slice: `contact`
|
|
1032
|
+
* first, then `cover`, then `lock-control`. The contact is the physical truth
|
|
1033
|
+
* about the opening — a reed switch on the frame says the window is open even
|
|
1034
|
+
* when the motor's last reported word says `closed`. A slice that cannot be
|
|
1035
|
+
* read falls through to the next; only when none of them reads is the device
|
|
1036
|
+
* `unknown`, naming the first unreadable cap.
|
|
1037
|
+
*
|
|
1038
|
+
* The vocabulary of each word is its cap's own schema (`CoverStateSchema`,
|
|
1039
|
+
* `LockStateSchema`) — a word the cap does not define is not guessed at.
|
|
1040
|
+
*/
|
|
1041
|
+
/** The one state word, per cap, that counts as secure. Everything else is open. */
|
|
1042
|
+
var COVER_SECURE = "closed";
|
|
1043
|
+
var LOCK_SECURE = "locked";
|
|
1044
|
+
var NOT_OPENING = { kind: "not-opening" };
|
|
1045
|
+
function sliceOf(runtime, cap) {
|
|
1046
|
+
const slice = runtime[cap];
|
|
1047
|
+
if (slice === void 0) return void 0;
|
|
1048
|
+
return slice !== null && typeof slice === "object" ? slice : null;
|
|
1049
|
+
}
|
|
1050
|
+
function fieldOf(slice, field) {
|
|
1051
|
+
if (slice === null) return void 0;
|
|
1052
|
+
return Object.entries(slice).find(([key]) => key === field)?.[1];
|
|
1053
|
+
}
|
|
1054
|
+
/** A provider's cold placeholder: nobody has read the device yet (D49 ⇒ unknown). */
|
|
1055
|
+
function isPlaceholder(slice) {
|
|
1056
|
+
return fieldOf(slice, "lastChangedAt") === 0;
|
|
1057
|
+
}
|
|
1058
|
+
function reading(word, secure) {
|
|
1059
|
+
return {
|
|
1060
|
+
kind: "reading",
|
|
1061
|
+
reading: word === secure ? "secure" : "open",
|
|
1062
|
+
state: word
|
|
1063
|
+
};
|
|
1064
|
+
}
|
|
1065
|
+
function readContact(runtime) {
|
|
1066
|
+
const slice = sliceOf(runtime, "contact");
|
|
1067
|
+
if (slice === void 0) return { kind: "absent" };
|
|
1068
|
+
if (isPlaceholder(slice)) return {
|
|
1069
|
+
kind: "unknown",
|
|
1070
|
+
cap: "contact",
|
|
1071
|
+
raw: slice
|
|
1072
|
+
};
|
|
1073
|
+
const raw = fieldOf(slice, "entryOpen");
|
|
1074
|
+
if (typeof raw !== "boolean") return {
|
|
1075
|
+
kind: "unknown",
|
|
1076
|
+
cap: "contact",
|
|
1077
|
+
raw
|
|
1078
|
+
};
|
|
1079
|
+
return raw ? {
|
|
1080
|
+
kind: "reading",
|
|
1081
|
+
reading: "open",
|
|
1082
|
+
state: "open"
|
|
1083
|
+
} : {
|
|
1084
|
+
kind: "reading",
|
|
1085
|
+
reading: "secure",
|
|
1086
|
+
state: "closed"
|
|
1087
|
+
};
|
|
1088
|
+
}
|
|
1089
|
+
function readCover(runtime) {
|
|
1090
|
+
const slice = sliceOf(runtime, "cover");
|
|
1091
|
+
if (slice === void 0) return { kind: "absent" };
|
|
1092
|
+
if (isPlaceholder(slice)) return {
|
|
1093
|
+
kind: "unknown",
|
|
1094
|
+
cap: "cover",
|
|
1095
|
+
raw: slice
|
|
1096
|
+
};
|
|
1097
|
+
const raw = fieldOf(slice, "state");
|
|
1098
|
+
const parsed = CoverStateSchema.safeParse(raw);
|
|
1099
|
+
return parsed.success ? reading(parsed.data, COVER_SECURE) : {
|
|
1100
|
+
kind: "unknown",
|
|
1101
|
+
cap: "cover",
|
|
1102
|
+
raw
|
|
1103
|
+
};
|
|
1104
|
+
}
|
|
1105
|
+
function readLock(runtime) {
|
|
1106
|
+
const slice = sliceOf(runtime, "lock-control");
|
|
1107
|
+
if (slice === void 0) return { kind: "absent" };
|
|
1108
|
+
if (isPlaceholder(slice)) return {
|
|
1109
|
+
kind: "unknown",
|
|
1110
|
+
cap: "lock-control",
|
|
1111
|
+
raw: slice
|
|
1112
|
+
};
|
|
1113
|
+
const raw = fieldOf(slice, "state");
|
|
1114
|
+
const parsed = LockStateSchema.safeParse(raw);
|
|
1115
|
+
return parsed.success ? reading(parsed.data, LOCK_SECURE) : {
|
|
1116
|
+
kind: "unknown",
|
|
1117
|
+
cap: "lock-control",
|
|
1118
|
+
raw
|
|
1119
|
+
};
|
|
1120
|
+
}
|
|
1121
|
+
/** D630's table over one device's runtime slices — see the header for the three answers. */
|
|
1122
|
+
function alarmOpeningFrom(runtime) {
|
|
1123
|
+
let firstUnknown = null;
|
|
1124
|
+
for (const read of [
|
|
1125
|
+
readContact(runtime),
|
|
1126
|
+
readCover(runtime),
|
|
1127
|
+
readLock(runtime)
|
|
1128
|
+
]) {
|
|
1129
|
+
if (read.kind === "reading") return read;
|
|
1130
|
+
if (read.kind === "unknown" && firstUnknown === null) firstUnknown = read;
|
|
1131
|
+
}
|
|
1132
|
+
return firstUnknown ?? NOT_OPENING;
|
|
1133
|
+
}
|
|
1134
|
+
/**
|
|
1135
|
+
* The sensor-event kind (`CAP_TO_KIND` in `@camstack/types`) → the cap whose
|
|
1136
|
+
* slice the row's `value` is. Only the opening kinds; every other kind is not
|
|
1137
|
+
* an opening.
|
|
1138
|
+
*/
|
|
1139
|
+
var OPENING_CAP_BY_EVENT_KIND = {
|
|
1140
|
+
contact: "contact",
|
|
1141
|
+
cover: "cover",
|
|
1142
|
+
lock: "lock-control"
|
|
1143
|
+
};
|
|
1144
|
+
/**
|
|
1145
|
+
* The same table, read off one sensor-event row: its `value` IS the slice of
|
|
1146
|
+
* the cap its `kind` names, so the event path and the reconcile read answer in
|
|
1147
|
+
* one vocabulary — a cover is secure only when `closed`, a lock only when
|
|
1148
|
+
* `locked`, whichever path saw it.
|
|
1149
|
+
*/
|
|
1150
|
+
function alarmOpeningFromSensorEvent(kind, value) {
|
|
1151
|
+
const cap = OPENING_CAP_BY_EVENT_KIND[kind];
|
|
1152
|
+
if (cap === void 0) return NOT_OPENING;
|
|
1153
|
+
return alarmOpeningFrom({ [cap]: value });
|
|
1154
|
+
}
|
|
1155
|
+
function classifyCoveredOpening(opening) {
|
|
1156
|
+
if (opening === void 0) return {
|
|
1157
|
+
kind: "unknown",
|
|
1158
|
+
why: "read-failed"
|
|
1159
|
+
};
|
|
1160
|
+
if (opening.kind === "unknown") return {
|
|
1161
|
+
kind: "unknown",
|
|
1162
|
+
why: "unknown-word",
|
|
1163
|
+
cap: opening.cap,
|
|
1164
|
+
raw: opening.raw
|
|
1165
|
+
};
|
|
1166
|
+
if (opening.kind === "not-opening" || opening.reading !== "open") return { kind: "skip" };
|
|
1167
|
+
return {
|
|
1168
|
+
kind: "open",
|
|
1169
|
+
state: opening.state
|
|
1170
|
+
};
|
|
1171
|
+
}
|
|
1172
|
+
/**
|
|
1173
|
+
* An `unknown` opening's `raw`, for a log line: a placeholder carries the whole
|
|
1174
|
+
* slice, which `String()` would flatten to `[object Object]`.
|
|
1175
|
+
*/
|
|
1176
|
+
function rawForLog(raw) {
|
|
1177
|
+
return raw !== null && typeof raw === "object" ? JSON.stringify(raw) : String(raw);
|
|
1178
|
+
}
|
|
1179
|
+
/** How many runtime reads `readAlarmOpenings` keeps in flight at once. */
|
|
1180
|
+
var OPENINGS_READ_CONCURRENCY = 8;
|
|
1181
|
+
/**
|
|
1182
|
+
* The Notification Center's `readDeviceOpenings` dep, given the one runtime
|
|
1183
|
+
* read it needs. Every id that could be read is present with its
|
|
1184
|
+
* {@link NcAlarmOpening}; an id whose read THREW is ABSENT (unknown, D49) and
|
|
1185
|
+
* leaves one debug line tagged with its `deviceId`.
|
|
1186
|
+
*/
|
|
1187
|
+
async function readAlarmOpenings(ids, loadRuntime, logger) {
|
|
1188
|
+
const out = /* @__PURE__ */ new Map();
|
|
1189
|
+
const readOne = async (id) => {
|
|
1190
|
+
try {
|
|
1191
|
+
out.set(id, alarmOpeningFrom(await loadRuntime(id)));
|
|
1192
|
+
} catch (err) {
|
|
1193
|
+
logger.debug("device-opening read failed", {
|
|
1194
|
+
tags: { deviceId: id },
|
|
1195
|
+
meta: { error: String(err) }
|
|
1196
|
+
});
|
|
1197
|
+
}
|
|
1198
|
+
};
|
|
1199
|
+
for (let i = 0; i < ids.length; i += OPENINGS_READ_CONCURRENCY) await Promise.all(ids.slice(i, i + OPENINGS_READ_CONCURRENCY).map(readOne));
|
|
1200
|
+
return out;
|
|
1201
|
+
}
|
|
1202
|
+
//#endregion
|
|
1203
|
+
//#region src/notification-center/alarm/alarm-exclusions.ts
|
|
1204
|
+
function isPhase(value) {
|
|
1205
|
+
return value === "open" || value === "closed";
|
|
1206
|
+
}
|
|
1207
|
+
var NcAlarmExclusions = class {
|
|
1208
|
+
entries = [];
|
|
1209
|
+
/** Replace the whole set — one arm, one set of exclusions. */
|
|
1210
|
+
exclude(deviceIds, at) {
|
|
1211
|
+
this.entries = [...new Set(deviceIds)].map((deviceId) => ({
|
|
1212
|
+
deviceId,
|
|
1213
|
+
phase: "open",
|
|
1214
|
+
at,
|
|
1215
|
+
announced: false
|
|
1216
|
+
}));
|
|
1217
|
+
}
|
|
1218
|
+
/** Every excluded sensor, in the order it was excluded. */
|
|
1219
|
+
list() {
|
|
1220
|
+
return this.entries.map((e) => e.deviceId);
|
|
1221
|
+
}
|
|
1222
|
+
/** True while this sensor's events must not reach the panel. */
|
|
1223
|
+
blocks(deviceId) {
|
|
1224
|
+
return this.entries.some((e) => e.deviceId === deviceId);
|
|
1225
|
+
}
|
|
1226
|
+
/**
|
|
1227
|
+
* Feed one opening's reading, and say what it CHANGED.
|
|
1228
|
+
*
|
|
1229
|
+
* Three answers, not two, because the caller has to persist on both of the
|
|
1230
|
+
* changes: the first `alarm-arm-refusal.spec.ts` run of this feature saved
|
|
1231
|
+
* only the re-arm, so a hub redeployed between the close and the re-open came
|
|
1232
|
+
* back with the sensor still in phase `open` — and the exclusion then needed
|
|
1233
|
+
* a SECOND close to lift. A phase that lives only in RAM is half a durable
|
|
1234
|
+
* exclusion.
|
|
1235
|
+
*
|
|
1236
|
+
* An unreadable state changes nothing (D49): a failed read must never lift an
|
|
1237
|
+
* exclusion the operator asked for, and must never swallow the close that
|
|
1238
|
+
* would have.
|
|
1239
|
+
*/
|
|
1240
|
+
observe(deviceId, reading, _at) {
|
|
1241
|
+
if (reading === void 0) return "unchanged";
|
|
1242
|
+
const current = this.entries.find((e) => e.deviceId === deviceId);
|
|
1243
|
+
if (current === void 0) return "unchanged";
|
|
1244
|
+
if (reading === "secure") {
|
|
1245
|
+
if (current.phase === "closed") return "unchanged";
|
|
1246
|
+
this.entries = this.entries.map((e) => e.deviceId === deviceId ? {
|
|
1247
|
+
...e,
|
|
1248
|
+
phase: "closed"
|
|
1249
|
+
} : e);
|
|
1250
|
+
return "closed";
|
|
1251
|
+
}
|
|
1252
|
+
if (current.phase === "open") return "unchanged";
|
|
1253
|
+
this.entries = this.entries.filter((e) => e.deviceId !== deviceId);
|
|
1254
|
+
return "rearmed";
|
|
1255
|
+
}
|
|
1256
|
+
/**
|
|
1257
|
+
* Mark this sensor's ignored trigger as announced — true only the FIRST time
|
|
1258
|
+
* in this arm (the caller then announces and persists), false for a repeat
|
|
1259
|
+
* or for a sensor that is not excluded.
|
|
1260
|
+
*/
|
|
1261
|
+
markAnnounced(deviceId) {
|
|
1262
|
+
const current = this.entries.find((e) => e.deviceId === deviceId);
|
|
1263
|
+
if (current === void 0 || current.announced) return false;
|
|
1264
|
+
this.entries = this.entries.map((e) => e.deviceId === deviceId ? {
|
|
1265
|
+
...e,
|
|
1266
|
+
announced: true
|
|
1267
|
+
} : e);
|
|
1268
|
+
return true;
|
|
1269
|
+
}
|
|
1270
|
+
/** The arm this belongs to is over. */
|
|
1271
|
+
clear() {
|
|
1272
|
+
this.entries = [];
|
|
1273
|
+
}
|
|
1274
|
+
/** What to persist. Plain data — it rides the panel's config blob. */
|
|
1275
|
+
snapshot() {
|
|
1276
|
+
return this.entries.map((e) => ({
|
|
1277
|
+
deviceId: e.deviceId,
|
|
1278
|
+
phase: e.phase,
|
|
1279
|
+
at: e.at,
|
|
1280
|
+
announced: e.announced
|
|
1281
|
+
}));
|
|
1282
|
+
}
|
|
1283
|
+
/**
|
|
1284
|
+
* Rebuild from the persisted blob — a GUARD, never a cast.
|
|
1285
|
+
*
|
|
1286
|
+
* Anything unrecognised restores NOTHING, which is the failure that shows
|
|
1287
|
+
* itself (a bypassed sensor that fires) rather than the one that hides (a
|
|
1288
|
+
* sensor excluded forever by a blob nobody can read). A bare id with no phase
|
|
1289
|
+
* restores as still-OPEN: it needs a close before it can re-arm, which is the
|
|
1290
|
+
* half that cannot cause a false alarm.
|
|
1291
|
+
*/
|
|
1292
|
+
restore(raw) {
|
|
1293
|
+
if (!Array.isArray(raw)) return;
|
|
1294
|
+
const out = [];
|
|
1295
|
+
for (const item of raw) {
|
|
1296
|
+
if (typeof item === "number" && Number.isInteger(item)) {
|
|
1297
|
+
out.push({
|
|
1298
|
+
deviceId: item,
|
|
1299
|
+
phase: "open",
|
|
1300
|
+
at: 0,
|
|
1301
|
+
announced: false
|
|
1302
|
+
});
|
|
1303
|
+
continue;
|
|
1304
|
+
}
|
|
1305
|
+
if (item === null || typeof item !== "object") continue;
|
|
1306
|
+
const rec = { ...item };
|
|
1307
|
+
const deviceId = rec["deviceId"];
|
|
1308
|
+
if (typeof deviceId !== "number" || !Number.isInteger(deviceId)) continue;
|
|
1309
|
+
const phase = isPhase(rec["phase"]) ? rec["phase"] : "open";
|
|
1310
|
+
const at = typeof rec["at"] === "number" ? rec["at"] : 0;
|
|
1311
|
+
out.push({
|
|
1312
|
+
deviceId,
|
|
1313
|
+
phase,
|
|
1314
|
+
at,
|
|
1315
|
+
announced: rec["announced"] === true
|
|
1316
|
+
});
|
|
1317
|
+
}
|
|
1318
|
+
this.entries = out;
|
|
1319
|
+
}
|
|
1320
|
+
};
|
|
1321
|
+
//#endregion
|
|
533
1322
|
//#region src/notification-center/alarm/alarm-state-machine.ts
|
|
534
1323
|
/**
|
|
535
1324
|
* Defaults an operator can change per panel. `home` has no exit delay on
|
|
@@ -585,6 +1374,19 @@ function exitDelayAt(m, now) {
|
|
|
585
1374
|
armedAt: m.armedAt
|
|
586
1375
|
};
|
|
587
1376
|
}
|
|
1377
|
+
/**
|
|
1378
|
+
* The entry delay running at `now`, or `undefined` when none is. Gated on
|
|
1379
|
+
* `stateAt`, like {@link exitDelayAt}: `triggerAt` survives on the machine
|
|
1380
|
+
* after the siren sounds, until a settle drops it.
|
|
1381
|
+
*/
|
|
1382
|
+
function pendingAt(m, now) {
|
|
1383
|
+
if (m.triggerAt === void 0) return void 0;
|
|
1384
|
+
if (stateAt(m, now) !== "pending") return void 0;
|
|
1385
|
+
return {
|
|
1386
|
+
target: m.target,
|
|
1387
|
+
triggerAt: m.triggerAt
|
|
1388
|
+
};
|
|
1389
|
+
}
|
|
588
1390
|
/** Whether the panel is armed enough for a trigger to matter. */
|
|
589
1391
|
function isArmed(m, now) {
|
|
590
1392
|
const s = stateAt(m, now);
|
|
@@ -677,512 +1479,6 @@ function settle(m, now) {
|
|
|
677
1479
|
};
|
|
678
1480
|
return m;
|
|
679
1481
|
}
|
|
680
|
-
//#endregion
|
|
681
|
-
//#region src/notification-center/alarm/alarm-mode-coverage.ts
|
|
682
|
-
/**
|
|
683
|
-
* Whether the panel just BECAME armed, in the sense a person means.
|
|
684
|
-
*
|
|
685
|
-
* Two transitions reach an `armed_*` state and only one of them is an arm:
|
|
686
|
-
*
|
|
687
|
-
* - `arming`/`disarmed` → `armed_away` is somebody arming the alarm. Announce.
|
|
688
|
-
* - `triggered` → `armed_away` is the siren's duration ending and the panel
|
|
689
|
-
* re-arming itself. Announcing it would send "Away armed" in the middle of
|
|
690
|
-
* a break-in, seconds after the alarm notification, saying nothing new.
|
|
691
|
-
*
|
|
692
|
-
* `armed_home` → `armed_away` IS an arm: the operator changed mode, and the
|
|
693
|
-
* set of devices covered just changed with it — which is the whole content of
|
|
694
|
-
* the message.
|
|
695
|
-
*
|
|
696
|
-
* A null `previous` is the FIRST publish and never announces. A hub restarting
|
|
697
|
-
* while armed re-publishes `armed_away`, and nobody armed anything — every
|
|
698
|
-
* deploy would otherwise send one. Handled here rather than by the caller
|
|
699
|
-
* happening to wire the hook late, because "it works because of the order two
|
|
700
|
-
* unrelated things run in" is how this stops working.
|
|
701
|
-
*/
|
|
702
|
-
function shouldAnnounceArm(previous, next) {
|
|
703
|
-
if (previous === null) return false;
|
|
704
|
-
if (!next.startsWith("armed_")) return false;
|
|
705
|
-
if (previous === next) return false;
|
|
706
|
-
if (previous === "triggered") return false;
|
|
707
|
-
return true;
|
|
708
|
-
}
|
|
709
|
-
/** The mode inside an `armed_<mode>` state, or null for any other state. */
|
|
710
|
-
function armModeOf(state, modes) {
|
|
711
|
-
for (const mode of modes) if (state === armedStateFor(mode)) return mode;
|
|
712
|
-
return null;
|
|
713
|
-
}
|
|
714
|
-
/** The `deviceState` word a rule must gate on for `mode` to cover it. */
|
|
715
|
-
function armedStateFor(mode) {
|
|
716
|
-
return `armed_${mode}`;
|
|
717
|
-
}
|
|
718
|
-
/**
|
|
719
|
-
* One entry per mode the panel offers, including modes nothing is gated on.
|
|
720
|
-
*
|
|
721
|
-
* Empty modes are KEPT rather than filtered: "Night arms nothing" is the single
|
|
722
|
-
* most useful thing this can tell an operator, and a list that omits it looks
|
|
723
|
-
* identical to a list where night is covered.
|
|
724
|
-
*/
|
|
725
|
-
function alarmModeCoverage(rules, panelDeviceId, modes, skipProbe) {
|
|
726
|
-
return modes.map((mode) => coverageFor(rules, panelDeviceId, mode, skipProbe));
|
|
727
|
-
}
|
|
728
|
-
function coverageFor(rules, panelDeviceId, mode, skipProbe) {
|
|
729
|
-
const wanted = armedStateFor(mode);
|
|
730
|
-
const ids = /* @__PURE__ */ new Set();
|
|
731
|
-
let ruleCount = 0;
|
|
732
|
-
let allDevices = false;
|
|
733
|
-
for (const rule of rules) {
|
|
734
|
-
if (!rule.enabled) continue;
|
|
735
|
-
const gate = rule.conditions.deviceState;
|
|
736
|
-
if (gate === void 0 || gate.deviceId !== panelDeviceId) continue;
|
|
737
|
-
if (!gate.states.includes(wanted)) continue;
|
|
738
|
-
ruleCount += 1;
|
|
739
|
-
const scope = rule.conditions.devices;
|
|
740
|
-
if (scope === void 0 || scope.length === 0) {
|
|
741
|
-
allDevices = true;
|
|
742
|
-
continue;
|
|
743
|
-
}
|
|
744
|
-
for (const id of scope) ids.add(id);
|
|
745
|
-
}
|
|
746
|
-
const deviceIds = [...ids].toSorted((a, b) => a - b);
|
|
747
|
-
return {
|
|
748
|
-
mode,
|
|
749
|
-
ruleCount,
|
|
750
|
-
allDevices,
|
|
751
|
-
deviceIds,
|
|
752
|
-
skippedDevices: skippedFor(deviceIds, skipProbe)
|
|
753
|
-
};
|
|
754
|
-
}
|
|
755
|
-
function skippedFor(deviceIds, skipProbe) {
|
|
756
|
-
if (skipProbe === void 0) return [];
|
|
757
|
-
const out = [];
|
|
758
|
-
for (const deviceId of deviceIds) {
|
|
759
|
-
const reason = skipProbe(deviceId);
|
|
760
|
-
if (reason !== void 0) out.push({
|
|
761
|
-
deviceId,
|
|
762
|
-
reason
|
|
763
|
-
});
|
|
764
|
-
}
|
|
765
|
-
return out;
|
|
766
|
-
}
|
|
767
|
-
/**
|
|
768
|
-
* Whether this mode arms anything, and if not, which way it fails.
|
|
769
|
-
*
|
|
770
|
-
* An `allDevices` mode is never a gap: `skippedDevices` only ever probes the
|
|
771
|
-
* ids a rule wrote down, so it cannot answer for a mode that covers every
|
|
772
|
-
* camera on the hub, and reporting "all excluded" from a subset would be a
|
|
773
|
-
* warning about protection that exists.
|
|
774
|
-
*/
|
|
775
|
-
function alarmCoverageGap(coverage) {
|
|
776
|
-
if (coverage.ruleCount === 0) return "uncovered";
|
|
777
|
-
if (coverage.allDevices) return null;
|
|
778
|
-
if (coverage.deviceIds.length === 0) return null;
|
|
779
|
-
if (coverage.skippedDevices.length < coverage.deviceIds.length) return null;
|
|
780
|
-
return "all-skipped";
|
|
781
|
-
}
|
|
782
|
-
/**
|
|
783
|
-
* "Attivi: Cancello, Garage — Esclusi: Porta retro."
|
|
784
|
-
*
|
|
785
|
-
* A LIST, not a sentence. The mode is in the title and the body is one
|
|
786
|
-
* `Label: names` line per device group, because this is read on a lock screen
|
|
787
|
-
* and the question is always "which one" — the prose version ("3 dispositivi
|
|
788
|
-
* attivi: …. Esclusi: ….") made the operator parse a paragraph to find a
|
|
789
|
-
* window. Each variant still owns its own catalog key, so the Texts tab can
|
|
790
|
-
* re-word "nothing is armed" without touching "these are armed".
|
|
791
|
-
*
|
|
792
|
-
* Names, not ids: this is read by a person standing at a door. An id that has
|
|
793
|
-
* no name falls back to a NAMED, translated placeholder rather than being
|
|
794
|
-
* dropped — a silently shorter list would understate what is armed, which is
|
|
795
|
-
* the one error this message must not make. (It used to hard-code the English
|
|
796
|
-
* `Device <id>` into both languages.)
|
|
797
|
-
*
|
|
798
|
-
* **Always a sentence.** It used to return null for a mode no rule is gated on,
|
|
799
|
-
* on the grounds that "Night armed" followed by an unwatched night is a false
|
|
800
|
-
* assurance — true, but the caller then emitted `alarm-armed` anyway with a
|
|
801
|
-
* fallback body key that existed in NEITHER locale, so the operator received a
|
|
802
|
-
* title and an empty line. Silence is not the third option here: SAYING that
|
|
803
|
-
* the mode arms nothing is.
|
|
804
|
-
*/
|
|
805
|
-
function buildArmAnnouncement(coverage, deviceName, bypassed = []) {
|
|
806
|
-
const rules = `${coverage.ruleCount}`;
|
|
807
|
-
const mode = coverage.mode;
|
|
808
|
-
if (bypassed.length > 0) return armedWithBypass(coverage, bypassed, deviceName);
|
|
809
|
-
const gap = alarmCoverageGap(coverage);
|
|
810
|
-
if (gap === "uncovered") return {
|
|
811
|
-
bodyKey: "alarm.arm.body.uncovered",
|
|
812
|
-
textParams: { mode },
|
|
813
|
-
textCount: 0
|
|
814
|
-
};
|
|
815
|
-
if (gap === "all-skipped") {
|
|
816
|
-
const names = coverage.skippedDevices.map((s) => deviceName(s.deviceId));
|
|
817
|
-
return {
|
|
818
|
-
bodyKey: "alarm.arm.body.allSkipped",
|
|
819
|
-
textParams: {
|
|
820
|
-
rules,
|
|
821
|
-
mode,
|
|
822
|
-
devices: `${names.length}`,
|
|
823
|
-
names: names.join(", ")
|
|
824
|
-
},
|
|
825
|
-
textCount: names.length
|
|
826
|
-
};
|
|
827
|
-
}
|
|
828
|
-
if (coverage.allDevices) return {
|
|
829
|
-
bodyKey: "alarm.arm.body.allDevices",
|
|
830
|
-
textParams: {
|
|
831
|
-
rules,
|
|
832
|
-
mode
|
|
833
|
-
},
|
|
834
|
-
textCount: coverage.ruleCount
|
|
835
|
-
};
|
|
836
|
-
if (coverage.deviceIds.length === 0) return {
|
|
837
|
-
bodyKey: "alarm.arm.body.noDevices",
|
|
838
|
-
textParams: {
|
|
839
|
-
rules,
|
|
840
|
-
mode
|
|
841
|
-
},
|
|
842
|
-
textCount: coverage.ruleCount
|
|
843
|
-
};
|
|
844
|
-
const skipped = new Set(coverage.skippedDevices.map((s) => s.deviceId));
|
|
845
|
-
const names = coverage.deviceIds.filter((id) => !skipped.has(id)).map(deviceName);
|
|
846
|
-
if (skipped.size === 0) return {
|
|
847
|
-
bodyKey: "alarm.arm.body.devices",
|
|
848
|
-
textParams: {
|
|
849
|
-
rules,
|
|
850
|
-
mode,
|
|
851
|
-
devices: `${names.length}`,
|
|
852
|
-
names: names.join(", ")
|
|
853
|
-
},
|
|
854
|
-
textCount: names.length
|
|
855
|
-
};
|
|
856
|
-
return {
|
|
857
|
-
bodyKey: "alarm.arm.body.devicesSkipped",
|
|
858
|
-
textParams: {
|
|
859
|
-
rules,
|
|
860
|
-
mode,
|
|
861
|
-
devices: `${names.length}`,
|
|
862
|
-
names: names.join(", "),
|
|
863
|
-
skippedNames: coverage.skippedDevices.map((s) => deviceName(s.deviceId)).join(", ")
|
|
864
|
-
},
|
|
865
|
-
textCount: names.length
|
|
866
|
-
};
|
|
867
|
-
}
|
|
868
|
-
/**
|
|
869
|
-
* "Attivi: 2 — Bypassati: Finestra veranda."
|
|
870
|
-
*
|
|
871
|
-
* The bypassed sensors are named and the armed ones are COUNTED, not listed:
|
|
872
|
-
* this sentence's subject is the exception, and burying it under thirteen
|
|
873
|
-
* camera names is how an operator stops reading the arm notification. The
|
|
874
|
-
* count pluralises on what IS armed, like every other arm variant.
|
|
875
|
-
*/
|
|
876
|
-
function armedWithBypass(coverage, bypassed, deviceName) {
|
|
877
|
-
const skipped = new Set(coverage.skippedDevices.map((s) => s.deviceId));
|
|
878
|
-
const excluded = new Set(bypassed);
|
|
879
|
-
const armed = coverage.allDevices ? coverage.ruleCount : coverage.deviceIds.filter((id) => !skipped.has(id) && !excluded.has(id)).length;
|
|
880
|
-
return {
|
|
881
|
-
bodyKey: "alarm.arm.body.bypassed",
|
|
882
|
-
textParams: {
|
|
883
|
-
rules: `${coverage.ruleCount}`,
|
|
884
|
-
mode: coverage.mode,
|
|
885
|
-
devices: `${armed}`,
|
|
886
|
-
bypassedNames: bypassed.map(deviceName).join(", ")
|
|
887
|
-
},
|
|
888
|
-
textCount: armed
|
|
889
|
-
};
|
|
890
|
-
}
|
|
891
|
-
/**
|
|
892
|
-
* "Aperti: Finestra veranda, Porta retro" + how to arm anyway.
|
|
893
|
-
*
|
|
894
|
-
* The one alarm sentence about something that did not happen, so it has to
|
|
895
|
-
* carry both halves an operator needs to act: WHICH sensors are open, and that
|
|
896
|
-
* pressing arm again arms without them. Naming them is the whole message — a
|
|
897
|
-
* refusal that says only "cannot arm" is a panel the operator will conclude is
|
|
898
|
-
* broken — so the names are the FIRST line and the retry hint is the second.
|
|
899
|
-
*/
|
|
900
|
-
function buildArmRefusedAnnouncement(mode, names, retrySeconds) {
|
|
901
|
-
return {
|
|
902
|
-
bodyKey: "alarm.armRefused.body",
|
|
903
|
-
textParams: {
|
|
904
|
-
mode,
|
|
905
|
-
devices: `${names.length}`,
|
|
906
|
-
names: names.join(", "),
|
|
907
|
-
seconds: `${retrySeconds}`
|
|
908
|
-
},
|
|
909
|
-
textCount: names.length
|
|
910
|
-
};
|
|
911
|
-
}
|
|
912
|
-
/**
|
|
913
|
-
* The EXCEPTIONS to the same coverage, said 30 seconds earlier.
|
|
914
|
-
*
|
|
915
|
-
* The exit-delay sentence is deliberately SHORTER than the arm one — it is read
|
|
916
|
-
* on the way out of a door — and it carries exactly one thing the arm sentence
|
|
917
|
-
* cannot: the warning arrives while the operator can still disarm and fix it.
|
|
918
|
-
* A mode that arms nothing is worth interrupting somebody for at second 0 and
|
|
919
|
-
* merely worth recording at second 30.
|
|
920
|
-
*
|
|
921
|
-
* **It never repeats the device list.** The countdown lives in the TITLE
|
|
922
|
-
* (`system.alarm-arming.title.<mode>` renders `{{seconds}}`), and the armed
|
|
923
|
-
* list arrives thirty seconds later in the arm announcement — printing it
|
|
924
|
-
* twice is the verbosity that made the operator stop reading both. So the body
|
|
925
|
-
* is what is WRONG: what was bypassed, what is excluded, or that nothing is.
|
|
926
|
-
*
|
|
927
|
-
* `seconds` stays in the params because the title asks for it, and `textCount`
|
|
928
|
-
* stays with it: a row enqueued before this deploy still pluralises on the
|
|
929
|
-
* countdown it was written for.
|
|
930
|
-
*/
|
|
931
|
-
function buildArmingAnnouncement(coverage, seconds, deviceName, bypassed = []) {
|
|
932
|
-
const textParams = {
|
|
933
|
-
mode: coverage.mode,
|
|
934
|
-
seconds: `${seconds}`
|
|
935
|
-
};
|
|
936
|
-
if (bypassed.length > 0) return {
|
|
937
|
-
bodyKey: "alarm.arming.body.bypassed",
|
|
938
|
-
textParams: {
|
|
939
|
-
...textParams,
|
|
940
|
-
bypassedNames: bypassed.map(deviceName).join(", ")
|
|
941
|
-
},
|
|
942
|
-
textCount: seconds
|
|
943
|
-
};
|
|
944
|
-
const skippedNames = coverage.skippedDevices.map((s) => deviceName(s.deviceId)).join(", ");
|
|
945
|
-
const gap = alarmCoverageGap(coverage);
|
|
946
|
-
if (gap === "uncovered") return {
|
|
947
|
-
bodyKey: "alarm.arming.body.uncovered",
|
|
948
|
-
textParams,
|
|
949
|
-
textCount: seconds
|
|
950
|
-
};
|
|
951
|
-
if (gap === "all-skipped") return {
|
|
952
|
-
bodyKey: "alarm.arming.body.allSkipped",
|
|
953
|
-
textParams: {
|
|
954
|
-
...textParams,
|
|
955
|
-
names: skippedNames
|
|
956
|
-
},
|
|
957
|
-
textCount: seconds
|
|
958
|
-
};
|
|
959
|
-
if (coverage.skippedDevices.length > 0) return {
|
|
960
|
-
bodyKey: "alarm.arming.body.skipped",
|
|
961
|
-
textParams: {
|
|
962
|
-
...textParams,
|
|
963
|
-
names: skippedNames
|
|
964
|
-
},
|
|
965
|
-
textCount: coverage.skippedDevices.length
|
|
966
|
-
};
|
|
967
|
-
return {
|
|
968
|
-
bodyKey: "alarm.arming.body",
|
|
969
|
-
textParams,
|
|
970
|
-
textCount: seconds
|
|
971
|
-
};
|
|
972
|
-
}
|
|
973
|
-
//#endregion
|
|
974
|
-
//#region src/notification-center/alarm/alarm-arm-guard.ts
|
|
975
|
-
/**
|
|
976
|
-
* How long a refusal stays confirmable.
|
|
977
|
-
*
|
|
978
|
-
* Long enough to read the notification and press again (the operator is
|
|
979
|
-
* standing at the door, phone in hand); short enough that it cannot be the
|
|
980
|
-
* state the panel is left in. Sixty seconds is also under the shortest exit
|
|
981
|
-
* delay this panel ships with, so a bypass can never outlive the arm it
|
|
982
|
-
* belongs to.
|
|
983
|
-
*/
|
|
984
|
-
var ARM_BYPASS_WINDOW_MS = 6e4;
|
|
985
|
-
/** The taxonomy kind a door/window contact persists its events under. */
|
|
986
|
-
var CONTACT_SENSOR_KIND = "contact";
|
|
987
|
-
/**
|
|
988
|
-
* The contact sensors a mode would arm, ascending and de-duplicated.
|
|
989
|
-
*
|
|
990
|
-
* A rule qualifies only when it is enabled, gated on THIS panel in THIS mode,
|
|
991
|
-
* and about contacts (`sensorKinds` naming `contact`). Two exclusions are
|
|
992
|
-
* deliberate and both are about refusing an arm the operator cannot fix:
|
|
993
|
-
*
|
|
994
|
-
* - a rule that names CAMERAS (`Intrusione — Fuori casa` names thirteen) is
|
|
995
|
-
* not a contact rule, and a camera is never open;
|
|
996
|
-
* - a contact rule with NO device scope covers every contact on the hub, and
|
|
997
|
-
* blocking on a set the operator never wrote down produces a refusal naming
|
|
998
|
-
* devices they never associated with the alarm. The caller says so in a log
|
|
999
|
-
* line rather than inventing the list.
|
|
1000
|
-
*/
|
|
1001
|
-
function alarmBlockingCandidates(rules, panelDeviceId, mode) {
|
|
1002
|
-
const wanted = armedStateFor(mode);
|
|
1003
|
-
const ids = /* @__PURE__ */ new Set();
|
|
1004
|
-
for (const rule of rules) {
|
|
1005
|
-
if (!rule.enabled) continue;
|
|
1006
|
-
const gate = rule.conditions.deviceState;
|
|
1007
|
-
if (gate === void 0 || gate.deviceId !== panelDeviceId) continue;
|
|
1008
|
-
if (!gate.states.includes(wanted)) continue;
|
|
1009
|
-
const kinds = rule.conditions.sensorKinds;
|
|
1010
|
-
if (kinds === void 0 || !kinds.includes(CONTACT_SENSOR_KIND)) continue;
|
|
1011
|
-
const scope = rule.conditions.devices;
|
|
1012
|
-
if (scope === void 0 || scope.length === 0) continue;
|
|
1013
|
-
for (const id of scope) ids.add(id);
|
|
1014
|
-
}
|
|
1015
|
-
return [...ids].toSorted((a, b) => a - b);
|
|
1016
|
-
}
|
|
1017
|
-
/**
|
|
1018
|
-
* Every contact any mode of this panel could block on — the set whose state is
|
|
1019
|
-
* worth keeping mirrored.
|
|
1020
|
-
*
|
|
1021
|
-
* Read on the rule-reload tick, so the arm path has a recent answer even when
|
|
1022
|
-
* the targeted read at arm time fails, and so an excluded sensor's close can be
|
|
1023
|
-
* noticed on a hub where the sensor is linked to no camera (and therefore emits
|
|
1024
|
-
* no device event at all — the live installation on 2026-08-13).
|
|
1025
|
-
*/
|
|
1026
|
-
function alarmContactWatchList(rules, panelDeviceId, modes) {
|
|
1027
|
-
const ids = /* @__PURE__ */ new Set();
|
|
1028
|
-
for (const mode of modes) for (const id of alarmBlockingCandidates(rules, panelDeviceId, mode)) ids.add(id);
|
|
1029
|
-
return [...ids].toSorted((a, b) => a - b);
|
|
1030
|
-
}
|
|
1031
|
-
/**
|
|
1032
|
-
* Refuse, bypass or proceed — the whole decision, as one comparison.
|
|
1033
|
-
*
|
|
1034
|
-
* The bypass requires the SAME mode. "Arm home" refused and "arm away" pressed
|
|
1035
|
-
* is a different intent about a different set of sensors, and letting one
|
|
1036
|
-
* confirm the other would arm a mode around a window nobody was told about.
|
|
1037
|
-
*
|
|
1038
|
-
* The bypassed set is what is open NOW, not what was open at the refusal: a
|
|
1039
|
-
* window shut between the two presses is armed with everything else, which is
|
|
1040
|
-
* the entire point of having refused.
|
|
1041
|
-
*/
|
|
1042
|
-
function decideArm(input) {
|
|
1043
|
-
const window = input.windowMs ?? 6e4;
|
|
1044
|
-
if (input.openContacts.length === 0) return { kind: "clear" };
|
|
1045
|
-
const previous = input.previous;
|
|
1046
|
-
if (previous !== null && previous.mode === input.mode && input.at - previous.at <= window) return {
|
|
1047
|
-
kind: "bypassed",
|
|
1048
|
-
bypassed: input.openContacts
|
|
1049
|
-
};
|
|
1050
|
-
return {
|
|
1051
|
-
kind: "refused",
|
|
1052
|
-
blocking: input.openContacts,
|
|
1053
|
-
retryUntil: input.at + window
|
|
1054
|
-
};
|
|
1055
|
-
}
|
|
1056
|
-
/**
|
|
1057
|
-
* The error `alarmPanel.arm` throws when it refuses.
|
|
1058
|
-
*
|
|
1059
|
-
* A THROW and not a return value, and the reason is a train: the hub's
|
|
1060
|
-
* generated router validates this method's response against the HOST's copy of
|
|
1061
|
-
* the cap, which says `z.void()`. An addon that answered a structured refusal
|
|
1062
|
-
* would have it rejected by the router in front of it — so the refusal reaches
|
|
1063
|
-
* the caller as the one channel that already works across that boundary, and
|
|
1064
|
-
* the operator's PHONE gets the sentence (`alarm-arm-refused`, through the
|
|
1065
|
-
* ordinary outbox) rather than a UI error being the only trace.
|
|
1066
|
-
*
|
|
1067
|
-
* The message is deliberately actionable and English (an exception message is
|
|
1068
|
-
* a developer/API surface; the notification is the operator's).
|
|
1069
|
-
*/
|
|
1070
|
-
var NcAlarmArmRefusedError = class extends Error {
|
|
1071
|
-
code = "alarm-arm-refused";
|
|
1072
|
-
mode;
|
|
1073
|
-
blocking;
|
|
1074
|
-
retryUntil;
|
|
1075
|
-
constructor(input) {
|
|
1076
|
-
const names = input.blocking.map((c) => c.name).join(", ");
|
|
1077
|
-
const seconds = Math.round((input.windowMs ?? 6e4) / 1e3);
|
|
1078
|
-
super(`arm refused — open: ${names}. Arm ${input.mode} again within ${String(seconds)}s to arm with them excluded.`);
|
|
1079
|
-
this.name = "NcAlarmArmRefusedError";
|
|
1080
|
-
this.mode = input.mode;
|
|
1081
|
-
this.blocking = input.blocking;
|
|
1082
|
-
this.retryUntil = input.retryUntil;
|
|
1083
|
-
}
|
|
1084
|
-
};
|
|
1085
|
-
//#endregion
|
|
1086
|
-
//#region src/notification-center/alarm/alarm-exclusions.ts
|
|
1087
|
-
function isPhase(value) {
|
|
1088
|
-
return value === "open" || value === "closed";
|
|
1089
|
-
}
|
|
1090
|
-
var NcAlarmExclusions = class {
|
|
1091
|
-
entries = [];
|
|
1092
|
-
/** Replace the whole set — one arm, one set of exclusions. */
|
|
1093
|
-
exclude(deviceIds, at) {
|
|
1094
|
-
this.entries = [...new Set(deviceIds)].map((deviceId) => ({
|
|
1095
|
-
deviceId,
|
|
1096
|
-
phase: "open",
|
|
1097
|
-
at
|
|
1098
|
-
}));
|
|
1099
|
-
}
|
|
1100
|
-
/** Every excluded sensor, in the order it was excluded. */
|
|
1101
|
-
list() {
|
|
1102
|
-
return this.entries.map((e) => e.deviceId);
|
|
1103
|
-
}
|
|
1104
|
-
/** True while this sensor's events must not reach the panel. */
|
|
1105
|
-
blocks(deviceId) {
|
|
1106
|
-
return this.entries.some((e) => e.deviceId === deviceId);
|
|
1107
|
-
}
|
|
1108
|
-
/**
|
|
1109
|
-
* Feed one contact reading, and say what it CHANGED.
|
|
1110
|
-
*
|
|
1111
|
-
* Three answers, not two, because the caller has to persist on both of the
|
|
1112
|
-
* changes: the first `alarm-arm-refusal.spec.ts` run of this feature saved
|
|
1113
|
-
* only the re-arm, so a hub redeployed between the close and the re-open came
|
|
1114
|
-
* back with the sensor still in phase `open` — and the exclusion then needed
|
|
1115
|
-
* a SECOND close to lift. A phase that lives only in RAM is half a durable
|
|
1116
|
-
* exclusion.
|
|
1117
|
-
*
|
|
1118
|
-
* An unreadable state changes nothing (D49): a failed read must never lift an
|
|
1119
|
-
* exclusion the operator asked for, and must never swallow the close that
|
|
1120
|
-
* would have.
|
|
1121
|
-
*/
|
|
1122
|
-
observe(deviceId, reading, _at) {
|
|
1123
|
-
if (reading === void 0) return "unchanged";
|
|
1124
|
-
const current = this.entries.find((e) => e.deviceId === deviceId);
|
|
1125
|
-
if (current === void 0) return "unchanged";
|
|
1126
|
-
if (reading === "closed") {
|
|
1127
|
-
if (current.phase === "closed") return "unchanged";
|
|
1128
|
-
this.entries = this.entries.map((e) => e.deviceId === deviceId ? {
|
|
1129
|
-
...e,
|
|
1130
|
-
phase: "closed"
|
|
1131
|
-
} : e);
|
|
1132
|
-
return "closed";
|
|
1133
|
-
}
|
|
1134
|
-
if (current.phase === "open") return "unchanged";
|
|
1135
|
-
this.entries = this.entries.filter((e) => e.deviceId !== deviceId);
|
|
1136
|
-
return "rearmed";
|
|
1137
|
-
}
|
|
1138
|
-
/** The arm this belongs to is over. */
|
|
1139
|
-
clear() {
|
|
1140
|
-
this.entries = [];
|
|
1141
|
-
}
|
|
1142
|
-
/** What to persist. Plain data — it rides the panel's config blob. */
|
|
1143
|
-
snapshot() {
|
|
1144
|
-
return this.entries.map((e) => ({
|
|
1145
|
-
deviceId: e.deviceId,
|
|
1146
|
-
phase: e.phase,
|
|
1147
|
-
at: e.at
|
|
1148
|
-
}));
|
|
1149
|
-
}
|
|
1150
|
-
/**
|
|
1151
|
-
* Rebuild from the persisted blob — a GUARD, never a cast.
|
|
1152
|
-
*
|
|
1153
|
-
* Anything unrecognised restores NOTHING, which is the failure that shows
|
|
1154
|
-
* itself (a bypassed sensor that fires) rather than the one that hides (a
|
|
1155
|
-
* sensor excluded forever by a blob nobody can read). A bare id with no phase
|
|
1156
|
-
* restores as still-OPEN: it needs a close before it can re-arm, which is the
|
|
1157
|
-
* half that cannot cause a false alarm.
|
|
1158
|
-
*/
|
|
1159
|
-
restore(raw) {
|
|
1160
|
-
if (!Array.isArray(raw)) return;
|
|
1161
|
-
const out = [];
|
|
1162
|
-
for (const item of raw) {
|
|
1163
|
-
if (typeof item === "number" && Number.isInteger(item)) {
|
|
1164
|
-
out.push({
|
|
1165
|
-
deviceId: item,
|
|
1166
|
-
phase: "open",
|
|
1167
|
-
at: 0
|
|
1168
|
-
});
|
|
1169
|
-
continue;
|
|
1170
|
-
}
|
|
1171
|
-
if (item === null || typeof item !== "object") continue;
|
|
1172
|
-
const rec = { ...item };
|
|
1173
|
-
const deviceId = rec["deviceId"];
|
|
1174
|
-
if (typeof deviceId !== "number" || !Number.isInteger(deviceId)) continue;
|
|
1175
|
-
const phase = isPhase(rec["phase"]) ? rec["phase"] : "open";
|
|
1176
|
-
const at = typeof rec["at"] === "number" ? rec["at"] : 0;
|
|
1177
|
-
out.push({
|
|
1178
|
-
deviceId,
|
|
1179
|
-
phase,
|
|
1180
|
-
at
|
|
1181
|
-
});
|
|
1182
|
-
}
|
|
1183
|
-
this.entries = out;
|
|
1184
|
-
}
|
|
1185
|
-
};
|
|
1186
1482
|
/**
|
|
1187
1483
|
* WHO the panel's current countdown belongs to.
|
|
1188
1484
|
*
|
|
@@ -1281,6 +1577,18 @@ function alarmTransition(input) {
|
|
|
1281
1577
|
...input.source !== void 0 ? { source: input.source } : {}
|
|
1282
1578
|
};
|
|
1283
1579
|
if (next === "arming") return armingTransition(previous, input);
|
|
1580
|
+
if (next === "pending") return pendingTransition(previous, input);
|
|
1581
|
+
if (previous === "triggered" && next.startsWith("armed_")) {
|
|
1582
|
+
const mode = armModeOf(next, input.modes);
|
|
1583
|
+
if (mode === null) return null;
|
|
1584
|
+
return {
|
|
1585
|
+
kind: "alarm-rearmed",
|
|
1586
|
+
state: next,
|
|
1587
|
+
previous,
|
|
1588
|
+
mode,
|
|
1589
|
+
at
|
|
1590
|
+
};
|
|
1591
|
+
}
|
|
1284
1592
|
if (shouldAnnounceArm(previous, next)) {
|
|
1285
1593
|
const mode = armModeOf(next, input.modes);
|
|
1286
1594
|
if (mode === null) return null;
|
|
@@ -1323,6 +1631,27 @@ function armingTransition(previous, input) {
|
|
|
1323
1631
|
at: input.at
|
|
1324
1632
|
};
|
|
1325
1633
|
}
|
|
1634
|
+
/**
|
|
1635
|
+
* The entry delay, as one notification at its START — the mirror of
|
|
1636
|
+
* {@link armingTransition}. Null when the panel did not say which mode the
|
|
1637
|
+
* countdown belongs to and when it ends: "Intrusion — s to disarm" is not a
|
|
1638
|
+
* sentence to send.
|
|
1639
|
+
*/
|
|
1640
|
+
function pendingTransition(previous, input) {
|
|
1641
|
+
const pending = input.pending;
|
|
1642
|
+
if (pending === void 0) return null;
|
|
1643
|
+
const mode = armModeOf(pending.target, input.modes);
|
|
1644
|
+
if (mode === null) return null;
|
|
1645
|
+
return {
|
|
1646
|
+
kind: "alarm-pending",
|
|
1647
|
+
state: "pending",
|
|
1648
|
+
previous,
|
|
1649
|
+
mode,
|
|
1650
|
+
seconds: Math.max(0, Math.ceil((pending.triggerAt - input.at) / 1e3)),
|
|
1651
|
+
at: input.at,
|
|
1652
|
+
...input.source !== void 0 ? { source: input.source } : {}
|
|
1653
|
+
};
|
|
1654
|
+
}
|
|
1326
1655
|
//#endregion
|
|
1327
1656
|
//#region src/notification-center/alarm/alarm-panel-device.ts
|
|
1328
1657
|
/**
|
|
@@ -1365,22 +1694,70 @@ var ncAlarmPanelSchema = object({
|
|
|
1365
1694
|
exitDelaySec: number().int().min(0).max(600).default(DEFAULT_DELAYS.exitDelaySec),
|
|
1366
1695
|
entryDelaySec: number().int().min(0).max(600).default(DEFAULT_DELAYS.entryDelaySec),
|
|
1367
1696
|
triggeredDurationSec: number().int().min(0).max(3600).default(DEFAULT_DELAYS.triggeredDurationSec),
|
|
1368
|
-
/**
|
|
1697
|
+
/**
|
|
1698
|
+
* VESTIGIAL (D630): nothing reads it to decide anything any more — every
|
|
1699
|
+
* transition is announced to `announceTargets`. Kept so a persisted blob
|
|
1700
|
+
* and the settings cap still round-trip it, and read once at boot to tell
|
|
1701
|
+
* an operator who had it OFF that their targets now hear every transition.
|
|
1702
|
+
*/
|
|
1369
1703
|
announceArm: boolean().default(false),
|
|
1370
|
-
/** Target ids
|
|
1704
|
+
/** Target ids EVERY alarm announcement goes to — see `NcAlarmSettingsSchema`. */
|
|
1371
1705
|
announceTargets: array(string()).default([]),
|
|
1372
1706
|
machine: record(string(), unknown()).optional(),
|
|
1373
1707
|
/**
|
|
1374
|
-
*
|
|
1375
|
-
*
|
|
1708
|
+
* Devices the CURRENT arm is ignoring because they were open when it armed
|
|
1709
|
+
* (tolerated by the mode's non-blocking list, or confirmed with Arm anyway),
|
|
1710
|
+
* and how far each is through its two-phase lift.
|
|
1376
1711
|
*
|
|
1377
1712
|
* Persisted for the same reason the machine is: a deploy in the middle of an
|
|
1378
1713
|
* armed night would otherwise silently re-arm every sensor the operator
|
|
1379
1714
|
* agreed to leave out, and they would learn about it from the siren. Read
|
|
1380
1715
|
* back through a guard, never a cast — see `NcAlarmExclusions.restore`.
|
|
1381
1716
|
*/
|
|
1382
|
-
exclusions: array(unknown()).default([])
|
|
1717
|
+
exclusions: array(unknown()).default([]),
|
|
1718
|
+
/**
|
|
1719
|
+
* Per arm mode, the covered devices whose being open does not refuse the arm
|
|
1720
|
+
* (D630). Loose on purpose — `mode` is a string here — and read back through
|
|
1721
|
+
* {@link nonBlockingFromPersisted}, never a cast: a blob written by a later
|
|
1722
|
+
* release with a mode this one does not know must drop that entry, not the
|
|
1723
|
+
* whole config.
|
|
1724
|
+
*/
|
|
1725
|
+
nonBlocking: array(object({
|
|
1726
|
+
mode: string(),
|
|
1727
|
+
deviceIds: array(number().int())
|
|
1728
|
+
})).default([])
|
|
1383
1729
|
});
|
|
1730
|
+
/**
|
|
1731
|
+
* The persisted non-blocking lists, narrowed to modes that exist, first entry
|
|
1732
|
+
* per mode kept — the same "at most one list per mode" the settings schema
|
|
1733
|
+
* enforces at the boundary. A type guard (`safeParse`), never a cast.
|
|
1734
|
+
*/
|
|
1735
|
+
function nonBlockingFromPersisted(raw) {
|
|
1736
|
+
const seen = /* @__PURE__ */ new Set();
|
|
1737
|
+
const lists = [];
|
|
1738
|
+
for (const entry of raw) {
|
|
1739
|
+
const mode = AlarmArmModeSchema.safeParse(entry.mode);
|
|
1740
|
+
if (!mode.success || seen.has(mode.data)) continue;
|
|
1741
|
+
seen.add(mode.data);
|
|
1742
|
+
lists.push({
|
|
1743
|
+
mode: mode.data,
|
|
1744
|
+
deviceIds: [...entry.deviceIds]
|
|
1745
|
+
});
|
|
1746
|
+
}
|
|
1747
|
+
return lists;
|
|
1748
|
+
}
|
|
1749
|
+
/**
|
|
1750
|
+
* The log line for a trigger the state machine ignored, per state. `trigger`
|
|
1751
|
+
* ignores four: disarmed, arming (the exit delay), and pending / triggered
|
|
1752
|
+
* (already counting down or going off — a second sensor must not restart it).
|
|
1753
|
+
*/
|
|
1754
|
+
function ignoredTriggerMessage(state) {
|
|
1755
|
+
if (state === "arming") return "alarm trigger ignored — the panel is still arming (exit delay)";
|
|
1756
|
+
if (state === "pending") return "alarm trigger ignored — the entry delay is already running";
|
|
1757
|
+
if (state === "triggered") return "alarm trigger ignored — the alarm is already going off";
|
|
1758
|
+
if (state === "disarmed") return "alarm trigger ignored — the panel is disarmed";
|
|
1759
|
+
return "alarm trigger ignored — the panel cannot be triggered in this state";
|
|
1760
|
+
}
|
|
1384
1761
|
/** `armed_home` and friends — the only targets a machine may settle into. */
|
|
1385
1762
|
function isArmTarget(value) {
|
|
1386
1763
|
return value === "disarmed" || typeof value === "string" && value.startsWith("armed_");
|
|
@@ -1434,20 +1811,26 @@ var NcAlarmPanelDevice = class extends BaseDevice {
|
|
|
1434
1811
|
/** Who answers "is anything open" — null until the centre wires itself in. */
|
|
1435
1812
|
armGuard = null;
|
|
1436
1813
|
/**
|
|
1437
|
-
* The
|
|
1814
|
+
* The LATEST refusal — the only one whose Arm anyway can still arm.
|
|
1438
1815
|
*
|
|
1439
1816
|
* In RAM and only in RAM, deliberately: it is a confirmation of something the
|
|
1440
|
-
* operator read
|
|
1817
|
+
* operator read minutes ago, and a confirmation that survived a restart would
|
|
1441
1818
|
* let a process respawn arm the alarm around an open window nobody re-read.
|
|
1442
|
-
* Losing it costs one extra
|
|
1443
|
-
|
|
1444
|
-
|
|
1819
|
+
* Losing it costs one extra tap. Nothing else clears it: a disarm or an arm
|
|
1820
|
+
* moves `machine.changedAt`, and `armAnyway` then finds it stale.
|
|
1821
|
+
*/
|
|
1822
|
+
refusal = null;
|
|
1823
|
+
/** The refusal whose Arm anyway was pressed last — a second press is `used`. */
|
|
1824
|
+
spentRefusalId = null;
|
|
1825
|
+
/** Makes refusal ids unique inside one millisecond. */
|
|
1826
|
+
refusalSeq = 0;
|
|
1445
1827
|
constructor(ctx) {
|
|
1446
1828
|
super(ctx, ncAlarmPanelSchema, { type: DeviceType.AlarmPanel });
|
|
1447
1829
|
this.delays = this.readDelays();
|
|
1448
1830
|
this.restore();
|
|
1449
1831
|
this.registerAlarmCap();
|
|
1450
1832
|
this.publish();
|
|
1833
|
+
if (this.config.get("announceArm") === false && this.config.get("announceTargets").length > 0) this.ctx.logger.info("alarm announcements now cover every transition (D630) — the announceArm switch is gone", { tags: { deviceId: this.id } });
|
|
1451
1834
|
}
|
|
1452
1835
|
/** The operator-visible configuration, as the cap serves it. */
|
|
1453
1836
|
settings() {
|
|
@@ -1455,7 +1838,7 @@ var NcAlarmPanelDevice = class extends BaseDevice {
|
|
|
1455
1838
|
...this.readDelays(),
|
|
1456
1839
|
announceArm: this.config.get("announceArm"),
|
|
1457
1840
|
announceTargets: [...this.config.get("announceTargets")],
|
|
1458
|
-
nonBlocking:
|
|
1841
|
+
nonBlocking: nonBlockingFromPersisted(this.config.get("nonBlocking"))
|
|
1459
1842
|
};
|
|
1460
1843
|
}
|
|
1461
1844
|
/**
|
|
@@ -1472,6 +1855,10 @@ var NcAlarmPanelDevice = class extends BaseDevice {
|
|
|
1472
1855
|
if (patch.triggeredDurationSec !== void 0) await this.config.set("triggeredDurationSec", patch.triggeredDurationSec);
|
|
1473
1856
|
if (patch.announceArm !== void 0) await this.config.set("announceArm", patch.announceArm);
|
|
1474
1857
|
if (patch.announceTargets !== void 0) await this.config.set("announceTargets", [...patch.announceTargets]);
|
|
1858
|
+
if (patch.nonBlocking !== void 0) await this.config.set("nonBlocking", patch.nonBlocking.map((l) => ({
|
|
1859
|
+
mode: l.mode,
|
|
1860
|
+
deviceIds: [...l.deviceIds]
|
|
1861
|
+
})));
|
|
1475
1862
|
this.delays = this.readDelays();
|
|
1476
1863
|
return this.settings();
|
|
1477
1864
|
}
|
|
@@ -1500,11 +1887,106 @@ var NcAlarmPanelDevice = class extends BaseDevice {
|
|
|
1500
1887
|
currentState(now = Date.now()) {
|
|
1501
1888
|
return stateAt(this.machine, now);
|
|
1502
1889
|
}
|
|
1503
|
-
/**
|
|
1890
|
+
/** The mode the machine belongs to — see {@link NcAlarmPublishReport.target}. */
|
|
1891
|
+
currentTarget() {
|
|
1892
|
+
return this.machine.target;
|
|
1893
|
+
}
|
|
1894
|
+
/** Wire the openings check and the refusal / check-failed announcements. */
|
|
1504
1895
|
setArmGuard(hook) {
|
|
1505
1896
|
this.armGuard = hook;
|
|
1506
1897
|
}
|
|
1507
1898
|
/**
|
|
1899
|
+
* A plain arm. Refused on any open covered device the mode does not tolerate
|
|
1900
|
+
* — pressed once or ten times, the answer is the same; the only way past a
|
|
1901
|
+
* refusal is {@link armAnyway} with that refusal's id.
|
|
1902
|
+
*
|
|
1903
|
+
* When the openings could not be read at all it arms (fail-open, announced
|
|
1904
|
+
* as a check failure) and EXCLUDES NOTHING NEW: the previous exclusions are
|
|
1905
|
+
* left as they were, since there is no reading to replace them with. An
|
|
1906
|
+
* `unknown` device neither blocks nor is excluded — nobody was shown it.
|
|
1907
|
+
*/
|
|
1908
|
+
async armWith(mode) {
|
|
1909
|
+
const now = Date.now();
|
|
1910
|
+
const read = await this.applyArmGuard(mode);
|
|
1911
|
+
if (read.kind === "unguarded") return this.settleArm(mode, null, now);
|
|
1912
|
+
if (read.kind === "check-failed") {
|
|
1913
|
+
const outcome = this.settleArm(mode, null, now);
|
|
1914
|
+
this.announceUncheckedArm(mode, read.error, now);
|
|
1915
|
+
return outcome;
|
|
1916
|
+
}
|
|
1917
|
+
if (read.verdict.kind === "refused") return this.refuse(mode, read.verdict.blocking, now);
|
|
1918
|
+
return this.settleArm(mode, read.verdict.excluded.map((device) => ({
|
|
1919
|
+
device,
|
|
1920
|
+
why: "non-blocking"
|
|
1921
|
+
})), now);
|
|
1922
|
+
}
|
|
1923
|
+
/**
|
|
1924
|
+
* The Arm anyway on ONE refusal's notification — the only path that arms
|
|
1925
|
+
* around a device that blocks.
|
|
1926
|
+
*
|
|
1927
|
+
* 1. no held refusal, or a different one (a later refusal replaced it) ⇒
|
|
1928
|
+
* `unknown-refusal`; the id of the refusal spent last ⇒ `used`;
|
|
1929
|
+
* 2. the panel changed state since ⇒ `stale`, and the refusal is dropped;
|
|
1930
|
+
* 3. older than {@link ARM_ANYWAY_TTL_MS} ⇒ `expired`, and it is dropped;
|
|
1931
|
+
* 4. otherwise the openings are READ AGAIN. A device that opened since is a
|
|
1932
|
+
* NEW refusal (new id, new notification) listing EVERY device that
|
|
1933
|
+
* blocks now — the still-open confirmed ones too, so its own Arm anyway
|
|
1934
|
+
* can arm around all of them, and the confirmed ones the re-read reads
|
|
1935
|
+
* UNKNOWN (so that Arm anyway still excludes them). Otherwise the arm excludes what is open
|
|
1936
|
+
* now; a refused device that closed meanwhile is armed normally. A
|
|
1937
|
+
* confirmed device the re-read cannot vouch for — the read failed, or
|
|
1938
|
+
* it reads unknown — stays EXCLUDED (D49: unknown is never secure).
|
|
1939
|
+
* 5. the refusal is spent on every outcome of step 4.
|
|
1940
|
+
*/
|
|
1941
|
+
async armAnyway(refusalId) {
|
|
1942
|
+
const now = Date.now();
|
|
1943
|
+
const held = this.refusal;
|
|
1944
|
+
if (held === null || held.id !== refusalId) {
|
|
1945
|
+
const reason = refusalId === this.spentRefusalId ? "used" : "unknown-refusal";
|
|
1946
|
+
return this.rejectArmAnyway(refusalId, reason);
|
|
1947
|
+
}
|
|
1948
|
+
if (this.machine.changedAt !== held.panelChangedAt) {
|
|
1949
|
+
this.refusal = null;
|
|
1950
|
+
return this.rejectArmAnyway(refusalId, "stale");
|
|
1951
|
+
}
|
|
1952
|
+
if (now - held.at > 3e5) {
|
|
1953
|
+
this.refusal = null;
|
|
1954
|
+
return this.rejectArmAnyway(refusalId, "expired");
|
|
1955
|
+
}
|
|
1956
|
+
this.refusal = null;
|
|
1957
|
+
this.spentRefusalId = held.id;
|
|
1958
|
+
const read = await this.applyArmGuard(held.mode, held.blockingIds);
|
|
1959
|
+
if (this.machine.changedAt !== held.panelChangedAt) return this.rejectArmAnyway(refusalId, "stale");
|
|
1960
|
+
if (read.kind !== "read") {
|
|
1961
|
+
const outcome = this.settleArm(held.mode, held.blocking.map((device) => ({
|
|
1962
|
+
device,
|
|
1963
|
+
why: "arm-anyway-unverified"
|
|
1964
|
+
})), now);
|
|
1965
|
+
if (read.kind === "check-failed") this.announceUncheckedArm(held.mode, read.error, now);
|
|
1966
|
+
return outcome;
|
|
1967
|
+
}
|
|
1968
|
+
const tolerated = this.nonBlockingFor(held.mode);
|
|
1969
|
+
const unknown = new Set(read.openings.unknown);
|
|
1970
|
+
if (read.verdict.kind === "refused") {
|
|
1971
|
+
const plain = decideArm({
|
|
1972
|
+
mode: held.mode,
|
|
1973
|
+
open: read.openings.open,
|
|
1974
|
+
nonBlocking: tolerated
|
|
1975
|
+
});
|
|
1976
|
+
const blockingNow = plain.kind === "refused" ? plain.blocking : read.verdict.blocking;
|
|
1977
|
+
const shownIds = new Set(blockingNow.map((d) => d.deviceId));
|
|
1978
|
+
const blocking = [...blockingNow, ...held.blocking.filter((d) => unknown.has(d.deviceId) && !shownIds.has(d.deviceId))];
|
|
1979
|
+
return this.refuse(held.mode, blocking, now);
|
|
1980
|
+
}
|
|
1981
|
+
return this.settleArm(held.mode, [...read.verdict.excluded.map((device) => ({
|
|
1982
|
+
device,
|
|
1983
|
+
why: tolerated.includes(device.deviceId) ? "non-blocking" : "arm-anyway"
|
|
1984
|
+
})), ...held.blocking.filter((device) => unknown.has(device.deviceId)).map((device) => ({
|
|
1985
|
+
device,
|
|
1986
|
+
why: "arm-anyway-unknown"
|
|
1987
|
+
}))], now);
|
|
1988
|
+
}
|
|
1989
|
+
/**
|
|
1508
1990
|
* Sensors the current arm is ignoring — what the armed/arming sentence names
|
|
1509
1991
|
* as excluded, and the only place that list is published.
|
|
1510
1992
|
*/
|
|
@@ -1512,26 +1994,54 @@ var NcAlarmPanelDevice = class extends BaseDevice {
|
|
|
1512
1994
|
return this.exclusions.list();
|
|
1513
1995
|
}
|
|
1514
1996
|
/**
|
|
1515
|
-
*
|
|
1516
|
-
*
|
|
1997
|
+
* The same exclusions, each with its phase — `open` while the device is
|
|
1998
|
+
* still open as it was when armed, `closed` once it has been secured and
|
|
1999
|
+
* its next open re-arms it. What the live view shows; a read, never a copy.
|
|
2000
|
+
*/
|
|
2001
|
+
exclusionPhases() {
|
|
2002
|
+
return this.exclusions.snapshot().map((e) => ({
|
|
2003
|
+
deviceId: e.deviceId,
|
|
2004
|
+
phase: e.phase
|
|
2005
|
+
}));
|
|
2006
|
+
}
|
|
2007
|
+
/**
|
|
2008
|
+
* Disarm — the ONE path, for the cap and the `nc.disarmAlarm` bridge alike.
|
|
2009
|
+
* The exclusions belong to the ARM, and this one is over: keeping them would
|
|
2010
|
+
* carry an open window's exemption into tonight's arm, which nobody
|
|
2011
|
+
* confirmed.
|
|
2012
|
+
*/
|
|
2013
|
+
async disarm() {
|
|
2014
|
+
this.machine = disarm(Date.now());
|
|
2015
|
+
this.attribution.clear();
|
|
2016
|
+
this.exclusions.clear();
|
|
2017
|
+
this.publish();
|
|
2018
|
+
}
|
|
2019
|
+
/**
|
|
2020
|
+
* One opening's reading (`open` / `secure`, D630's vocabulary — a cover is
|
|
2021
|
+
* secure only when `closed`, a lock only when `locked`), from whoever saw
|
|
2022
|
+
* it: the sensor's own event, or the centre's reconcile tick.
|
|
1517
2023
|
*
|
|
1518
2024
|
* The lift is two-phase (`alarm-exclusions.ts`), so this is safe to call with
|
|
1519
2025
|
* the same reading repeatedly, and an unreadable state changes nothing. Fed
|
|
1520
2026
|
* BEFORE the event that carried it is evaluated, so the open that re-arms a
|
|
1521
2027
|
* sensor is already lifted when the rule it fires reaches the panel.
|
|
1522
2028
|
*/
|
|
1523
|
-
|
|
2029
|
+
observeOpening(deviceId, reading, now = Date.now()) {
|
|
1524
2030
|
const change = this.exclusions.observe(deviceId, reading, now);
|
|
1525
2031
|
if (change === "unchanged") return;
|
|
1526
2032
|
this.persist();
|
|
1527
2033
|
if (change === "closed") return;
|
|
1528
|
-
this.ctx.logger.info("alarm exclusion lifted — the
|
|
2034
|
+
this.ctx.logger.info("alarm exclusion lifted — the device was secured and opened again", {
|
|
1529
2035
|
tags: { deviceId },
|
|
1530
2036
|
meta: {
|
|
1531
2037
|
panelDeviceId: this.id,
|
|
1532
2038
|
remaining: this.exclusions.list().length
|
|
1533
2039
|
}
|
|
1534
2040
|
});
|
|
2041
|
+
this.announceBypass({
|
|
2042
|
+
deviceId,
|
|
2043
|
+
event: "lifted"
|
|
2044
|
+
}, now);
|
|
1535
2045
|
}
|
|
1536
2046
|
/** Stop the countdown when the device goes away. The state itself is
|
|
1537
2047
|
* persisted, so nothing is lost — only the announcements stop. */
|
|
@@ -1539,108 +2049,166 @@ var NcAlarmPanelDevice = class extends BaseDevice {
|
|
|
1539
2049
|
this.stopTicking();
|
|
1540
2050
|
}
|
|
1541
2051
|
/**
|
|
1542
|
-
*
|
|
1543
|
-
*
|
|
2052
|
+
* Ask the centre what is open and decide — or say why there is nothing to
|
|
2053
|
+
* decide with.
|
|
1544
2054
|
*
|
|
1545
2055
|
* Fail-OPEN by construction, in three places, and each one is a decision:
|
|
1546
2056
|
*
|
|
1547
2057
|
* - no guard wired (an agent node, a boot pass before the centre exists) ⇒
|
|
1548
|
-
* arm. A panel that cannot ask must not refuse.
|
|
1549
|
-
* - the
|
|
1550
|
-
*
|
|
1551
|
-
*
|
|
1552
|
-
*
|
|
1553
|
-
* -
|
|
1554
|
-
*
|
|
1555
|
-
*
|
|
1556
|
-
*
|
|
1557
|
-
|
|
1558
|
-
|
|
1559
|
-
async applyArmGuard(mode, now) {
|
|
2058
|
+
* `unguarded`, arm. A panel that cannot ask must not refuse.
|
|
2059
|
+
* - the read threw (nothing could be read) ⇒ `check-failed`, arm, and SAY
|
|
2060
|
+
* so — a WARN here and, once the arm has LANDED, `announceCheckFailed` on
|
|
2061
|
+
* the operator's phone. Refusing on a failed read would send the operator
|
|
2062
|
+
* to shut a window that is already shut.
|
|
2063
|
+
* - a covered device whose state is UNKNOWN ⇒ never blocks and is never
|
|
2064
|
+
* counted secure (D49); the guard logs one line each, under its own id.
|
|
2065
|
+
*
|
|
2066
|
+
* `acknowledged` is what an Arm anyway confirms — the ids its refusal SHOWED.
|
|
2067
|
+
*/
|
|
2068
|
+
async applyArmGuard(mode, acknowledged) {
|
|
1560
2069
|
const guard = this.armGuard;
|
|
1561
|
-
if (guard === null) return;
|
|
1562
|
-
let
|
|
2070
|
+
if (guard === null) return { kind: "unguarded" };
|
|
2071
|
+
let openings;
|
|
1563
2072
|
try {
|
|
1564
|
-
|
|
2073
|
+
openings = await guard.openings(mode);
|
|
1565
2074
|
} catch (err) {
|
|
1566
|
-
this.ctx.logger.warn("alarm arm guard could not read the
|
|
2075
|
+
this.ctx.logger.warn("alarm arm guard could not read the openings — arming without the check", {
|
|
1567
2076
|
tags: { deviceId: this.id },
|
|
1568
2077
|
meta: {
|
|
1569
2078
|
mode,
|
|
1570
2079
|
error: String(err)
|
|
1571
2080
|
}
|
|
1572
2081
|
});
|
|
1573
|
-
return
|
|
2082
|
+
return {
|
|
2083
|
+
kind: "check-failed",
|
|
2084
|
+
error: String(err)
|
|
2085
|
+
};
|
|
1574
2086
|
}
|
|
1575
2087
|
const verdict = decideArm({
|
|
1576
2088
|
mode,
|
|
1577
|
-
|
|
1578
|
-
|
|
1579
|
-
|
|
2089
|
+
open: openings.open,
|
|
2090
|
+
nonBlocking: this.nonBlockingFor(mode),
|
|
2091
|
+
...acknowledged !== void 0 ? { acknowledged } : {}
|
|
1580
2092
|
});
|
|
1581
|
-
|
|
1582
|
-
|
|
1583
|
-
|
|
1584
|
-
|
|
1585
|
-
|
|
1586
|
-
|
|
1587
|
-
|
|
1588
|
-
|
|
1589
|
-
|
|
1590
|
-
|
|
1591
|
-
|
|
2093
|
+
return {
|
|
2094
|
+
kind: "read",
|
|
2095
|
+
openings,
|
|
2096
|
+
verdict
|
|
2097
|
+
};
|
|
2098
|
+
}
|
|
2099
|
+
/**
|
|
2100
|
+
* The operator's copy of "armed WITHOUT the openings check" — called only
|
|
2101
|
+
* after {@link settleArm}, with the state the panel is really in now. Never
|
|
2102
|
+
* on a path that armed nothing: a stale Arm anyway whose re-read also failed
|
|
2103
|
+
* used to push "Armed … without checking" for an arm that never happened.
|
|
2104
|
+
*/
|
|
2105
|
+
announceUncheckedArm(mode, error, now) {
|
|
2106
|
+
this.armGuard?.announceCheckFailed({
|
|
2107
|
+
mode,
|
|
2108
|
+
state: stateAt(this.machine, now),
|
|
2109
|
+
at: now,
|
|
2110
|
+
error
|
|
2111
|
+
});
|
|
2112
|
+
}
|
|
2113
|
+
/** This mode's non-blocking ids, from the persisted lists. */
|
|
2114
|
+
nonBlockingFor(mode) {
|
|
2115
|
+
return nonBlockingFromPersisted(this.config.get("nonBlocking")).find((l) => l.mode === mode)?.deviceIds ?? [];
|
|
2116
|
+
}
|
|
2117
|
+
/**
|
|
2118
|
+
* Arm, with these exclusions.
|
|
2119
|
+
*
|
|
2120
|
+
* `null` exclusions (no guard, or nothing could be read on a plain arm) arm
|
|
2121
|
+
* and leave the exclusions as they were — there is no reading to replace them with.
|
|
2122
|
+
*/
|
|
2123
|
+
settleArm(mode, exclusions, now) {
|
|
2124
|
+
const excluded = (exclusions ?? []).map((e) => e.device);
|
|
2125
|
+
if (exclusions !== null) {
|
|
2126
|
+
if (excluded.length > 0) this.exclusions.exclude(excluded.map((d) => d.deviceId), now);
|
|
2127
|
+
else this.exclusions.clear();
|
|
1592
2128
|
this.persist();
|
|
1593
|
-
for (const contact of verdict.bypassed) this.ctx.logger.info("alarm armed with an open sensor excluded", {
|
|
1594
|
-
tags: { deviceId: contact.deviceId },
|
|
1595
|
-
meta: {
|
|
1596
|
-
panelDeviceId: this.id,
|
|
1597
|
-
mode,
|
|
1598
|
-
name: contact.name
|
|
1599
|
-
}
|
|
1600
|
-
});
|
|
1601
|
-
return;
|
|
1602
2129
|
}
|
|
1603
|
-
this.
|
|
2130
|
+
this.machine = arm(mode, this.delays, now);
|
|
2131
|
+
this.attribution.clear();
|
|
2132
|
+
this.publish(now);
|
|
2133
|
+
for (const { device, why } of exclusions ?? []) this.ctx.logger.info("alarm armed with an open device excluded", {
|
|
2134
|
+
tags: { deviceId: device.deviceId },
|
|
2135
|
+
meta: {
|
|
2136
|
+
panelDeviceId: this.id,
|
|
2137
|
+
mode,
|
|
2138
|
+
name: device.name,
|
|
2139
|
+
state: device.state,
|
|
2140
|
+
why
|
|
2141
|
+
}
|
|
2142
|
+
});
|
|
2143
|
+
return {
|
|
2144
|
+
outcome: stateAt(this.machine, now) === "arming" ? "arming" : "armed",
|
|
2145
|
+
excluded
|
|
2146
|
+
};
|
|
2147
|
+
}
|
|
2148
|
+
/**
|
|
2149
|
+
* Record the refusal, say it per device, and hand it to the operator's phone.
|
|
2150
|
+
*
|
|
2151
|
+
* The record REPLACES any earlier one — only the latest refusal's Arm anyway
|
|
2152
|
+
* can arm (an older notification's button is superseded).
|
|
2153
|
+
*/
|
|
2154
|
+
refuse(mode, blocking, now) {
|
|
2155
|
+
this.refusalSeq += 1;
|
|
2156
|
+
const refusal = {
|
|
2157
|
+
id: `refusal:${String(this.id)}:${mode}:${String(now)}:${String(this.refusalSeq)}`,
|
|
1604
2158
|
mode,
|
|
1605
|
-
|
|
2159
|
+
blockingIds: blocking.map((d) => d.deviceId),
|
|
2160
|
+
blocking,
|
|
2161
|
+
at: now,
|
|
2162
|
+
panelChangedAt: this.machine.changedAt
|
|
1606
2163
|
};
|
|
1607
|
-
|
|
1608
|
-
|
|
2164
|
+
this.refusal = refusal;
|
|
2165
|
+
for (const d of blocking) this.ctx.logger.warn("alarm arm REFUSED — this device is open", {
|
|
2166
|
+
tags: { deviceId: d.deviceId },
|
|
1609
2167
|
meta: {
|
|
1610
2168
|
panelDeviceId: this.id,
|
|
1611
2169
|
mode,
|
|
1612
|
-
name:
|
|
2170
|
+
name: d.name,
|
|
2171
|
+
state: d.state,
|
|
2172
|
+
refusalId: refusal.id
|
|
1613
2173
|
}
|
|
1614
2174
|
});
|
|
1615
|
-
|
|
1616
|
-
|
|
1617
|
-
blocking
|
|
1618
|
-
|
|
2175
|
+
this.armGuard?.announceRefusal({
|
|
2176
|
+
refusal,
|
|
2177
|
+
blocking,
|
|
2178
|
+
state: stateAt(this.machine, now),
|
|
1619
2179
|
at: now
|
|
1620
2180
|
});
|
|
1621
|
-
|
|
1622
|
-
|
|
1623
|
-
|
|
1624
|
-
|
|
2181
|
+
return {
|
|
2182
|
+
outcome: "refused",
|
|
2183
|
+
refusalId: refusal.id,
|
|
2184
|
+
blocking
|
|
2185
|
+
};
|
|
2186
|
+
}
|
|
2187
|
+
/** A pressed Arm anyway that did nothing — refused work, so it says so. */
|
|
2188
|
+
rejectArmAnyway(refusalId, reason) {
|
|
2189
|
+
this.ctx.logger.info("alarm Arm anyway rejected — nothing was armed", {
|
|
2190
|
+
tags: { deviceId: this.id },
|
|
2191
|
+
meta: {
|
|
2192
|
+
refusalId,
|
|
2193
|
+
reason
|
|
2194
|
+
}
|
|
1625
2195
|
});
|
|
2196
|
+
return {
|
|
2197
|
+
outcome: "rejected",
|
|
2198
|
+
reason
|
|
2199
|
+
};
|
|
1626
2200
|
}
|
|
1627
2201
|
registerAlarmCap() {
|
|
1628
2202
|
this.ctx.registerNativeCap(alarmPanelCapability, {
|
|
1629
2203
|
getStatus: async () => this.status(),
|
|
1630
2204
|
arm: async ({ mode }) => {
|
|
1631
|
-
const
|
|
1632
|
-
|
|
1633
|
-
|
|
1634
|
-
|
|
1635
|
-
|
|
1636
|
-
},
|
|
1637
|
-
disarm: async () => {
|
|
1638
|
-
this.machine = disarm(Date.now());
|
|
1639
|
-
this.attribution.clear();
|
|
1640
|
-
this.exclusions.clear();
|
|
1641
|
-
this.lastRefusal = null;
|
|
1642
|
-
this.publish();
|
|
2205
|
+
const outcome = await this.armWith(mode);
|
|
2206
|
+
if (outcome.outcome === "refused") throw new NcAlarmArmRefusedError({
|
|
2207
|
+
mode,
|
|
2208
|
+
blocking: outcome.blocking
|
|
2209
|
+
});
|
|
1643
2210
|
},
|
|
2211
|
+
disarm: async () => this.disarm(),
|
|
1644
2212
|
trigger: async () => {
|
|
1645
2213
|
const now = Date.now();
|
|
1646
2214
|
const pending = this.attribution.pending(now);
|
|
@@ -1656,13 +2224,15 @@ var NcAlarmPanelDevice = class extends BaseDevice {
|
|
|
1656
2224
|
state: stateAt(this.machine, now)
|
|
1657
2225
|
}
|
|
1658
2226
|
});
|
|
2227
|
+
this.announceIgnoredTrigger(source, now);
|
|
1659
2228
|
return;
|
|
1660
2229
|
}
|
|
1661
2230
|
const next = trigger(this.machine, this.delays, now);
|
|
1662
2231
|
if (next === this.machine) {
|
|
1663
|
-
|
|
2232
|
+
const state = stateAt(this.machine, now);
|
|
2233
|
+
this.ctx.logger.info(ignoredTriggerMessage(state), {
|
|
1664
2234
|
tags: { deviceId: this.id },
|
|
1665
|
-
meta: { state
|
|
2235
|
+
meta: { state }
|
|
1666
2236
|
});
|
|
1667
2237
|
return;
|
|
1668
2238
|
}
|
|
@@ -1718,18 +2288,21 @@ var NcAlarmPanelDevice = class extends BaseDevice {
|
|
|
1718
2288
|
if (hook === null) return;
|
|
1719
2289
|
const source = this.attribution.current();
|
|
1720
2290
|
const arming = exitDelayAt(this.machine, now);
|
|
2291
|
+
const pending = pendingAt(this.machine, now);
|
|
1721
2292
|
const transition = alarmTransition({
|
|
1722
2293
|
previous,
|
|
1723
2294
|
next: state,
|
|
1724
2295
|
modes: NC_ALARM_MODES,
|
|
1725
2296
|
at: now,
|
|
1726
2297
|
...source !== null ? { source } : {},
|
|
1727
|
-
...arming !== void 0 ? { arming } : {}
|
|
2298
|
+
...arming !== void 0 ? { arming } : {},
|
|
2299
|
+
...pending !== void 0 ? { pending } : {}
|
|
1728
2300
|
});
|
|
1729
2301
|
if (transition?.kind === "alarm-triggered") this.attribution.clear();
|
|
1730
2302
|
try {
|
|
1731
2303
|
hook({
|
|
1732
2304
|
state,
|
|
2305
|
+
target: this.machine.target,
|
|
1733
2306
|
transition
|
|
1734
2307
|
});
|
|
1735
2308
|
} catch (err) {
|
|
@@ -1743,6 +2316,70 @@ var NcAlarmPanelDevice = class extends BaseDevice {
|
|
|
1743
2316
|
});
|
|
1744
2317
|
}
|
|
1745
2318
|
}
|
|
2319
|
+
/**
|
|
2320
|
+
* A dropped trigger is TOLD, not only logged — an intrusion the alarm
|
|
2321
|
+
* deliberately did not act on is what the operator most needs to hear — but
|
|
2322
|
+
* ONCE per sensor per arm (the mark rides the persisted exclusion), and only
|
|
2323
|
+
* while the panel is armed: during the exit delay `trigger()` would have
|
|
2324
|
+
* dropped it anyway, so the exclusion is not what ignored it.
|
|
2325
|
+
*/
|
|
2326
|
+
announceIgnoredTrigger(deviceId, now) {
|
|
2327
|
+
if (!isArmed(this.machine, now)) {
|
|
2328
|
+
this.ctx.logger.debug("alarm bypass not announced — the panel is not armed yet", {
|
|
2329
|
+
tags: { deviceId },
|
|
2330
|
+
meta: {
|
|
2331
|
+
panelDeviceId: this.id,
|
|
2332
|
+
state: stateAt(this.machine, now)
|
|
2333
|
+
}
|
|
2334
|
+
});
|
|
2335
|
+
return;
|
|
2336
|
+
}
|
|
2337
|
+
if (!this.exclusions.markAnnounced(deviceId)) {
|
|
2338
|
+
this.ctx.logger.debug("alarm bypass already announced in this arm — repeat not sent", {
|
|
2339
|
+
tags: { deviceId },
|
|
2340
|
+
meta: { panelDeviceId: this.id }
|
|
2341
|
+
});
|
|
2342
|
+
return;
|
|
2343
|
+
}
|
|
2344
|
+
this.persist();
|
|
2345
|
+
this.announceBypass({
|
|
2346
|
+
deviceId,
|
|
2347
|
+
event: "ignored"
|
|
2348
|
+
}, now);
|
|
2349
|
+
}
|
|
2350
|
+
/**
|
|
2351
|
+
* Report a sensor the current arm ignored, or stopped ignoring, through the
|
|
2352
|
+
* publish hook — a report whose state is UNCHANGED, so `lastPublished` is
|
|
2353
|
+
* not touched and the next real transition is still computed from it.
|
|
2354
|
+
*/
|
|
2355
|
+
announceBypass(sensor, now) {
|
|
2356
|
+
const hook = this.onPublish;
|
|
2357
|
+
if (hook === null) return;
|
|
2358
|
+
const state = stateAt(this.machine, now);
|
|
2359
|
+
const transition = {
|
|
2360
|
+
kind: "alarm-sensor-bypass",
|
|
2361
|
+
state,
|
|
2362
|
+
previous: state,
|
|
2363
|
+
at: now,
|
|
2364
|
+
sensor
|
|
2365
|
+
};
|
|
2366
|
+
try {
|
|
2367
|
+
hook({
|
|
2368
|
+
state,
|
|
2369
|
+
target: this.machine.target,
|
|
2370
|
+
transition
|
|
2371
|
+
});
|
|
2372
|
+
} catch (err) {
|
|
2373
|
+
this.ctx.logger.warn("alarm publish hook failed", {
|
|
2374
|
+
tags: { deviceId: this.id },
|
|
2375
|
+
meta: {
|
|
2376
|
+
state,
|
|
2377
|
+
transition: transition.kind,
|
|
2378
|
+
error: String(err)
|
|
2379
|
+
}
|
|
2380
|
+
});
|
|
2381
|
+
}
|
|
2382
|
+
}
|
|
1746
2383
|
/** The three durations, read from the persisted config. */
|
|
1747
2384
|
readDelays() {
|
|
1748
2385
|
return {
|
|
@@ -1805,6 +2442,25 @@ var NC_ALARM_DECLARATION = {
|
|
|
1805
2442
|
DeviceClass: NcAlarmPanelDevice
|
|
1806
2443
|
};
|
|
1807
2444
|
//#endregion
|
|
2445
|
+
//#region src/notification-center/alarm/alarm-panel-port.ts
|
|
2446
|
+
function ncAlarmPanelPort(panel) {
|
|
2447
|
+
return {
|
|
2448
|
+
deviceId: panel.id,
|
|
2449
|
+
settings: () => panel.settings(),
|
|
2450
|
+
applySettings: (patch) => panel.applySettings(patch),
|
|
2451
|
+
availableModes: () => NC_ALARM_MODES,
|
|
2452
|
+
noteRuleTrigger: (source) => panel.noteRuleTrigger(source),
|
|
2453
|
+
excludedDevices: () => panel.excludedDevices(),
|
|
2454
|
+
exclusionPhases: () => panel.exclusionPhases(),
|
|
2455
|
+
currentState: () => panel.currentState(),
|
|
2456
|
+
currentTarget: () => panel.currentTarget(),
|
|
2457
|
+
armWith: (mode) => panel.armWith(mode),
|
|
2458
|
+
armAnyway: (refusalId) => panel.armAnyway(refusalId),
|
|
2459
|
+
disarm: () => panel.disarm(),
|
|
2460
|
+
observeOpening: (deviceId, reading, now) => panel.observeOpening(deviceId, reading, now)
|
|
2461
|
+
};
|
|
2462
|
+
}
|
|
2463
|
+
//#endregion
|
|
1808
2464
|
//#region src/notification-center/artifact-index.ts
|
|
1809
2465
|
/**
|
|
1810
2466
|
* @durable class=ledger owner=notification-center
|
|
@@ -7104,10 +7760,10 @@ function replaceConditionReferences(monitor, condition, reference) {
|
|
|
7104
7760
|
//#endregion
|
|
7105
7761
|
//#region src/notification-center/snooze-exempt.ts
|
|
7106
7762
|
/** The cap whose presence in a sequence makes the rule the alarm's trigger. */
|
|
7107
|
-
var ALARM_PANEL_CAP$
|
|
7763
|
+
var ALARM_PANEL_CAP$2 = "alarm-panel";
|
|
7108
7764
|
var ALARM_KINDS = new Set(NC_ALARM_SYSTEM_EVENT_KINDS);
|
|
7109
7765
|
function actuatesAlarmPanel(sequences) {
|
|
7110
|
-
for (const sequence of sequences) for (const step of sequence.actions) if (step.kind === "cap" && step.cap === ALARM_PANEL_CAP$
|
|
7766
|
+
for (const sequence of sequences) for (const step of sequence.actions) if (step.kind === "cap" && step.cap === ALARM_PANEL_CAP$2) return true;
|
|
7111
7767
|
return false;
|
|
7112
7768
|
}
|
|
7113
7769
|
/**
|
|
@@ -7258,10 +7914,11 @@ var MAX_ARM_BUTTONS = 3;
|
|
|
7258
7914
|
*
|
|
7259
7915
|
* | notification | buttons |
|
|
7260
7916
|
* | --- | --- |
|
|
7261
|
-
* | `alarm-arming` | **Disarma**, destructive |
|
|
7262
|
-
* | `alarm-triggered` | **Disarma**, destructive |
|
|
7263
|
-
* | `alarm-
|
|
7264
|
-
* | `alarm-
|
|
7917
|
+
* | `alarm-arming`, `alarm-armed`, `alarm-pending` | **Disarma**, destructive |
|
|
7918
|
+
* | `alarm-triggered`, `alarm-rearmed`, `alarm-check-failed` | **Disarma**, destructive |
|
|
7919
|
+
* | `alarm-disarmed` | **Arma …** per declared (enabled) mode, max three |
|
|
7920
|
+
* | `alarm-arm-refused` | **Arma comunque**, bound to the row's refusal |
|
|
7921
|
+
* | `alarm-sensor-bypass` | nothing — it reports, there is nothing to undo |
|
|
7265
7922
|
*
|
|
7266
7923
|
* The `alarm-arming` row is the one that earns its keep: that notification
|
|
7267
7924
|
* arrives at the START of the exit delay, so its Disarm is the only alarm
|
|
@@ -7276,10 +7933,15 @@ var MAX_ARM_BUTTONS = 3;
|
|
|
7276
7933
|
* browser. The disarm is `navigate`: it removes a protection, and the operator
|
|
7277
7934
|
* doing it deserves to SEE that it happened rather than trust a POST nobody
|
|
7278
7935
|
* confirmed.
|
|
7936
|
+
*
|
|
7937
|
+
* `panel.availableModes` ARE the enabled modes: CamStack has no per-mode
|
|
7938
|
+
* disable setting, so the Arm buttons offer exactly what the panel declares.
|
|
7279
7939
|
*/
|
|
7280
7940
|
function buildAlarmButtons(input) {
|
|
7281
7941
|
const panel = input.panel;
|
|
7282
7942
|
if (panel === null) return [];
|
|
7943
|
+
if (input.kind === "alarm-sensor-bypass") return [];
|
|
7944
|
+
if (input.kind === "alarm-arm-refused") return armAnywayButton(input.refusalId, input);
|
|
7283
7945
|
if (input.kind === "alarm-disarmed") return panel.availableModes.slice(0, MAX_ARM_BUTTONS).map((mode) => {
|
|
7284
7946
|
const actionId = `alarm-arm-${mode}`;
|
|
7285
7947
|
return {
|
|
@@ -7307,6 +7969,28 @@ function buildAlarmButtons(input) {
|
|
|
7307
7969
|
mode: "navigate"
|
|
7308
7970
|
}];
|
|
7309
7971
|
}
|
|
7972
|
+
/**
|
|
7973
|
+
* The one Arm anyway a refusal notification carries — or none, when the row
|
|
7974
|
+
* names no refusal. `navigate` and destructive, like the disarm: it arms
|
|
7975
|
+
* AROUND an open door, and the person doing it should see it happen. Its TTL
|
|
7976
|
+
* is the refusal's own (`ARM_ANYWAY_TTL_MS`, priced by the minter).
|
|
7977
|
+
*/
|
|
7978
|
+
function armAnywayButton(refusalId, input) {
|
|
7979
|
+
if (refusalId === void 0) return [];
|
|
7980
|
+
const actionId = "alarm-arm-anyway";
|
|
7981
|
+
return [{
|
|
7982
|
+
id: actionId,
|
|
7983
|
+
label: input.labels.armAnyway,
|
|
7984
|
+
url: input.mintUrl({
|
|
7985
|
+
actionId,
|
|
7986
|
+
action: "arm-anyway",
|
|
7987
|
+
refusalId
|
|
7988
|
+
}),
|
|
7989
|
+
icon: "arm",
|
|
7990
|
+
destructive: true,
|
|
7991
|
+
mode: "navigate"
|
|
7992
|
+
}];
|
|
7993
|
+
}
|
|
7310
7994
|
//#endregion
|
|
7311
7995
|
//#region src/notification-center/addon-update-apply.ts
|
|
7312
7996
|
/**
|
|
@@ -7369,6 +8053,80 @@ function buildAddonUpdateButtons(input) {
|
|
|
7369
8053
|
}];
|
|
7370
8054
|
}
|
|
7371
8055
|
//#endregion
|
|
8056
|
+
//#region src/notification-center/alarm/alarm-gate-words.ts
|
|
8057
|
+
/** The states that belong to an armed mode without being it. */
|
|
8058
|
+
var INCIDENT_STATES = new Set(["pending", "triggered"]);
|
|
8059
|
+
/** `[state]`, plus `target` while `state` is `pending`/`triggered` of an armed mode. */
|
|
8060
|
+
function alarmGateWords(state, target) {
|
|
8061
|
+
if (INCIDENT_STATES.has(state) && target.startsWith("armed_")) return [state, target];
|
|
8062
|
+
return [state];
|
|
8063
|
+
}
|
|
8064
|
+
/**
|
|
8065
|
+
* The engine's lookup: every device answers its one mirrored word, and the
|
|
8066
|
+
* panel answers its gate words — but only while the mirror AGREES on the state.
|
|
8067
|
+
*
|
|
8068
|
+
* The mirror keeps the freshness policy (an expired or cold entry is
|
|
8069
|
+
* `undefined`, which fails the gate closed — D49); the alias only widens an
|
|
8070
|
+
* answer the mirror already gives. A target paired with a state it was not
|
|
8071
|
+
* published with would be a guess, so a disagreement answers the mirror alone.
|
|
8072
|
+
*/
|
|
8073
|
+
function panelAwareLookup(read, panel) {
|
|
8074
|
+
return (deviceId) => {
|
|
8075
|
+
const state = read(deviceId);
|
|
8076
|
+
if (state === void 0) return void 0;
|
|
8077
|
+
if (panel !== null && panel.deviceId === deviceId && sameWord(panel.words[0], state)) return panel.words;
|
|
8078
|
+
return [state];
|
|
8079
|
+
};
|
|
8080
|
+
}
|
|
8081
|
+
function sameWord(a, b) {
|
|
8082
|
+
return a !== void 0 && a.trim().toLowerCase() === b.trim().toLowerCase();
|
|
8083
|
+
}
|
|
8084
|
+
//#endregion
|
|
8085
|
+
//#region src/notification-center/alarm/alarm-live.ts
|
|
8086
|
+
/** Every covered id across all modes, ascending, once — what the one read asks for. */
|
|
8087
|
+
function alarmLiveReadIds(covered) {
|
|
8088
|
+
const ids = /* @__PURE__ */ new Set();
|
|
8089
|
+
for (const list of covered.values()) for (const id of list) ids.add(id);
|
|
8090
|
+
return [...ids].toSorted((a, b) => a - b);
|
|
8091
|
+
}
|
|
8092
|
+
function modeOpenings(input, mode) {
|
|
8093
|
+
const tolerated = new Set(input.nonBlocking.find((l) => l.mode === mode)?.deviceIds ?? []);
|
|
8094
|
+
const devices = [];
|
|
8095
|
+
const unknown = [];
|
|
8096
|
+
for (const deviceId of input.covered.get(mode) ?? []) {
|
|
8097
|
+
const verdict = classifyCoveredOpening(input.openings.get(deviceId));
|
|
8098
|
+
if (verdict.kind === "unknown") {
|
|
8099
|
+
unknown.push(deviceId);
|
|
8100
|
+
continue;
|
|
8101
|
+
}
|
|
8102
|
+
if (verdict.kind === "skip") continue;
|
|
8103
|
+
devices.push({
|
|
8104
|
+
deviceId,
|
|
8105
|
+
name: input.name(deviceId),
|
|
8106
|
+
state: verdict.state,
|
|
8107
|
+
blocking: !tolerated.has(deviceId)
|
|
8108
|
+
});
|
|
8109
|
+
}
|
|
8110
|
+
return {
|
|
8111
|
+
mode,
|
|
8112
|
+
devices,
|
|
8113
|
+
unknown
|
|
8114
|
+
};
|
|
8115
|
+
}
|
|
8116
|
+
function buildAlarmLive(input) {
|
|
8117
|
+
return {
|
|
8118
|
+
state: input.state,
|
|
8119
|
+
targetMode: armModeOf(input.target, input.modes),
|
|
8120
|
+
availableModes: [...input.modes],
|
|
8121
|
+
openings: input.modes.map((mode) => modeOpenings(input, mode)),
|
|
8122
|
+
exclusions: input.exclusions.map((e) => ({
|
|
8123
|
+
deviceId: e.deviceId,
|
|
8124
|
+
name: input.name(e.deviceId),
|
|
8125
|
+
phase: e.phase
|
|
8126
|
+
}))
|
|
8127
|
+
};
|
|
8128
|
+
}
|
|
8129
|
+
//#endregion
|
|
7372
8130
|
//#region src/notification-center/alerts/alert-store.ts
|
|
7373
8131
|
/**
|
|
7374
8132
|
* @durable class=ledger owner=notification-center
|
|
@@ -8063,6 +8821,52 @@ function maskPolygonOverlap(mask, maskWidth, maskHeight, bbox, polygon, _frameWi
|
|
|
8063
8821
|
return insidePolygon / totalMaskPixels;
|
|
8064
8822
|
}
|
|
8065
8823
|
//#endregion
|
|
8824
|
+
//#region src/notification-center/alarm/alarm-opening-edge.ts
|
|
8825
|
+
/**
|
|
8826
|
+
* Which way an OPENING moved, read off the slice one sensor event carries
|
|
8827
|
+
* (D630 "Alarm rules fire on the opening edge").
|
|
8828
|
+
*
|
|
8829
|
+
* A rule that belongs to the alarm fires when an opening OPENS — a contact
|
|
8830
|
+
* opens, a cover leaves closed, a lock unlocks — and never when it closes.
|
|
8831
|
+
*
|
|
8832
|
+
* Stateless on purpose: one state change is N SensorEvent rows (one per linked
|
|
8833
|
+
* camera), and a "previous reading" tracker would call the first row an edge
|
|
8834
|
+
* and the other N−1 steady — camera 2's alarm rule would lose the event. The
|
|
8835
|
+
* slice already says which way the device went, and the transition gate
|
|
8836
|
+
* upstream (`sensor-transition-gate.ts`) only lets a row through when the level
|
|
8837
|
+
* (the boolean, or a cover's state WORD) actually changed.
|
|
8838
|
+
*
|
|
8839
|
+
* What is open and what is secure is NOT decided here: it is
|
|
8840
|
+
* {@link alarmOpeningFromSensorEvent}'s table, the same one the arm check
|
|
8841
|
+
* reads. This file adds only the DIRECTION of a word that table calls open:
|
|
8842
|
+
* a word travelling towards secure (`closing`, `locking`) is a closing edge,
|
|
8843
|
+
* a halt or a fault (`stopped`, `jammed`) is `neither` — not an entry, so an
|
|
8844
|
+
* alarm rule does not fire on it, though the arm check still counts it as open
|
|
8845
|
+
* (a different question: "not secure" vs "just opened").
|
|
8846
|
+
*
|
|
8847
|
+
* `undefined` = this kind has no opening meaning, or the slice is unreadable
|
|
8848
|
+
* (D49 — never guessed). The caller then does not filter at all.
|
|
8849
|
+
*/
|
|
8850
|
+
/** Open words (per the openings table) that are travelling towards secure. */
|
|
8851
|
+
var TOWARD_SECURE = new Set(["closing", "locking"]);
|
|
8852
|
+
/** Open words that are a halt or a fault: neither an entry nor a close. */
|
|
8853
|
+
var HALTED = new Set(["stopped", "jammed"]);
|
|
8854
|
+
/** Open words that ARE the opening: leaving closed / leaving locked, or a contact apart. */
|
|
8855
|
+
var OPENING = new Set([
|
|
8856
|
+
"open",
|
|
8857
|
+
"opening",
|
|
8858
|
+
"unlocked",
|
|
8859
|
+
"unlocking"
|
|
8860
|
+
]);
|
|
8861
|
+
function sensorEdgeOf(kind, value) {
|
|
8862
|
+
const opening = alarmOpeningFromSensorEvent(kind, value);
|
|
8863
|
+
if (opening.kind !== "reading") return void 0;
|
|
8864
|
+
if (opening.reading === "secure") return "closing";
|
|
8865
|
+
if (TOWARD_SECURE.has(opening.state)) return "closing";
|
|
8866
|
+
if (HALTED.has(opening.state)) return "neither";
|
|
8867
|
+
if (OPENING.has(opening.state)) return "opening";
|
|
8868
|
+
}
|
|
8869
|
+
//#endregion
|
|
8066
8870
|
//#region src/notification-center/audio-rule-matcher.ts
|
|
8067
8871
|
/**
|
|
8068
8872
|
* AudioWatcher — the Notification Center's matcher for audio rules
|
|
@@ -8538,6 +9342,7 @@ function readSensorEventType(value) {
|
|
|
8538
9342
|
*/
|
|
8539
9343
|
function subjectFromSensorEvent(ev, markerTrackId) {
|
|
8540
9344
|
const eventType = readSensorEventType(ev.value);
|
|
9345
|
+
const sensorEdge = sensorEdgeOf(ev.kind, ev.value);
|
|
8541
9346
|
return {
|
|
8542
9347
|
kind: "device-event",
|
|
8543
9348
|
recordId: String(ev.id),
|
|
@@ -8549,7 +9354,20 @@ function subjectFromSensorEvent(ev, markerTrackId) {
|
|
|
8549
9354
|
zones: [],
|
|
8550
9355
|
source: "sensor",
|
|
8551
9356
|
sensorKind: ev.kind,
|
|
8552
|
-
...eventType !== void 0 ? { eventType } : {}
|
|
9357
|
+
...eventType !== void 0 ? { eventType } : {},
|
|
9358
|
+
...sensorEdge !== void 0 ? { sensorEdge } : {}
|
|
9359
|
+
};
|
|
9360
|
+
}
|
|
9361
|
+
/**
|
|
9362
|
+
* Build the subject for a sensor no camera takes a row for (D630 §6): the
|
|
9363
|
+
* sensor is its own device (`deviceId === sourceDeviceId`, so no
|
|
9364
|
+
* `sourceDeviceId` field) and there is no marker track — nothing owns a frame
|
|
9365
|
+
* of it. `linkedCameraIds` carries every camera linking it (see the field).
|
|
9366
|
+
*/
|
|
9367
|
+
function subjectFromUnownedSensorEvent(ev, linkedCameraIds) {
|
|
9368
|
+
return {
|
|
9369
|
+
...subjectFromSensorEvent(ev),
|
|
9370
|
+
linkedCameraIds: [...linkedCameraIds]
|
|
8553
9371
|
};
|
|
8554
9372
|
}
|
|
8555
9373
|
/**
|
|
@@ -8895,6 +9713,37 @@ function matchesIdentityScope(subject, scope) {
|
|
|
8895
9713
|
function ruleAwaitsRecognition(rule) {
|
|
8896
9714
|
return rule.conditions.identities !== void 0 || rule.conditions.plates !== void 0;
|
|
8897
9715
|
}
|
|
9716
|
+
var ARMED_WORD_PREFIX = "armed_";
|
|
9717
|
+
var ALARM_PANEL_CAP$1 = "alarm-panel";
|
|
9718
|
+
var ALARM_PANEL_TRIGGER_METHOD = "trigger";
|
|
9719
|
+
/**
|
|
9720
|
+
* A rule "belongs to the alarm" when its `deviceState` gate names ANY device's
|
|
9721
|
+
* `armed_*` word — CamStack's own panel or any other, e.g. an HA-mirrored
|
|
9722
|
+
* alarm panel — or when an ENABLED `onTrigger` sequence pulls the panel's
|
|
9723
|
+
* trigger itself. Such a rule fires on an opening's OPENING edge only: closing
|
|
9724
|
+
* a window while armed is not an intrusion (D630).
|
|
9725
|
+
*
|
|
9726
|
+
* This is NOT the arm check's coverage predicate (`alarm-mode-coverage.ts`):
|
|
9727
|
+
* it ignores which device the gate names, which mode, whether the rule is
|
|
9728
|
+
* enabled and what it is scoped to. It answers only "should this rule refuse
|
|
9729
|
+
* a closing edge", and erring wide there costs a closing notification nobody
|
|
9730
|
+
* gated on an armed word would have wanted anyway.
|
|
9731
|
+
*
|
|
9732
|
+
* Derived, never a stored flag: both rule editors prune condition keys to the
|
|
9733
|
+
* catalog on save, so a new key would be dropped by the first edit.
|
|
9734
|
+
*/
|
|
9735
|
+
function ruleBelongsToAlarm(rule) {
|
|
9736
|
+
if ((rule.conditions.deviceState?.states ?? []).some((s) => s.trim().toLowerCase().startsWith(ARMED_WORD_PREFIX))) return true;
|
|
9737
|
+
return (rule.actions?.onTrigger ?? []).some((sequence) => sequence.enabled && sequence.actions.some((step) => step.kind === "cap" && step.cap === ALARM_PANEL_CAP$1 && step.method === ALARM_PANEL_TRIGGER_METHOD));
|
|
9738
|
+
}
|
|
9739
|
+
/**
|
|
9740
|
+
* The edge an alarm rule refuses: a close, or a halt/fault (a cover stopped
|
|
9741
|
+
* after it already opened would otherwise fire twice; after a close it would
|
|
9742
|
+
* fire as if it had opened; a jammed lock is a fault, not an entry).
|
|
9743
|
+
*/
|
|
9744
|
+
function isRefusedAlarmEdge(edge) {
|
|
9745
|
+
return edge === "closing" || edge === "neither";
|
|
9746
|
+
}
|
|
8898
9747
|
/**
|
|
8899
9748
|
* Does this rule stop firing on the object event and fire at TRACK CLOSE?
|
|
8900
9749
|
*
|
|
@@ -9045,10 +9894,12 @@ function evaluateRule(rule, subject, deviceState, zoneOwners, sceneStates) {
|
|
|
9045
9894
|
}
|
|
9046
9895
|
const c = rule.conditions;
|
|
9047
9896
|
if (c.deviceState !== void 0) {
|
|
9048
|
-
const
|
|
9049
|
-
if (
|
|
9050
|
-
|
|
9897
|
+
const words = deviceState?.(c.deviceState.deviceId);
|
|
9898
|
+
if (words === void 0 || words.length === 0) return fail("deviceState");
|
|
9899
|
+
const wanted = toLowerSet(c.deviceState.states);
|
|
9900
|
+
if (!words.some((w) => wanted.has(w.trim().toLowerCase()))) return fail("deviceState");
|
|
9051
9901
|
}
|
|
9902
|
+
if (subject.kind === "device-event" && isRefusedAlarmEdge(subject.sensorEdge) && ruleBelongsToAlarm(rule)) return fail("sensorEdge");
|
|
9052
9903
|
if (c.scene !== void 0) {
|
|
9053
9904
|
const observation = sceneStates?.(c.scene.sceneId);
|
|
9054
9905
|
if (observation === void 0) return fail("scene");
|
|
@@ -9473,6 +10324,26 @@ function incomingFromSensorEvent(event, markerTrackId) {
|
|
|
9473
10324
|
};
|
|
9474
10325
|
}
|
|
9475
10326
|
/**
|
|
10327
|
+
* A sensor no camera takes a timeline row for (D630 §6) — evaluated, never
|
|
10328
|
+
* persisted. The sensor is its own device and there is no marker: a
|
|
10329
|
+
* text-only notification is the design here, not a degradation.
|
|
10330
|
+
*/
|
|
10331
|
+
function incomingFromUnownedSensorEvent(event, linkedCameraIds) {
|
|
10332
|
+
return {
|
|
10333
|
+
subject: subjectFromUnownedSensorEvent(event, linkedCameraIds),
|
|
10334
|
+
kind: "device-event",
|
|
10335
|
+
origin: "sensor",
|
|
10336
|
+
log: () => ({
|
|
10337
|
+
tags: { deviceId: event.deviceId },
|
|
10338
|
+
meta: {
|
|
10339
|
+
sensorEventId: event.id,
|
|
10340
|
+
kind: event.kind,
|
|
10341
|
+
linkedCameraIds: [...linkedCameraIds]
|
|
10342
|
+
}
|
|
10343
|
+
})
|
|
10344
|
+
};
|
|
10345
|
+
}
|
|
10346
|
+
/**
|
|
9476
10347
|
* Audio-event persist (classified episode).
|
|
9477
10348
|
*
|
|
9478
10349
|
* `stillOwnerId` is the audio still shelf's owner id for the photograph taken
|
|
@@ -10386,7 +11257,8 @@ function unique(values) {
|
|
|
10386
11257
|
* therefore keeps the alarm's own sentence, which is also what every row
|
|
10387
11258
|
* enqueued before this deploy carries.
|
|
10388
11259
|
*/
|
|
10389
|
-
function combinedAlarmText(source) {
|
|
11260
|
+
function combinedAlarmText(kind, source) {
|
|
11261
|
+
if (kind !== "alarm-triggered") return null;
|
|
10390
11262
|
if (source?.className === void 0) return null;
|
|
10391
11263
|
return {
|
|
10392
11264
|
titleKey: "alarm.combined.title",
|
|
@@ -10394,6 +11266,43 @@ function combinedAlarmText(source) {
|
|
|
10394
11266
|
};
|
|
10395
11267
|
}
|
|
10396
11268
|
/**
|
|
11269
|
+
* The title and body keys an alarm row carries — ONE function, so the
|
|
11270
|
+
* catalog spec (`alarm-texts.spec.ts`) enumerates exactly what the intake can
|
|
11271
|
+
* select.
|
|
11272
|
+
*
|
|
11273
|
+
* VARIANT SELECTION AT INTAKE, like every other system sentence: the mode is
|
|
11274
|
+
* part of the words ("Away armed"), not a fragment substituted into them,
|
|
11275
|
+
* because the catalog renders in ONE pass and a `{{mode}}` filled with `away`
|
|
11276
|
+
* would deliver the enum value to a person.
|
|
11277
|
+
*/
|
|
11278
|
+
function alarmTextKeys(transition, body) {
|
|
11279
|
+
const { kind, source } = transition;
|
|
11280
|
+
const combined = combinedAlarmText(kind, source);
|
|
11281
|
+
if (combined !== null) return combined;
|
|
11282
|
+
const titleKey = transition.mode !== void 0 ? `system.${kind}.title.${transition.mode}` : `system.${kind}.title`;
|
|
11283
|
+
if (body !== void 0) return {
|
|
11284
|
+
titleKey,
|
|
11285
|
+
bodyKey: body.bodyKey
|
|
11286
|
+
};
|
|
11287
|
+
if (kind === "alarm-sensor-bypass" && transition.sensor !== void 0) return {
|
|
11288
|
+
titleKey,
|
|
11289
|
+
bodyKey: `system.${kind}.body.${transition.sensor.event}`
|
|
11290
|
+
};
|
|
11291
|
+
if (kind === "alarm-disarmed" && (transition.previous === "triggered" || transition.previous === "pending")) return {
|
|
11292
|
+
titleKey,
|
|
11293
|
+
bodyKey: `system.${kind}.body.defused`
|
|
11294
|
+
};
|
|
11295
|
+
return {
|
|
11296
|
+
titleKey,
|
|
11297
|
+
bodyKey: source !== void 0 && (kind === "alarm-triggered" || kind === "alarm-pending") ? `system.${kind}.body.rule` : `system.${kind}.body`
|
|
11298
|
+
};
|
|
11299
|
+
}
|
|
11300
|
+
/** Whose event the row is — see {@link incomingFromAlarmTransition}. */
|
|
11301
|
+
function alarmSubjectDevice(transition, panelDeviceId) {
|
|
11302
|
+
if (transition.kind === "alarm-sensor-bypass" && transition.sensor !== void 0) return transition.sensor.deviceId;
|
|
11303
|
+
return transition.source?.deviceId ?? panelDeviceId;
|
|
11304
|
+
}
|
|
11305
|
+
/**
|
|
10397
11306
|
* The panel's own transition, as a system-event subject.
|
|
10398
11307
|
*
|
|
10399
11308
|
* IN-PROCESS, not off the bus. The alarm and the Notification Center live in
|
|
@@ -10409,42 +11318,53 @@ function combinedAlarmText(source) {
|
|
|
10409
11318
|
* With one, it is about the CAMERA that set it off: that is the media owner,
|
|
10410
11319
|
* the name the body reads out ("ALLARME — Ingresso"), and the device a rule
|
|
10411
11320
|
* scoping `deviceIds` means. One sentence, and both halves of it are testable.
|
|
11321
|
+
* A SENSOR BYPASS is about the sensor — its name is the title.
|
|
10412
11322
|
*/
|
|
10413
11323
|
function incomingFromAlarmTransition(input) {
|
|
10414
11324
|
const { transition, panelDeviceId } = input;
|
|
10415
11325
|
const source = transition.source;
|
|
10416
|
-
const deviceId =
|
|
11326
|
+
const deviceId = alarmSubjectDevice(transition, panelDeviceId);
|
|
10417
11327
|
const identity = input.lookup(deviceId);
|
|
10418
11328
|
const kind = transition.kind;
|
|
10419
|
-
const
|
|
11329
|
+
const keys = alarmTextKeys(transition, input.body);
|
|
11330
|
+
const sensor = transition.sensor;
|
|
11331
|
+
const alarmSource = kind === "alarm-triggered" ? source : void 0;
|
|
11332
|
+
const facts = {
|
|
11333
|
+
eventId: `alarm:${kind}:${String(transition.at)}${sensor !== void 0 ? `:${String(sensor.deviceId)}` : ""}${transition.refusalId !== void 0 ? `:${transition.refusalId}` : ""}`,
|
|
11334
|
+
timestamp: transition.at,
|
|
11335
|
+
category: `alarm.${kind.slice(6)}`,
|
|
11336
|
+
data: {
|
|
11337
|
+
panelDeviceId,
|
|
11338
|
+
state: transition.state,
|
|
11339
|
+
previous: transition.previous,
|
|
11340
|
+
...transition.mode !== void 0 ? { mode: transition.mode } : {},
|
|
11341
|
+
...source !== void 0 ? { sourceRuleId: source.ruleId } : {},
|
|
11342
|
+
...transition.refusalId !== void 0 ? { refusalId: transition.refusalId } : {},
|
|
11343
|
+
...sensor !== void 0 ? {
|
|
11344
|
+
sensorDeviceId: sensor.deviceId,
|
|
11345
|
+
event: sensor.event
|
|
11346
|
+
} : {}
|
|
11347
|
+
}
|
|
11348
|
+
};
|
|
11349
|
+
const deviceParams = deviceTextParams(deviceId, identity);
|
|
10420
11350
|
return systemSubject({
|
|
10421
|
-
facts
|
|
10422
|
-
eventId: `alarm:${kind}:${String(transition.at)}`,
|
|
10423
|
-
timestamp: transition.at,
|
|
10424
|
-
category: `alarm.${kind.slice(6)}`,
|
|
10425
|
-
data: {
|
|
10426
|
-
panelDeviceId,
|
|
10427
|
-
state: transition.state,
|
|
10428
|
-
previous: transition.previous,
|
|
10429
|
-
...transition.mode !== void 0 ? { mode: transition.mode } : {},
|
|
10430
|
-
...source !== void 0 ? { sourceRuleId: source.ruleId } : {}
|
|
10431
|
-
}
|
|
10432
|
-
},
|
|
11351
|
+
facts,
|
|
10433
11352
|
kind,
|
|
10434
11353
|
subject: `alarm:${String(panelDeviceId)}`,
|
|
10435
|
-
titleKey:
|
|
10436
|
-
bodyKey:
|
|
11354
|
+
titleKey: keys.titleKey,
|
|
11355
|
+
bodyKey: keys.bodyKey,
|
|
10437
11356
|
textParams: {
|
|
10438
|
-
...
|
|
11357
|
+
...deviceParams,
|
|
11358
|
+
...sensor?.name !== void 0 && identity === void 0 ? { deviceName: sensor.name } : {},
|
|
10439
11359
|
...transition.mode !== void 0 ? { mode: transition.mode } : {},
|
|
10440
11360
|
...source !== void 0 ? { rule: source.ruleName } : {},
|
|
10441
|
-
...kind === "alarm-arming" ? { seconds: `${transition.seconds ?? 0}` } : {},
|
|
11361
|
+
...kind === "alarm-arming" || kind === "alarm-pending" ? { seconds: `${transition.seconds ?? 0}` } : {},
|
|
10442
11362
|
...input.body?.textParams
|
|
10443
11363
|
},
|
|
10444
11364
|
deviceId,
|
|
10445
11365
|
...identity !== void 0 ? { deviceType: identity.type } : {},
|
|
10446
11366
|
...input.body !== void 0 ? { textCount: input.body.textCount } : {},
|
|
10447
|
-
...
|
|
11367
|
+
...alarmSource !== void 0 ? { alarmSource } : {}
|
|
10448
11368
|
});
|
|
10449
11369
|
}
|
|
10450
11370
|
/** Re-stamp a live-producer envelope as a boot-reconcile replay of the same
|
|
@@ -11402,7 +12322,8 @@ var en_default = {
|
|
|
11402
12322
|
"alarm.arming.body.skipped": "Excluded: {{names}}",
|
|
11403
12323
|
"alarm.arm.body.bypassed": "Active: {{devices}}\nBypassed: {{bypassedNames}}",
|
|
11404
12324
|
"alarm.arming.body.bypassed": "Bypassed: {{bypassedNames}}",
|
|
11405
|
-
"alarm.armRefused.body": "Open: {{names}}
|
|
12325
|
+
"alarm.armRefused.body": "Open: {{names}}",
|
|
12326
|
+
"alarm.armRefused.body.hint": "Open: {{names}}\nTap “Arm anyway” to arm with them excluded.",
|
|
11406
12327
|
"alarm.device.unnamed": "Device {{id}}",
|
|
11407
12328
|
"alarm.combined.title": "Alarm: {{deviceName}}",
|
|
11408
12329
|
"system.alarm-arm-refused.title.home": "Arming home refused",
|
|
@@ -11419,15 +12340,33 @@ var en_default = {
|
|
|
11419
12340
|
"system.alarm-armed.body": "Alarm armed",
|
|
11420
12341
|
"system.alarm-disarmed.title": "Disarmed",
|
|
11421
12342
|
"system.alarm-disarmed.body": "Alarm disarmed",
|
|
12343
|
+
"system.alarm-disarmed.body.defused": "Alarm disarmed during an alarm",
|
|
11422
12344
|
"system.alarm-triggered.title": "ALARM",
|
|
11423
12345
|
"system.alarm-triggered.body": "Alarm triggered",
|
|
11424
12346
|
"system.alarm-triggered.body.rule": "Triggered by {{rule}} on {{deviceName}}",
|
|
12347
|
+
"system.alarm-pending.title.home": "Intrusion — home, {{seconds}}s to disarm",
|
|
12348
|
+
"system.alarm-pending.title.away": "Intrusion — away, {{seconds}}s to disarm",
|
|
12349
|
+
"system.alarm-pending.title.night": "Intrusion — night, {{seconds}}s to disarm",
|
|
12350
|
+
"system.alarm-pending.body": "Entry delay started",
|
|
12351
|
+
"system.alarm-pending.body.rule": "Started by {{rule}} on {{deviceName}}",
|
|
12352
|
+
"system.alarm-rearmed.title.home": "Re-armed: home",
|
|
12353
|
+
"system.alarm-rearmed.title.away": "Re-armed: away",
|
|
12354
|
+
"system.alarm-rearmed.title.night": "Re-armed: night",
|
|
12355
|
+
"system.alarm-rearmed.body": "The siren ended and the alarm is armed again",
|
|
12356
|
+
"system.alarm-sensor-bypass.title": "{{deviceName}}",
|
|
12357
|
+
"system.alarm-sensor-bypass.body.ignored": "Ignored: it was open when the alarm was armed",
|
|
12358
|
+
"system.alarm-sensor-bypass.body.lifted": "Monitored again: it closed and opened",
|
|
12359
|
+
"system.alarm-check-failed.title.home": "Armed home without checking openings",
|
|
12360
|
+
"system.alarm-check-failed.title.away": "Armed away without checking openings",
|
|
12361
|
+
"system.alarm-check-failed.title.night": "Armed night without checking openings",
|
|
12362
|
+
"system.alarm-check-failed.body": "The device states could not be read",
|
|
11425
12363
|
"button.snooze.minutes": "Snooze {{minutes}} min",
|
|
11426
12364
|
"button.snooze.hours": {
|
|
11427
12365
|
"one": "Snooze {{hours}} hour",
|
|
11428
12366
|
"other": "Snooze {{hours}} hours"
|
|
11429
12367
|
},
|
|
11430
12368
|
"button.alarm.disarm": "Disarm",
|
|
12369
|
+
"button.alarm.armAnyway": "Arm anyway",
|
|
11431
12370
|
"button.alarm.arm.home": "Arm home",
|
|
11432
12371
|
"button.alarm.arm.away": "Arm away",
|
|
11433
12372
|
"button.alarm.arm.night": "Arm night",
|
|
@@ -11671,7 +12610,8 @@ var it_default = {
|
|
|
11671
12610
|
"alarm.arming.body.skipped": "Esclusi: {{names}}",
|
|
11672
12611
|
"alarm.arm.body.bypassed": "Attivi: {{devices}}\nBypassati: {{bypassedNames}}",
|
|
11673
12612
|
"alarm.arming.body.bypassed": "Bypassati: {{bypassedNames}}",
|
|
11674
|
-
"alarm.armRefused.body": "Aperti: {{names}}
|
|
12613
|
+
"alarm.armRefused.body": "Aperti: {{names}}",
|
|
12614
|
+
"alarm.armRefused.body.hint": "Aperti: {{names}}\nTocca “Arma comunque” per armare escludendoli.",
|
|
11675
12615
|
"alarm.device.unnamed": "Dispositivo {{id}}",
|
|
11676
12616
|
"alarm.combined.title": "Allarme: {{deviceName}}",
|
|
11677
12617
|
"system.alarm-arm-refused.title.home": "Inserimento Casa rifiutato",
|
|
@@ -11688,15 +12628,33 @@ var it_default = {
|
|
|
11688
12628
|
"system.alarm-armed.body": "Allarme inserito",
|
|
11689
12629
|
"system.alarm-disarmed.title": "Disinserito",
|
|
11690
12630
|
"system.alarm-disarmed.body": "Allarme disinserito",
|
|
12631
|
+
"system.alarm-disarmed.body.defused": "Allarme disinserito durante un allarme",
|
|
11691
12632
|
"system.alarm-triggered.title": "ALLARME",
|
|
11692
12633
|
"system.alarm-triggered.body": "Allarme scattato",
|
|
11693
12634
|
"system.alarm-triggered.body.rule": "Scattato da {{rule}} su {{deviceName}}",
|
|
12635
|
+
"system.alarm-pending.title.home": "Intrusione — Casa, {{seconds}}s per disinserire",
|
|
12636
|
+
"system.alarm-pending.title.away": "Intrusione — Fuori, {{seconds}}s per disinserire",
|
|
12637
|
+
"system.alarm-pending.title.night": "Intrusione — Notte, {{seconds}}s per disinserire",
|
|
12638
|
+
"system.alarm-pending.body": "Ritardo di ingresso avviato",
|
|
12639
|
+
"system.alarm-pending.body.rule": "Avviato da {{rule}} su {{deviceName}}",
|
|
12640
|
+
"system.alarm-rearmed.title.home": "Reinserito: Casa",
|
|
12641
|
+
"system.alarm-rearmed.title.away": "Reinserito: Fuori",
|
|
12642
|
+
"system.alarm-rearmed.title.night": "Reinserito: Notte",
|
|
12643
|
+
"system.alarm-rearmed.body": "La sirena è terminata e l'allarme è di nuovo inserito",
|
|
12644
|
+
"system.alarm-sensor-bypass.title": "{{deviceName}}",
|
|
12645
|
+
"system.alarm-sensor-bypass.body.ignored": "Ignorato: era aperto all'inserimento",
|
|
12646
|
+
"system.alarm-sensor-bypass.body.lifted": "Di nuovo sorvegliato: si è chiuso e riaperto",
|
|
12647
|
+
"system.alarm-check-failed.title.home": "Inserito Casa senza controllare le aperture",
|
|
12648
|
+
"system.alarm-check-failed.title.away": "Inserito Fuori senza controllare le aperture",
|
|
12649
|
+
"system.alarm-check-failed.title.night": "Inserito Notte senza controllare le aperture",
|
|
12650
|
+
"system.alarm-check-failed.body": "Non è stato possibile leggere lo stato dei dispositivi",
|
|
11694
12651
|
"button.snooze.minutes": "Silenzia {{minutes}} min",
|
|
11695
12652
|
"button.snooze.hours": {
|
|
11696
12653
|
"one": "Silenzia {{hours}} ora",
|
|
11697
12654
|
"other": "Silenzia {{hours}} ore"
|
|
11698
12655
|
},
|
|
11699
12656
|
"button.alarm.disarm": "Disarma",
|
|
12657
|
+
"button.alarm.armAnyway": "Arma comunque",
|
|
11700
12658
|
"button.alarm.arm.home": "Arma casa",
|
|
11701
12659
|
"button.alarm.arm.away": "Arma fuori",
|
|
11702
12660
|
"button.alarm.arm.night": "Arma notte",
|
|
@@ -16321,6 +17279,8 @@ var OUTBOX_RECORD_KINDS = new Set([
|
|
|
16321
17279
|
function isOutboxRecordKind(x) {
|
|
16322
17280
|
return typeof x === "string" && OUTBOX_RECORD_KINDS.has(x);
|
|
16323
17281
|
}
|
|
17282
|
+
/** A stored row back to an entry — a guard, never a cast. Exported for the
|
|
17283
|
+
* specs that read the store a centre wrote to. */
|
|
16324
17284
|
function rowToEntry$1(id, data) {
|
|
16325
17285
|
const ruleId = data["ruleId"];
|
|
16326
17286
|
const targetId = data["targetId"];
|
|
@@ -23169,6 +24129,26 @@ var TIMELAPSE_LOOKAHEAD_MS = 9 * 864e5;
|
|
|
23169
24129
|
*/
|
|
23170
24130
|
var NC_ALARM_ANNOUNCE_RULE_ID = "nc-alarm-announce";
|
|
23171
24131
|
/**
|
|
24132
|
+
* A rule's media policy, as the fields a row freezes — ONE derivation, used by
|
|
24133
|
+
* every row a rule addresses AND by the alarm's own announcement row when it is
|
|
24134
|
+
* the COMBINED siren (the triggering rule's pictures on the alarm's words).
|
|
24135
|
+
*/
|
|
24136
|
+
function mediaPolicyFields(mediaRule, cropZoneIds) {
|
|
24137
|
+
const media = mediaRule.media;
|
|
24138
|
+
return {
|
|
24139
|
+
media: media.attach,
|
|
24140
|
+
...media.zoneCrop === true && cropZoneIds !== void 0 ? { mediaZoneIds: [...cropZoneIds] } : {},
|
|
24141
|
+
...cropZoneIds !== void 0 ? { triggerZoneIds: [...cropZoneIds] } : {},
|
|
24142
|
+
...media.frame !== void 0 ? { mediaFrame: media.frame } : {},
|
|
24143
|
+
...media.profile !== void 0 ? { mediaProfile: media.profile } : {},
|
|
24144
|
+
...media.gif === true ? { mediaGif: true } : {},
|
|
24145
|
+
...media.clip === true ? { mediaClip: true } : {},
|
|
24146
|
+
...media.clipPreRollSec !== void 0 ? { mediaClipPreRollSec: media.clipPreRollSec } : {},
|
|
24147
|
+
...media.clipSpeed !== void 0 ? { mediaClipSpeed: media.clipSpeed } : {},
|
|
24148
|
+
...media.clipPostRollSec !== void 0 ? { mediaClipPostRollSec: media.clipPostRollSec } : {}
|
|
24149
|
+
};
|
|
24150
|
+
}
|
|
24151
|
+
/**
|
|
23172
24152
|
* Synthetic rule id for an export's on-screen picker.
|
|
23173
24153
|
*
|
|
23174
24154
|
* The operator named these targets on the export screen, not in a rule. The
|
|
@@ -23186,10 +24166,35 @@ var NC_EXPORT_NOTIFY_RULE_ID = "nc-export-notify";
|
|
|
23186
24166
|
* notification that says the alarm was disarmed is worse than offering nothing.
|
|
23187
24167
|
*/
|
|
23188
24168
|
function alarmButtonKind(kind) {
|
|
23189
|
-
|
|
23190
|
-
|
|
24169
|
+
switch (kind) {
|
|
24170
|
+
case "alarm-triggered":
|
|
24171
|
+
case "alarm-armed":
|
|
24172
|
+
case "alarm-arming":
|
|
24173
|
+
case "alarm-disarmed":
|
|
24174
|
+
case "alarm-pending":
|
|
24175
|
+
case "alarm-rearmed":
|
|
24176
|
+
case "alarm-check-failed":
|
|
24177
|
+
case "alarm-arm-refused":
|
|
24178
|
+
case "alarm-sensor-bypass": return kind;
|
|
24179
|
+
default: return null;
|
|
24180
|
+
}
|
|
24181
|
+
}
|
|
24182
|
+
/** The refusal id an `alarm-arm-refused` row froze (`incomingFromAlarmTransition`). */
|
|
24183
|
+
function rowRefusalId(systemEvent) {
|
|
24184
|
+
const value = systemEvent?.data["refusalId"];
|
|
24185
|
+
return typeof value === "string" && value.length > 0 ? value : void 0;
|
|
23191
24186
|
}
|
|
23192
24187
|
/**
|
|
24188
|
+
* What a pressed Arm anyway that did nothing tells the person holding the
|
|
24189
|
+
* phone — one sentence per reason the panel refused it.
|
|
24190
|
+
*/
|
|
24191
|
+
var ARM_ANYWAY_REJECTION_COPY = {
|
|
24192
|
+
stale: "The alarm changed since this notification — nothing was armed",
|
|
24193
|
+
expired: "This button expired — arm again from the app",
|
|
24194
|
+
used: "Already used",
|
|
24195
|
+
"unknown-refusal": "This refusal is no longer current — arm again"
|
|
24196
|
+
};
|
|
24197
|
+
/**
|
|
23193
24198
|
* What the sustained-state alerts run on when nothing wired the settings in.
|
|
23194
24199
|
*
|
|
23195
24200
|
* The SHIPPED DEFAULTS, not "off". A wiring that silently disabled the feature
|
|
@@ -23293,7 +24298,6 @@ function consumablesSliceOf(event) {
|
|
|
23293
24298
|
*/
|
|
23294
24299
|
var NC_ALARM_SETTINGS_FALLBACK = {
|
|
23295
24300
|
...DEFAULT_DELAYS,
|
|
23296
|
-
announceArm: false,
|
|
23297
24301
|
announceTargets: [],
|
|
23298
24302
|
nonBlocking: []
|
|
23299
24303
|
};
|
|
@@ -23403,47 +24407,6 @@ function historyRecordKind(kind) {
|
|
|
23403
24407
|
return kind;
|
|
23404
24408
|
}
|
|
23405
24409
|
/**
|
|
23406
|
-
* The system-event half of a history row, in the CAP's shape.
|
|
23407
|
-
*
|
|
23408
|
-
* The cap contract (`NcHistoryEntry`, frozen in `@camstack/types`) wants a
|
|
23409
|
-
* title and a body, and a row written since the text catalog carries keys
|
|
23410
|
-
* instead. So history resolves them through the SAME catalog the delivery used
|
|
23411
|
-
* — history must read as what the operator was actually sent, and a page that
|
|
23412
|
-
* showed a key while the phone showed a sentence would be a second vocabulary
|
|
23413
|
-
* to keep in step. A pre-catalog row falls back to the pair frozen on it.
|
|
23414
|
-
*/
|
|
23415
|
-
/**
|
|
23416
|
-
* Cap-facing downgrade of a system-event kind — the SAME device as
|
|
23417
|
-
* {@link historyRecordKind}, and it exists for the same reason.
|
|
23418
|
-
*
|
|
23419
|
-
* This addon bundles its own `@camstack/types` (self-contained externals), so
|
|
23420
|
-
* it can emit a kind the enum in the RUNNING SERVER does not have. The history
|
|
23421
|
-
* router does not: it validates its output against the host's
|
|
23422
|
-
* `NcHistorySubjectSchema`, whose `kind` is a `z.enum`. One row carrying a kind
|
|
23423
|
-
* the host has never heard of therefore does not degrade — it fails the whole
|
|
23424
|
-
* `getHistory` call, for every row, exactly as the enum's own legacy-tail note
|
|
23425
|
-
* warns.
|
|
23426
|
-
*
|
|
23427
|
-
* `alarm-arming` is projected onto `alarm-armed`, its nearest member: same
|
|
23428
|
-
* panel, same lifecycle, adjacent instant. Nothing a person reads changes —
|
|
23429
|
-
* the row's title and body are resolved from the arming keys ("Inserimento
|
|
23430
|
-
* Fuori — attivo tra 30 secondi") — only the machine-facing word does.
|
|
23431
|
-
*
|
|
23432
|
-
* DELETE THIS when a `@camstack/server` closure carrying `alarm-arming` in
|
|
23433
|
-
* `NcSystemEventKindSchema` is installed everywhere this addon runs. Until
|
|
23434
|
-
* then it is what lets the exit-delay notification ship without a train.
|
|
23435
|
-
*/
|
|
23436
|
-
/**
|
|
23437
|
-
* The mirrored device-state word, narrowed to a contact reading.
|
|
23438
|
-
*
|
|
23439
|
-
* Anything that is not one of the two contact words is `undefined` — a device
|
|
23440
|
-
* whose slice the state table read as `on` / `armed_home` is not a contact, and
|
|
23441
|
-
* an exclusion must never be lifted by a word that belongs to another cap.
|
|
23442
|
-
*/
|
|
23443
|
-
function contactReadingOf(state) {
|
|
23444
|
-
if (state === "open" || state === "closed") return state;
|
|
23445
|
-
}
|
|
23446
|
-
/**
|
|
23447
24410
|
* The subject kind a persisted record kind maps to.
|
|
23448
24411
|
*
|
|
23449
24412
|
* `NcOutboxRecordKind` carries two kinds a rule subject never has —
|
|
@@ -23481,11 +24444,16 @@ function cooldownSubjectFromOutbox(entry) {
|
|
|
23481
24444
|
...subject.systemEvent !== void 0 ? { systemEvent: subject.systemEvent } : {}
|
|
23482
24445
|
};
|
|
23483
24446
|
}
|
|
23484
|
-
|
|
23485
|
-
|
|
23486
|
-
|
|
23487
|
-
|
|
23488
|
-
|
|
24447
|
+
/**
|
|
24448
|
+
* The system-event half of a history row, in the CAP's shape.
|
|
24449
|
+
*
|
|
24450
|
+
* The cap contract (`NcHistoryEntry`, frozen in `@camstack/types`) wants a
|
|
24451
|
+
* title and a body, and a row written since the text catalog carries keys
|
|
24452
|
+
* instead. So history resolves them through the SAME catalog the delivery used
|
|
24453
|
+
* — history must read as what the operator was actually sent, and a page that
|
|
24454
|
+
* showed a key while the phone showed a sentence would be a second vocabulary
|
|
24455
|
+
* to keep in step. A pre-catalog row falls back to the pair frozen on it.
|
|
24456
|
+
*/
|
|
23489
24457
|
function historySystemEvent(texts, systemEvent) {
|
|
23490
24458
|
const vars = systemEvent.textParams ?? {};
|
|
23491
24459
|
const count = systemEvent.textCount;
|
|
@@ -23498,7 +24466,7 @@ function historySystemEvent(texts, systemEvent) {
|
|
|
23498
24466
|
return frozen ?? "";
|
|
23499
24467
|
};
|
|
23500
24468
|
return {
|
|
23501
|
-
kind:
|
|
24469
|
+
kind: systemEvent.kind,
|
|
23502
24470
|
subject: systemEvent.subject,
|
|
23503
24471
|
title: resolve(systemEvent.titleKey, systemEvent.title),
|
|
23504
24472
|
body: resolve(systemEvent.bodyKey, systemEvent.body),
|
|
@@ -23686,6 +24654,13 @@ var NotificationCenter = class NotificationCenter {
|
|
|
23686
24654
|
audioEnabled = false;
|
|
23687
24655
|
/** The panel this node owns, or null. See {@link NcAlarmPanelPort}. */
|
|
23688
24656
|
alarmPanel = null;
|
|
24657
|
+
/**
|
|
24658
|
+
* The words the panel answers the `deviceState` gate with — its state, plus
|
|
24659
|
+
* the armed mode a `pending`/`triggered` belongs to (D630). Refreshed on
|
|
24660
|
+
* every publish, next to the one-word mirror; the lookup only uses them
|
|
24661
|
+
* while the mirror agrees on the state (`panelAwareLookup`).
|
|
24662
|
+
*/
|
|
24663
|
+
panelGateWords = null;
|
|
23689
24664
|
/** In-memory cooldown map — seeded from persisted outbox rows on start. */
|
|
23690
24665
|
lastFiredAt = /* @__PURE__ */ new Map();
|
|
23691
24666
|
/**
|
|
@@ -23783,11 +24758,12 @@ var NotificationCenter = class NotificationCenter {
|
|
|
23783
24758
|
async mintButtons(input) {
|
|
23784
24759
|
const mint = this.deps.mintActionUrl;
|
|
23785
24760
|
if (mint === void 0) return [];
|
|
23786
|
-
const
|
|
23787
|
-
|
|
24761
|
+
const stored = this.rules.get(input.ruleId);
|
|
24762
|
+
const announcement = stored === null && input.ruleId === "nc-alarm-announce";
|
|
24763
|
+
if (stored === null && !announcement) return [];
|
|
23788
24764
|
const drafts = [];
|
|
23789
|
-
const sequenceButtons = this.actionRunner === null ? [] : buildActionButtons({
|
|
23790
|
-
actions:
|
|
24765
|
+
const sequenceButtons = this.actionRunner === null || stored === null ? [] : buildActionButtons({
|
|
24766
|
+
actions: stored.actions,
|
|
23791
24767
|
mintUrl: ({ sequence, actionId }) => {
|
|
23792
24768
|
const id = randomUUID();
|
|
23793
24769
|
drafts.push({
|
|
@@ -23808,8 +24784,8 @@ var NotificationCenter = class NotificationCenter {
|
|
|
23808
24784
|
return id;
|
|
23809
24785
|
}
|
|
23810
24786
|
});
|
|
23811
|
-
const snoozeButtons = input.ownerUserId === void 0 ? [] : buildSnoozeButtons({
|
|
23812
|
-
rule,
|
|
24787
|
+
const snoozeButtons = input.ownerUserId === void 0 || stored === null ? [] : buildSnoozeButtons({
|
|
24788
|
+
rule: stored,
|
|
23813
24789
|
label: (minutes) => this.snoozeButtonLabel(minutes),
|
|
23814
24790
|
mintUrl: ({ actionId, minutes }) => {
|
|
23815
24791
|
const id = randomUUID();
|
|
@@ -23836,36 +24812,63 @@ var NotificationCenter = class NotificationCenter {
|
|
|
23836
24812
|
});
|
|
23837
24813
|
const alarmKind = alarmButtonKind(input.systemEvent?.kind);
|
|
23838
24814
|
const panel = this.alarmPanel;
|
|
24815
|
+
const refusalId = rowRefusalId(input.systemEvent);
|
|
24816
|
+
if (alarmKind === "alarm-arm-refused" && refusalId === void 0) this.logger.debug("refusal notification carries no Arm anyway — the row names no refusal", {
|
|
24817
|
+
tags: { deviceId: input.deviceId },
|
|
24818
|
+
meta: {
|
|
24819
|
+
ruleId: input.ruleId,
|
|
24820
|
+
targetId: input.targetId
|
|
24821
|
+
}
|
|
24822
|
+
});
|
|
23839
24823
|
const alarmButtons = alarmKind === null ? [] : buildAlarmButtons({
|
|
23840
24824
|
kind: alarmKind,
|
|
23841
24825
|
panel: panel === null ? null : {
|
|
23842
24826
|
deviceId: panel.deviceId,
|
|
23843
24827
|
availableModes: panel.availableModes()
|
|
23844
24828
|
},
|
|
24829
|
+
...refusalId !== void 0 ? { refusalId } : {},
|
|
23845
24830
|
labels: {
|
|
23846
24831
|
disarm: this.texts.text({ key: "button.alarm.disarm" }),
|
|
23847
|
-
arm: (mode) => this.texts.text({ key: `button.alarm.arm.${mode}` })
|
|
24832
|
+
arm: (mode) => this.texts.text({ key: `button.alarm.arm.${mode}` }),
|
|
24833
|
+
armAnyway: this.texts.text({ key: "button.alarm.armAnyway" })
|
|
23848
24834
|
},
|
|
23849
|
-
mintUrl: (
|
|
24835
|
+
mintUrl: (button) => {
|
|
23850
24836
|
const id = randomUUID();
|
|
23851
|
-
const
|
|
24837
|
+
const common = {
|
|
24838
|
+
kind: "alarm",
|
|
24839
|
+
id,
|
|
24840
|
+
panelDeviceId: panel?.deviceId ?? 0,
|
|
24841
|
+
deviceId: input.deviceId,
|
|
24842
|
+
actionId: button.actionId,
|
|
24843
|
+
targetId: input.targetId
|
|
24844
|
+
};
|
|
23852
24845
|
drafts.push({
|
|
23853
24846
|
id,
|
|
23854
24847
|
ttlMs: actionTtlMs({
|
|
23855
24848
|
kind: "alarm",
|
|
23856
|
-
action
|
|
24849
|
+
action: button.action
|
|
23857
24850
|
}),
|
|
23858
|
-
grant: (expiresAt) =>
|
|
23859
|
-
|
|
23860
|
-
|
|
23861
|
-
|
|
23862
|
-
|
|
23863
|
-
|
|
23864
|
-
|
|
23865
|
-
|
|
23866
|
-
|
|
23867
|
-
|
|
23868
|
-
|
|
24851
|
+
grant: (expiresAt) => {
|
|
24852
|
+
switch (button.action) {
|
|
24853
|
+
case "arm-anyway": return {
|
|
24854
|
+
...common,
|
|
24855
|
+
action: "arm-anyway",
|
|
24856
|
+
refusalId: button.refusalId,
|
|
24857
|
+
expiresAt
|
|
24858
|
+
};
|
|
24859
|
+
case "arm": return {
|
|
24860
|
+
...common,
|
|
24861
|
+
action: "arm",
|
|
24862
|
+
mode: button.mode,
|
|
24863
|
+
expiresAt
|
|
24864
|
+
};
|
|
24865
|
+
case "disarm": return {
|
|
24866
|
+
...common,
|
|
24867
|
+
action: "disarm",
|
|
24868
|
+
expiresAt
|
|
24869
|
+
};
|
|
24870
|
+
}
|
|
24871
|
+
}
|
|
23869
24872
|
});
|
|
23870
24873
|
return id;
|
|
23871
24874
|
}
|
|
@@ -24047,10 +25050,11 @@ var NotificationCenter = class NotificationCenter {
|
|
|
24047
25050
|
/** Arm or disarm the panel a button granted. Throws when the panel is gone —
|
|
24048
25051
|
* the plane turns that into the page that says so. */
|
|
24049
25052
|
async applyGrantedAlarm(grant) {
|
|
24050
|
-
if (this.deps.actuate === void 0) throw new Error("no actuator is wired on this node");
|
|
24051
25053
|
const panel = this.alarmPanel;
|
|
24052
25054
|
if (panel === null) throw new Error("this node has no alarm panel");
|
|
24053
25055
|
if (panel.deviceId !== grant.panelDeviceId) throw new Error("this button was minted for a different alarm panel");
|
|
25056
|
+
if (grant.action === "arm-anyway") return this.applyGrantedArmAnyway(panel, grant);
|
|
25057
|
+
if (this.deps.actuate === void 0) throw new Error("no actuator is wired on this node");
|
|
24054
25058
|
await this.actuateWithAttribution({
|
|
24055
25059
|
deviceId: panel.deviceId,
|
|
24056
25060
|
cap: ALARM_PANEL_CAP,
|
|
@@ -24058,6 +25062,34 @@ var NotificationCenter = class NotificationCenter {
|
|
|
24058
25062
|
args: grant.action === "arm" && grant.mode !== void 0 ? { mode: grant.mode } : {}
|
|
24059
25063
|
});
|
|
24060
25064
|
}
|
|
25065
|
+
/**
|
|
25066
|
+
* The Arm anyway on a refusal notification — IN-PROCESS, `armAnyway(refusalId)`,
|
|
25067
|
+
* never the cap: `alarm-panel.arm` has no field for a refusal, and a plain
|
|
25068
|
+
* arm would simply be refused again.
|
|
25069
|
+
*
|
|
25070
|
+
* The token claim is already atomic and single-use; the panel adds its own
|
|
25071
|
+
* one-shot on the refusal, so a second phone's copy of the same button is
|
|
25072
|
+
* `used`. Every way it does nothing throws a sentence the page shows.
|
|
25073
|
+
*/
|
|
25074
|
+
async applyGrantedArmAnyway(panel, grant) {
|
|
25075
|
+
const outcome = await panel.armAnyway(grant.refusalId);
|
|
25076
|
+
switch (outcome.outcome) {
|
|
25077
|
+
case "rejected": throw new NcActionRefusedError(ARM_ANYWAY_REJECTION_COPY[outcome.reason]);
|
|
25078
|
+
case "refused": throw new NcActionRefusedError("Something else is open now — see the new notification");
|
|
25079
|
+
case "arming":
|
|
25080
|
+
case "armed":
|
|
25081
|
+
this.logger.info("alarm armed from a refusal notification (Arm anyway)", {
|
|
25082
|
+
tags: { deviceId: panel.deviceId },
|
|
25083
|
+
meta: {
|
|
25084
|
+
refusalId: grant.refusalId,
|
|
25085
|
+
outcome: outcome.outcome,
|
|
25086
|
+
excluded: outcome.excluded.length,
|
|
25087
|
+
targetId: grant.targetId ?? null
|
|
25088
|
+
}
|
|
25089
|
+
});
|
|
25090
|
+
return;
|
|
25091
|
+
}
|
|
25092
|
+
}
|
|
24061
25093
|
/** The registry the action plane claims against. */
|
|
24062
25094
|
get actionTokenRegistry() {
|
|
24063
25095
|
return this.actionTokens;
|
|
@@ -25117,17 +26149,38 @@ var NotificationCenter = class NotificationCenter {
|
|
|
25117
26149
|
* before it.
|
|
25118
26150
|
*/
|
|
25119
26151
|
onSensorEventPersisted(event, markerTrackId) {
|
|
25120
|
-
this.
|
|
26152
|
+
this.observeOpeningEvent(event);
|
|
25121
26153
|
this.consumeEvent(incomingFromSensorEvent(event, markerTrackId));
|
|
25122
26154
|
}
|
|
25123
|
-
/**
|
|
25124
|
-
*
|
|
25125
|
-
|
|
26155
|
+
/**
|
|
26156
|
+
* Called for a sensor state change no camera takes a timeline row for
|
|
26157
|
+
* (`ingestSensorStateChange`'s `onUnowned`, D630 §6): linked to no camera
|
|
26158
|
+
* (`linkedCameraIds` = []), or linked only by cameras that did not opt it
|
|
26159
|
+
* into tracked events (`linkedCameraIds` = those cameras). Nothing was
|
|
26160
|
+
* persisted, but the event is evaluated exactly like a persisted one, so a
|
|
26161
|
+
* contact rule fires and the contact reaches the alarm panel. The sensor is
|
|
26162
|
+
* its own device, and the notification is text-only (no marker).
|
|
26163
|
+
*
|
|
26164
|
+
* A separate door from {@link onSensorEventPersisted} because the SUBJECT
|
|
26165
|
+
* differs, not the path: both feed the panel first and then the same
|
|
26166
|
+
* evaluation. Same lossy boundary: no row, so no reconcile.
|
|
26167
|
+
*/
|
|
26168
|
+
onUnownedSensorEvent(event, linkedCameraIds) {
|
|
26169
|
+
this.observeOpeningEvent(event);
|
|
26170
|
+
this.consumeEvent(incomingFromUnownedSensorEvent(event, linkedCameraIds));
|
|
26171
|
+
}
|
|
26172
|
+
/**
|
|
26173
|
+
* The opening reading a sensor row carries — a contact, a cover or a lock,
|
|
26174
|
+
* read with D630's table (`alarmOpeningFromSensorEvent`) — handed to the
|
|
26175
|
+
* panel. A row that is not an opening, or whose word is unknown, hands over
|
|
26176
|
+
* nothing: an unreadable state must never move an exclusion (D49).
|
|
26177
|
+
*/
|
|
26178
|
+
observeOpeningEvent(event) {
|
|
25126
26179
|
const panel = this.alarmPanel;
|
|
25127
|
-
if (panel === null
|
|
25128
|
-
const
|
|
25129
|
-
if (
|
|
25130
|
-
panel.
|
|
26180
|
+
if (panel === null) return;
|
|
26181
|
+
const opening = alarmOpeningFromSensorEvent(event.kind, event.value);
|
|
26182
|
+
if (opening.kind !== "reading") return;
|
|
26183
|
+
panel.observeOpening(event.sourceDeviceId, opening.reading, event.timestamp);
|
|
25131
26184
|
}
|
|
25132
26185
|
/**
|
|
25133
26186
|
* Called at the AUDIO-event persist site (`eventStore.insertAudio`), in the
|
|
@@ -25479,22 +26532,7 @@ var NotificationCenter = class NotificationCenter {
|
|
|
25479
26532
|
return { success: true };
|
|
25480
26533
|
},
|
|
25481
26534
|
getAlarmConfig: async () => this.alarmConfig(),
|
|
25482
|
-
setAlarmConfig: async ({ patch }) =>
|
|
25483
|
-
const panel = this.alarmPanel;
|
|
25484
|
-
if (panel === null) throw new Error("this node has no alarm panel");
|
|
25485
|
-
const settings = await panel.applySettings(patch);
|
|
25486
|
-
this.logger.info("alarm settings changed", {
|
|
25487
|
-
tags: { deviceId: panel.deviceId },
|
|
25488
|
-
meta: {
|
|
25489
|
-
exitDelaySec: settings.exitDelaySec,
|
|
25490
|
-
entryDelaySec: settings.entryDelaySec,
|
|
25491
|
-
triggeredDurationSec: settings.triggeredDurationSec,
|
|
25492
|
-
announceArm: settings.announceArm,
|
|
25493
|
-
announceTargets: settings.announceTargets.length
|
|
25494
|
-
}
|
|
25495
|
-
});
|
|
25496
|
-
return this.alarmConfig();
|
|
25497
|
-
}
|
|
26535
|
+
setAlarmConfig: async ({ patch }) => this.setAlarmConfig(patch)
|
|
25498
26536
|
};
|
|
25499
26537
|
}
|
|
25500
26538
|
/**
|
|
@@ -25504,66 +26542,96 @@ var NotificationCenter = class NotificationCenter {
|
|
|
25504
26542
|
*/
|
|
25505
26543
|
setAlarmPanel(panel) {
|
|
25506
26544
|
this.alarmPanel = panel;
|
|
26545
|
+
if (panel === null || this.panelGateWords?.deviceId !== panel.deviceId) this.panelGateWords = null;
|
|
26546
|
+
}
|
|
26547
|
+
/**
|
|
26548
|
+
* The `deviceState` lookup the engine gets — live path and dry-run alike, so
|
|
26549
|
+
* the tester predicts what the saved rule will do. Every device answers its
|
|
26550
|
+
* one word from `read`; the panel this node owns also answers the armed mode
|
|
26551
|
+
* its `pending`/`triggered` belongs to (D630).
|
|
26552
|
+
*/
|
|
26553
|
+
deviceStateLookup(read) {
|
|
26554
|
+
return panelAwareLookup(read, this.panelGateWords);
|
|
25507
26555
|
}
|
|
25508
26556
|
/**
|
|
25509
26557
|
* What the panel asks before it arms — see {@link NcAlarmArmGuardHook}.
|
|
25510
26558
|
*
|
|
25511
26559
|
* Handed to the panel by the same wiring that hands it the publish hook: the
|
|
25512
26560
|
* panel owns the state machine, the centre owns the rules that say which
|
|
25513
|
-
*
|
|
26561
|
+
* devices a mode covers, and neither can reach the other on its own.
|
|
25514
26562
|
*/
|
|
25515
26563
|
alarmArmGuard() {
|
|
25516
26564
|
return {
|
|
25517
|
-
|
|
25518
|
-
announceRefusal: (input) => this.announceArmRefused(input)
|
|
26565
|
+
openings: (mode) => this.alarmOpenings(mode),
|
|
26566
|
+
announceRefusal: (input) => this.announceArmRefused(input),
|
|
26567
|
+
announceCheckFailed: (input) => this.announceArmCheckFailed(input)
|
|
25519
26568
|
};
|
|
25520
26569
|
}
|
|
25521
26570
|
/**
|
|
25522
|
-
* Which of the
|
|
26571
|
+
* Which of the devices this mode covers are OPEN right now, and which could
|
|
26572
|
+
* not be read (D630).
|
|
25523
26573
|
*
|
|
25524
|
-
* A TARGETED read of at most the handful of
|
|
25525
|
-
* refresh of the whole watched set: the arm is a person pressing a
|
|
25526
|
-
* the answer has to be about this second, but it must not become
|
|
25527
|
-
* over every gated device on the hub.
|
|
26574
|
+
* A TARGETED read of at most the handful of devices the mode's rules name,
|
|
26575
|
+
* not a refresh of the whole watched set: the arm is a person pressing a
|
|
26576
|
+
* button and the answer has to be about this second, but it must not become
|
|
26577
|
+
* a fan-out over every gated device on the hub. Read through
|
|
26578
|
+
* `readDeviceOpenings`, so a cover or a lock that is not secure is open too,
|
|
26579
|
+
* and a camera an intrusion rule names is `not-opening` and takes no part.
|
|
25528
26580
|
*
|
|
25529
|
-
*
|
|
25530
|
-
* ignorance is worse than arming:
|
|
26581
|
+
* Never a guess, in either direction (D49):
|
|
25531
26582
|
*
|
|
25532
|
-
* - a
|
|
25533
|
-
*
|
|
25534
|
-
*
|
|
25535
|
-
* - a mode whose
|
|
25536
|
-
*
|
|
25537
|
-
*
|
|
25538
|
-
*
|
|
26583
|
+
* - a device the read could not answer for (absent), or whose opening slice
|
|
26584
|
+
* carries a word its cap does not define (`unknown`), is UNKNOWN — it
|
|
26585
|
+
* never blocks, is never counted secure, and says so in one line each;
|
|
26586
|
+
* - a mode whose rules name no devices blocks nothing — an unscoped rule
|
|
26587
|
+
* covers every device on the hub, and a refusal naming devices the
|
|
26588
|
+
* operator never associated with the alarm is one they cannot act on;
|
|
26589
|
+
* - when NOTHING could be read (no reader, the read threw, or every id came
|
|
26590
|
+
* back absent) this THROWS, and the panel arms and announces that it
|
|
26591
|
+
* armed without the check.
|
|
25539
26592
|
*/
|
|
25540
|
-
async
|
|
26593
|
+
async alarmOpenings(mode) {
|
|
25541
26594
|
const panel = this.alarmPanel;
|
|
25542
|
-
if (panel === null) return
|
|
25543
|
-
|
|
25544
|
-
|
|
25545
|
-
|
|
26595
|
+
if (panel === null) return {
|
|
26596
|
+
open: [],
|
|
26597
|
+
unknown: []
|
|
26598
|
+
};
|
|
26599
|
+
const candidates = alarmCoveredDevices(this.rules.list(), panel.deviceId, mode);
|
|
26600
|
+
if (candidates.length === 0) return {
|
|
26601
|
+
open: [],
|
|
26602
|
+
unknown: []
|
|
26603
|
+
};
|
|
26604
|
+
const read = this.deps.readDeviceOpenings;
|
|
26605
|
+
if (read === void 0) throw new Error("no openings reader is wired on this node");
|
|
26606
|
+
const openings = await read(candidates);
|
|
26607
|
+
if (openings.size === 0) throw new Error(`none of the ${String(candidates.length)} covered devices could be read`);
|
|
25546
26608
|
const name = this.deviceNameFromDirectory();
|
|
25547
26609
|
const open = [];
|
|
25548
26610
|
const unknown = [];
|
|
25549
26611
|
for (const deviceId of candidates) {
|
|
25550
|
-
const
|
|
25551
|
-
if (
|
|
26612
|
+
const verdict = classifyCoveredOpening(openings.get(deviceId));
|
|
26613
|
+
if (verdict.kind === "unknown") {
|
|
25552
26614
|
unknown.push(deviceId);
|
|
26615
|
+
this.logger.info("alarm arm check: device state unreadable — it cannot block this arm", {
|
|
26616
|
+
tags: { deviceId },
|
|
26617
|
+
meta: {
|
|
26618
|
+
panelDeviceId: panel.deviceId,
|
|
26619
|
+
mode,
|
|
26620
|
+
why: verdict.why,
|
|
26621
|
+
...verdict.why === "unknown-word" ? {
|
|
26622
|
+
cap: verdict.cap,
|
|
26623
|
+
raw: rawForLog(verdict.raw)
|
|
26624
|
+
} : {}
|
|
26625
|
+
}
|
|
26626
|
+
});
|
|
25553
26627
|
continue;
|
|
25554
26628
|
}
|
|
25555
|
-
if (
|
|
26629
|
+
if (verdict.kind === "open") open.push({
|
|
25556
26630
|
deviceId,
|
|
25557
|
-
name: name(deviceId)
|
|
26631
|
+
name: name(deviceId),
|
|
26632
|
+
state: verdict.state
|
|
25558
26633
|
});
|
|
25559
26634
|
}
|
|
25560
|
-
for (const deviceId of unknown) this.logger.info("alarm arm check: no contact state — the sensor cannot block this arm", {
|
|
25561
|
-
tags: { deviceId },
|
|
25562
|
-
meta: {
|
|
25563
|
-
panelDeviceId: panel.deviceId,
|
|
25564
|
-
mode
|
|
25565
|
-
}
|
|
25566
|
-
});
|
|
25567
26635
|
this.logger.info("alarm arm check", {
|
|
25568
26636
|
tags: { deviceId: panel.deviceId },
|
|
25569
26637
|
meta: {
|
|
@@ -25571,55 +26639,198 @@ var NotificationCenter = class NotificationCenter {
|
|
|
25571
26639
|
candidates: candidates.length,
|
|
25572
26640
|
open: open.length,
|
|
25573
26641
|
unknown: unknown.length,
|
|
25574
|
-
openIds: open.map((
|
|
26642
|
+
openIds: open.map((d) => d.deviceId).join(",")
|
|
25575
26643
|
}
|
|
25576
26644
|
});
|
|
25577
|
-
return
|
|
25578
|
-
|
|
25579
|
-
|
|
25580
|
-
|
|
25581
|
-
async readContactStates(ids) {
|
|
25582
|
-
const read = this.deps.readDeviceStates;
|
|
25583
|
-
if (read === void 0) return /* @__PURE__ */ new Map();
|
|
25584
|
-
try {
|
|
25585
|
-
return await read(ids);
|
|
25586
|
-
} catch (err) {
|
|
25587
|
-
this.logger.warn("alarm arm check: contact read failed — falling back to the mirror", { meta: {
|
|
25588
|
-
devices: ids.length,
|
|
25589
|
-
error: String(err)
|
|
25590
|
-
} });
|
|
25591
|
-
return /* @__PURE__ */ new Map();
|
|
25592
|
-
}
|
|
26645
|
+
return {
|
|
26646
|
+
open,
|
|
26647
|
+
unknown
|
|
26648
|
+
};
|
|
25593
26649
|
}
|
|
25594
26650
|
/**
|
|
25595
26651
|
* The refusal, as an ordinary notification.
|
|
25596
26652
|
*
|
|
25597
|
-
* Same path
|
|
25598
|
-
* ordinary notification path), so it is durable, retried, in history and
|
|
26653
|
+
* Same path every alarm announcement takes (D126: the alarm speaks through
|
|
26654
|
+
* the ordinary notification path), so it is durable, retried, in history and
|
|
25599
26655
|
* worded in the reader's language at send time. It is the operator's ONLY
|
|
25600
26656
|
* copy: the thrown error reaches whoever called the cap, and when that caller
|
|
25601
|
-
* is a notification button on a phone, nobody ever sees it.
|
|
26657
|
+
* is a notification button on a phone, nobody ever sees it. The refusal id
|
|
26658
|
+
* rides the transition onto the ROW, where the Arm anyway button is minted
|
|
26659
|
+
* from (D630).
|
|
25602
26660
|
*/
|
|
25603
26661
|
announceArmRefused(input) {
|
|
25604
26662
|
const panel = this.alarmPanel;
|
|
25605
26663
|
if (panel === null) return;
|
|
25606
|
-
|
|
25607
|
-
const transition = {
|
|
26664
|
+
this.scheduleAlarmTransition(panel, {
|
|
25608
26665
|
kind: "alarm-arm-refused",
|
|
25609
|
-
state:
|
|
25610
|
-
previous:
|
|
26666
|
+
state: input.state,
|
|
26667
|
+
previous: input.state,
|
|
26668
|
+
mode: input.refusal.mode,
|
|
26669
|
+
at: input.at,
|
|
26670
|
+
refusalId: input.refusal.id,
|
|
26671
|
+
blocking: input.blocking.map((d) => ({
|
|
26672
|
+
deviceId: d.deviceId,
|
|
26673
|
+
name: d.name,
|
|
26674
|
+
state: d.state
|
|
26675
|
+
}))
|
|
26676
|
+
});
|
|
26677
|
+
}
|
|
26678
|
+
/**
|
|
26679
|
+
* The panel armed WITHOUT the openings check — nothing could be read. A
|
|
26680
|
+
* line on the panel AND an `alarm-check-failed` notification to its
|
|
26681
|
+
* recipients: "armed, but I could not check the windows" is exactly what the
|
|
26682
|
+
* operator has to hear before leaving.
|
|
26683
|
+
*/
|
|
26684
|
+
announceArmCheckFailed(input) {
|
|
26685
|
+
const panel = this.alarmPanel;
|
|
26686
|
+
if (panel === null) return;
|
|
26687
|
+
this.logger.warn("alarm armed WITHOUT the openings check — nothing could be read", {
|
|
26688
|
+
tags: { deviceId: panel.deviceId },
|
|
26689
|
+
meta: {
|
|
26690
|
+
mode: input.mode,
|
|
26691
|
+
at: input.at,
|
|
26692
|
+
error: input.error
|
|
26693
|
+
}
|
|
26694
|
+
});
|
|
26695
|
+
this.scheduleAlarmTransition(panel, {
|
|
26696
|
+
kind: "alarm-check-failed",
|
|
26697
|
+
state: input.state,
|
|
26698
|
+
previous: input.state,
|
|
25611
26699
|
mode: input.mode,
|
|
25612
26700
|
at: input.at
|
|
26701
|
+
});
|
|
26702
|
+
}
|
|
26703
|
+
/**
|
|
26704
|
+
* The alarm, for the `nc.*` bridge — the viewer's and the admin tab's
|
|
26705
|
+
* surface (`nc-actions.ts` decides who may call what). The same paths the cap
|
|
26706
|
+
* and the notification buttons take: `armWith`, `armAnyway`, `disarm` on the
|
|
26707
|
+
* panel, and the one config reader/writer below.
|
|
26708
|
+
*/
|
|
26709
|
+
alarmActions() {
|
|
26710
|
+
const requirePanel = () => {
|
|
26711
|
+
const panel = this.alarmPanel;
|
|
26712
|
+
if (panel === null) throw new Error("this node has no alarm panel");
|
|
26713
|
+
return panel;
|
|
25613
26714
|
};
|
|
25614
|
-
|
|
25615
|
-
|
|
25616
|
-
|
|
25617
|
-
|
|
25618
|
-
|
|
26715
|
+
return {
|
|
26716
|
+
status: async () => {
|
|
26717
|
+
const panel = this.alarmPanel;
|
|
26718
|
+
if (panel === null) return {
|
|
26719
|
+
deviceId: null,
|
|
26720
|
+
live: null
|
|
26721
|
+
};
|
|
26722
|
+
return {
|
|
26723
|
+
deviceId: panel.deviceId,
|
|
26724
|
+
live: await this.alarmLive(panel)
|
|
26725
|
+
};
|
|
26726
|
+
},
|
|
26727
|
+
arm: async (mode, userId) => {
|
|
26728
|
+
const panel = requirePanel();
|
|
26729
|
+
const outcome = await panel.armWith(mode);
|
|
26730
|
+
this.logger.info("alarm arm requested through the bridge", {
|
|
26731
|
+
tags: { deviceId: panel.deviceId },
|
|
26732
|
+
meta: {
|
|
26733
|
+
userId,
|
|
26734
|
+
mode,
|
|
26735
|
+
outcome: outcome.outcome
|
|
26736
|
+
}
|
|
26737
|
+
});
|
|
26738
|
+
return outcome;
|
|
26739
|
+
},
|
|
26740
|
+
armAnyway: async (refusalId, userId) => {
|
|
26741
|
+
const panel = requirePanel();
|
|
26742
|
+
const outcome = await panel.armAnyway(refusalId);
|
|
26743
|
+
this.logger.info("alarm Arm anyway requested through the bridge", {
|
|
26744
|
+
tags: { deviceId: panel.deviceId },
|
|
26745
|
+
meta: {
|
|
26746
|
+
userId,
|
|
26747
|
+
refusalId,
|
|
26748
|
+
outcome: outcome.outcome
|
|
26749
|
+
}
|
|
26750
|
+
});
|
|
26751
|
+
return outcome;
|
|
26752
|
+
},
|
|
26753
|
+
disarm: async (userId) => {
|
|
26754
|
+
const panel = requirePanel();
|
|
26755
|
+
await panel.disarm();
|
|
26756
|
+
this.logger.info("alarm disarm requested through the bridge", {
|
|
26757
|
+
tags: { deviceId: panel.deviceId },
|
|
26758
|
+
meta: { userId }
|
|
26759
|
+
});
|
|
26760
|
+
},
|
|
26761
|
+
getConfig: () => this.alarmConfig(),
|
|
26762
|
+
setConfig: (patch) => this.setAlarmConfig(patch)
|
|
26763
|
+
};
|
|
26764
|
+
}
|
|
26765
|
+
async setAlarmConfig(patch) {
|
|
26766
|
+
const panel = this.alarmPanel;
|
|
26767
|
+
if (panel === null) throw new Error("this node has no alarm panel");
|
|
26768
|
+
const settings = await panel.applySettings(patch);
|
|
26769
|
+
this.logger.info("alarm settings changed", {
|
|
26770
|
+
tags: { deviceId: panel.deviceId },
|
|
26771
|
+
meta: {
|
|
26772
|
+
exitDelaySec: settings.exitDelaySec,
|
|
26773
|
+
entryDelaySec: settings.entryDelaySec,
|
|
26774
|
+
triggeredDurationSec: settings.triggeredDurationSec,
|
|
26775
|
+
announceTargets: settings.announceTargets.length
|
|
26776
|
+
}
|
|
26777
|
+
});
|
|
26778
|
+
return this.alarmConfig();
|
|
26779
|
+
}
|
|
26780
|
+
/**
|
|
26781
|
+
* The panel as it is NOW — see `alarm-live.ts`.
|
|
26782
|
+
*
|
|
26783
|
+
* ONE read of the union of every mode's covered devices, so each is read
|
|
26784
|
+
* once however many modes cover it. A read that fails is not an error here:
|
|
26785
|
+
* every covered device is then `unknown` (D49) and the line says why — a
|
|
26786
|
+
* status the UI polls must not fail because a contact is asleep.
|
|
26787
|
+
*/
|
|
26788
|
+
async alarmLive(panel) {
|
|
26789
|
+
const modes = panel.availableModes();
|
|
26790
|
+
const rules = this.rules.list();
|
|
26791
|
+
const covered = new Map(modes.map((mode) => [mode, alarmCoveredDevices(rules, panel.deviceId, mode)]));
|
|
26792
|
+
const ids = alarmLiveReadIds(covered);
|
|
26793
|
+
let openings = /* @__PURE__ */ new Map();
|
|
26794
|
+
const read = this.deps.readDeviceOpenings;
|
|
26795
|
+
let readThrew = false;
|
|
26796
|
+
if (ids.length > 0 && read === void 0) this.logger.warn("alarm live view: no openings reader is wired — every opening is unknown", {
|
|
26797
|
+
tags: { deviceId: panel.deviceId },
|
|
26798
|
+
meta: { covered: ids.length }
|
|
26799
|
+
});
|
|
26800
|
+
else if (ids.length > 0 && read !== void 0) try {
|
|
26801
|
+
openings = await read(ids);
|
|
26802
|
+
} catch (err) {
|
|
26803
|
+
readThrew = true;
|
|
26804
|
+
this.logger.warn("alarm live view: openings read failed — every opening is unknown", {
|
|
26805
|
+
tags: { deviceId: panel.deviceId },
|
|
26806
|
+
meta: {
|
|
26807
|
+
covered: ids.length,
|
|
26808
|
+
error: err instanceof Error ? err.message : String(err)
|
|
26809
|
+
}
|
|
26810
|
+
});
|
|
26811
|
+
}
|
|
26812
|
+
const unanswered = ids.filter((id) => !openings.has(id));
|
|
26813
|
+
if (read !== void 0 && !readThrew && unanswered.length > 0) this.logger.warn("alarm live view: covered devices did not answer — shown as unknown", {
|
|
26814
|
+
tags: { deviceId: panel.deviceId },
|
|
26815
|
+
meta: {
|
|
26816
|
+
covered: ids.length,
|
|
26817
|
+
unanswered: unanswered.length,
|
|
26818
|
+
unansweredIds: unanswered.join(",")
|
|
26819
|
+
}
|
|
26820
|
+
});
|
|
26821
|
+
return buildAlarmLive({
|
|
26822
|
+
state: panel.currentState(),
|
|
26823
|
+
target: panel.currentTarget(),
|
|
26824
|
+
modes,
|
|
26825
|
+
covered,
|
|
26826
|
+
openings,
|
|
26827
|
+
nonBlocking: panel.settings().nonBlocking,
|
|
26828
|
+
exclusions: panel.exclusionPhases(),
|
|
26829
|
+
name: this.deviceNameFromDirectory()
|
|
25619
26830
|
});
|
|
25620
26831
|
}
|
|
25621
|
-
/** Settings + the DERIVED coverage. Read together — see the cap doc. */
|
|
25622
|
-
alarmConfig() {
|
|
26832
|
+
/** Settings + the DERIVED coverage + the live panel. Read together — see the cap doc. */
|
|
26833
|
+
async alarmConfig() {
|
|
25623
26834
|
const panel = this.alarmPanel;
|
|
25624
26835
|
if (panel === null) return {
|
|
25625
26836
|
deviceId: null,
|
|
@@ -25629,6 +26840,7 @@ var NotificationCenter = class NotificationCenter {
|
|
|
25629
26840
|
return {
|
|
25630
26841
|
deviceId: panel.deviceId,
|
|
25631
26842
|
settings: panel.settings(),
|
|
26843
|
+
live: await this.alarmLive(panel),
|
|
25632
26844
|
coverage: this.coverageFor(panel).map((c) => ({
|
|
25633
26845
|
...c,
|
|
25634
26846
|
deviceIds: [...c.deviceIds],
|
|
@@ -25772,6 +26984,10 @@ var NotificationCenter = class NotificationCenter {
|
|
|
25772
26984
|
const panel = this.alarmPanel;
|
|
25773
26985
|
if (panel === null) return;
|
|
25774
26986
|
this.deviceStates.note(panel.deviceId, report.state);
|
|
26987
|
+
this.panelGateWords = {
|
|
26988
|
+
deviceId: panel.deviceId,
|
|
26989
|
+
words: alarmGateWords(report.state, report.target)
|
|
26990
|
+
};
|
|
25775
26991
|
const transition = report.transition;
|
|
25776
26992
|
if (transition === null) return;
|
|
25777
26993
|
this.scheduleAlarmTransition(panel, transition);
|
|
@@ -25783,28 +26999,29 @@ var NotificationCenter = class NotificationCenter {
|
|
|
25783
26999
|
* On the SAME serialized chain as every other evaluation, because both of
|
|
25784
27000
|
* those follow-ups read what the evaluation just enqueued:
|
|
25785
27001
|
*
|
|
25786
|
-
* -
|
|
25787
|
-
* rule addresses — but only where a
|
|
25788
|
-
* a target that is both would get
|
|
27002
|
+
* - EVERY alarm transition is also announced to the panel's own
|
|
27003
|
+
* `announceTargets`, which no rule addresses (D630) — but only where a
|
|
27004
|
+
* rule has not already delivered it, or a target that is both would get
|
|
27005
|
+
* the same sentence twice;
|
|
25789
27006
|
* - a TRIGGERED with an attribution supersedes the triggering rule's own
|
|
25790
27007
|
* still-pending rows, for exactly the targets the alarm reached. That is
|
|
25791
27008
|
* the combined notification: one row carrying the alarm's words and that
|
|
25792
27009
|
* rule's pictures, instead of two pushes about one incident.
|
|
25793
27010
|
*/
|
|
25794
|
-
scheduleAlarmTransition(panel, transition
|
|
25795
|
-
const
|
|
27011
|
+
scheduleAlarmTransition(panel, transition) {
|
|
27012
|
+
const announcement = this.alarmAnnouncement(panel, transition);
|
|
25796
27013
|
const incoming = incomingFromAlarmTransition({
|
|
25797
27014
|
transition,
|
|
25798
27015
|
panelDeviceId: panel.deviceId,
|
|
25799
27016
|
lookup: this.deviceDirectory.lookup(),
|
|
25800
|
-
...
|
|
27017
|
+
...announcement.text !== null ? { body: announcement.text } : {}
|
|
25801
27018
|
});
|
|
25802
27019
|
this.evalChain = this.evalChain.then(async () => {
|
|
25803
27020
|
try {
|
|
25804
27021
|
const report = await this.evaluateAndEnqueue(incoming);
|
|
25805
27022
|
const reached = new Set(report.outcomes.flatMap((o) => o.targetIds));
|
|
25806
|
-
|
|
25807
|
-
await this.settleCombinedAlarm(transition, incoming, report, reached);
|
|
27023
|
+
const announced = await this.enqueueAlarmAnnouncement(panel, incoming, announcement, reached);
|
|
27024
|
+
await this.settleCombinedAlarm(transition, incoming, report, reached, announced);
|
|
25808
27025
|
} catch (err) {
|
|
25809
27026
|
this.logger.warn("alarm transition evaluation failed", {
|
|
25810
27027
|
tags: { deviceId: panel.deviceId },
|
|
@@ -25818,28 +27035,41 @@ var NotificationCenter = class NotificationCenter {
|
|
|
25818
27035
|
});
|
|
25819
27036
|
}
|
|
25820
27037
|
/**
|
|
25821
|
-
* The
|
|
25822
|
-
*
|
|
27038
|
+
* The sentence an alarm transition is said with, and the targets it goes to
|
|
27039
|
+
* — for EVERY alarm kind, always the panel's `announceTargets` (D630: the
|
|
27040
|
+
* `announceArm` switch is gone; rules may add more recipients, never fewer).
|
|
25823
27041
|
*
|
|
25824
|
-
*
|
|
25825
|
-
* watch" — because they are the same answer given thirty seconds apart. The
|
|
25826
|
-
* arming one is the useful one: it reaches the operator while the exit delay
|
|
25827
|
-
* is still running, so a mode that protects nothing can still be fixed.
|
|
27042
|
+
* `text` is a composed body only where the words depend on the rule set:
|
|
25828
27043
|
*
|
|
25829
|
-
*
|
|
25830
|
-
*
|
|
25831
|
-
*
|
|
25832
|
-
*
|
|
25833
|
-
*
|
|
25834
|
-
*
|
|
27044
|
+
* - ARM and ARMING compose from the SAME coverage — "what does this mode
|
|
27045
|
+
* actually watch" — because they are the same answer given thirty seconds
|
|
27046
|
+
* apart. A COVERAGE GAP does not suppress the sentence, it changes it.
|
|
27047
|
+
* - a REFUSAL names the devices that refused it, and — since its row
|
|
27048
|
+
* carries the Arm anyway — where the button is.
|
|
27049
|
+
*
|
|
27050
|
+
* Every other kind is `null`: the intake picks its catalog body and params.
|
|
25835
27051
|
*/
|
|
25836
|
-
|
|
27052
|
+
alarmAnnouncement(panel, transition) {
|
|
27053
|
+
const targetIds = panel.settings().announceTargets;
|
|
27054
|
+
if (targetIds.length === 0) this.logger.info("alarm transition has no announcement target", {
|
|
27055
|
+
tags: { deviceId: panel.deviceId },
|
|
27056
|
+
meta: { kind: transition.kind }
|
|
27057
|
+
});
|
|
27058
|
+
return {
|
|
27059
|
+
text: this.alarmAnnouncementText(panel, transition),
|
|
27060
|
+
targetIds
|
|
27061
|
+
};
|
|
27062
|
+
}
|
|
27063
|
+
/** The composed body of {@link alarmAnnouncement}, or null for a catalog body. */
|
|
27064
|
+
alarmAnnouncementText(panel, transition) {
|
|
25837
27065
|
const kind = transition.kind;
|
|
25838
|
-
if (kind !== "alarm-armed" && kind !== "alarm-arming") return null;
|
|
25839
27066
|
const mode = transition.mode;
|
|
27067
|
+
if (kind === "alarm-arm-refused") {
|
|
27068
|
+
if (mode === void 0) return null;
|
|
27069
|
+
return buildArmRefusedAnnouncement(mode, (transition.blocking ?? []).map((d) => d.name), transition.refusalId !== void 0);
|
|
27070
|
+
}
|
|
27071
|
+
if (kind !== "alarm-armed" && kind !== "alarm-arming") return null;
|
|
25840
27072
|
if (mode === void 0) return null;
|
|
25841
|
-
const settings = panel.settings();
|
|
25842
|
-
const tags = { deviceId: panel.deviceId };
|
|
25843
27073
|
const coverage = this.coverageFor(panel).find((c) => c.mode === mode);
|
|
25844
27074
|
if (coverage === void 0) return null;
|
|
25845
27075
|
const name = this.deviceNameFromDirectory();
|
|
@@ -25847,7 +27077,7 @@ var NotificationCenter = class NotificationCenter {
|
|
|
25847
27077
|
const text = kind === "alarm-arming" ? buildArmingAnnouncement(coverage, transition.seconds ?? 0, name, bypassed) : buildArmAnnouncement(coverage, name, bypassed);
|
|
25848
27078
|
const gap = alarmCoverageGap(coverage);
|
|
25849
27079
|
if (gap !== null) this.logger.warn("alarm armed into a mode that watches nothing", {
|
|
25850
|
-
tags,
|
|
27080
|
+
tags: { deviceId: panel.deviceId },
|
|
25851
27081
|
meta: {
|
|
25852
27082
|
mode,
|
|
25853
27083
|
kind,
|
|
@@ -25857,41 +27087,27 @@ var NotificationCenter = class NotificationCenter {
|
|
|
25857
27087
|
skipped: coverage.skippedDevices.length
|
|
25858
27088
|
}
|
|
25859
27089
|
});
|
|
25860
|
-
|
|
25861
|
-
text,
|
|
25862
|
-
targetIds: []
|
|
25863
|
-
};
|
|
25864
|
-
if (settings.announceTargets.length === 0) {
|
|
25865
|
-
this.logger.info("alarm armed but no announcement target is configured", {
|
|
25866
|
-
tags,
|
|
25867
|
-
meta: { mode }
|
|
25868
|
-
});
|
|
25869
|
-
return {
|
|
25870
|
-
text,
|
|
25871
|
-
targetIds: []
|
|
25872
|
-
};
|
|
25873
|
-
}
|
|
25874
|
-
return {
|
|
25875
|
-
text,
|
|
25876
|
-
targetIds: settings.announceTargets
|
|
25877
|
-
};
|
|
27090
|
+
return text;
|
|
25878
27091
|
}
|
|
25879
27092
|
/**
|
|
25880
|
-
* Put the
|
|
27093
|
+
* Put the alarm announcement in the outbox, under {@link NC_ALARM_ANNOUNCE_RULE_ID}.
|
|
25881
27094
|
*
|
|
25882
27095
|
* The row is what makes this a notification like any other: durable, retried,
|
|
25883
27096
|
* visible in history, and rendered in the reader's language at send time
|
|
25884
|
-
* rather than frozen in the hub's at
|
|
25885
|
-
* own id, so every
|
|
25886
|
-
* is deduped.
|
|
27097
|
+
* rather than frozen in the hub's at transition time. `recordId` is the
|
|
27098
|
+
* transition's own id, so every transition is a new row (cooldown 0) and a
|
|
27099
|
+
* replay of the same one is deduped.
|
|
25887
27100
|
*
|
|
25888
|
-
* Targets a RULE already reached are skipped — one
|
|
25889
|
-
* push per person, whichever path addressed them.
|
|
27101
|
+
* Targets a RULE already reached are skipped — one transition, one sentence,
|
|
27102
|
+
* one push per person, whichever path addressed them.
|
|
25890
27103
|
*/
|
|
25891
|
-
async
|
|
25892
|
-
const targets =
|
|
25893
|
-
if (targets.length === 0) return;
|
|
27104
|
+
async enqueueAlarmAnnouncement(panel, incoming, announcement, reached) {
|
|
27105
|
+
const targets = announcement.targetIds.filter((id) => !reached.has(id));
|
|
27106
|
+
if (targets.length === 0) return [];
|
|
25894
27107
|
const subject = incoming.subject;
|
|
27108
|
+
const alarmSource = subject.systemEvent?.alarmSource;
|
|
27109
|
+
const sourceRule = alarmSource !== void 0 ? this.rules.get(alarmSource.ruleId) : null;
|
|
27110
|
+
const media = sourceRule !== null ? mediaPolicyFields(sourceRule, void 0) : { media: "none" };
|
|
25895
27111
|
const entries = targets.map((targetId) => ({
|
|
25896
27112
|
ruleId: NC_ALARM_ANNOUNCE_RULE_ID,
|
|
25897
27113
|
targetId,
|
|
@@ -25902,9 +27118,11 @@ var NotificationCenter = class NotificationCenter {
|
|
|
25902
27118
|
ruleName: "alarm",
|
|
25903
27119
|
delivery: "system-event",
|
|
25904
27120
|
priority: 2,
|
|
25905
|
-
media
|
|
27121
|
+
...media,
|
|
25906
27122
|
subject: {
|
|
25907
27123
|
deviceId: panel.deviceId,
|
|
27124
|
+
...alarmSource?.trackId !== void 0 ? { trackId: alarmSource.trackId } : {},
|
|
27125
|
+
...alarmSource?.eventId !== void 0 ? { eventId: alarmSource.eventId } : {},
|
|
25908
27126
|
className: "alarm",
|
|
25909
27127
|
zones: [],
|
|
25910
27128
|
timestamp: subject.timestamp,
|
|
@@ -25913,14 +27131,17 @@ var NotificationCenter = class NotificationCenter {
|
|
|
25913
27131
|
}
|
|
25914
27132
|
}));
|
|
25915
27133
|
const inserted = await this.outbox.enqueue(entries);
|
|
25916
|
-
this.logger.info("alarm
|
|
27134
|
+
this.logger.info("alarm announcement enqueued", {
|
|
25917
27135
|
tags: { deviceId: panel.deviceId },
|
|
25918
27136
|
meta: {
|
|
27137
|
+
kind: subject.systemEvent?.kind,
|
|
25919
27138
|
targets: targets.length,
|
|
25920
27139
|
inserted,
|
|
25921
|
-
bodyKey:
|
|
27140
|
+
bodyKey: subject.systemEvent?.bodyKey,
|
|
27141
|
+
media: media.media
|
|
25922
27142
|
}
|
|
25923
27143
|
});
|
|
27144
|
+
return targets;
|
|
25924
27145
|
}
|
|
25925
27146
|
/**
|
|
25926
27147
|
* Deliver to targets the producer named (`controls.directTargetIds`).
|
|
@@ -25979,12 +27200,12 @@ var NotificationCenter = class NotificationCenter {
|
|
|
25979
27200
|
* went out twenty seconds ago — that is not a duplicate, it is the
|
|
25980
27201
|
* escalation, and this says so rather than silently doing nothing.
|
|
25981
27202
|
*/
|
|
25982
|
-
async settleCombinedAlarm(transition, incoming, report, reached) {
|
|
27203
|
+
async settleCombinedAlarm(transition, incoming, report, reached, announced) {
|
|
25983
27204
|
if (transition.kind !== "alarm-triggered") return;
|
|
25984
27205
|
const tags = { deviceId: incoming.subject.deviceId };
|
|
25985
27206
|
const source = transition.source;
|
|
25986
|
-
if (reached.size === 0) {
|
|
25987
|
-
this.logger.warn("alarm TRIGGERED but
|
|
27207
|
+
if (reached.size === 0 && announced.length === 0) {
|
|
27208
|
+
this.logger.warn("alarm TRIGGERED but neither a rule nor an announce target heard it", {
|
|
25988
27209
|
tags,
|
|
25989
27210
|
meta: {
|
|
25990
27211
|
candidates: report.candidates,
|
|
@@ -25994,19 +27215,21 @@ var NotificationCenter = class NotificationCenter {
|
|
|
25994
27215
|
return;
|
|
25995
27216
|
}
|
|
25996
27217
|
if (source === void 0) return;
|
|
27218
|
+
const combined = new Set([...reached, ...announced]);
|
|
25997
27219
|
const dropped = await this.outbox.supersede({
|
|
25998
27220
|
ruleId: source.ruleId,
|
|
25999
27221
|
recordId: source.recordId,
|
|
26000
|
-
targetIds: [...
|
|
27222
|
+
targetIds: [...combined]
|
|
26001
27223
|
});
|
|
26002
27224
|
this.logger.info("alarm notification combined with the rule that triggered it", {
|
|
26003
27225
|
tags,
|
|
26004
27226
|
meta: {
|
|
26005
27227
|
sourceRuleId: source.ruleId,
|
|
26006
27228
|
rule: source.ruleName,
|
|
26007
|
-
targets:
|
|
27229
|
+
targets: combined.size,
|
|
27230
|
+
announced: announced.length,
|
|
26008
27231
|
superseded: dropped,
|
|
26009
|
-
alreadyDelivered:
|
|
27232
|
+
alreadyDelivered: combined.size - dropped
|
|
26010
27233
|
}
|
|
26011
27234
|
});
|
|
26012
27235
|
}
|
|
@@ -26106,7 +27329,7 @@ var NotificationCenter = class NotificationCenter {
|
|
|
26106
27329
|
let noOwnerReported = false;
|
|
26107
27330
|
const rejections = [];
|
|
26108
27331
|
for (const rule of candidates) {
|
|
26109
|
-
const evaluation = evaluateRule(rule, subject, (id) => this.deviceStates.get(id), (id) => this.zoneOwners.get(id), (sceneId) => this.sceneStates.get(sceneId));
|
|
27332
|
+
const evaluation = evaluateRule(rule, subject, this.deviceStateLookup((id) => this.deviceStates.get(id)), (id) => this.zoneOwners.get(id), (sceneId) => this.sceneStates.get(sceneId));
|
|
26110
27333
|
if (!evaluation.matched) {
|
|
26111
27334
|
if (evaluation.failedCondition === "scene") this.reportSceneSuppression(rule, subject);
|
|
26112
27335
|
if (evaluation.excludedBySystemEventFilter !== void 0) this.reportSystemFilterExclusion(rule, subject, evaluation.excludedBySystemEventFilter);
|
|
@@ -26128,6 +27351,7 @@ var NotificationCenter = class NotificationCenter {
|
|
|
26128
27351
|
kind,
|
|
26129
27352
|
failed: evaluation.failedCondition,
|
|
26130
27353
|
classes: subject.classNames,
|
|
27354
|
+
...subject.sensorEdge !== void 0 ? { sensorEdge: subject.sensorEdge } : {},
|
|
26131
27355
|
eventId: subject.recordId,
|
|
26132
27356
|
...subject.trackId !== void 0 ? { trackId: subject.trackId } : {}
|
|
26133
27357
|
}
|
|
@@ -26251,15 +27475,24 @@ var NotificationCenter = class NotificationCenter {
|
|
|
26251
27475
|
});
|
|
26252
27476
|
if (!noOwnerReported && kind === "device-event" && subject.trackId === void 0 && rule.media.attach !== "none") {
|
|
26253
27477
|
noOwnerReported = true;
|
|
26254
|
-
|
|
27478
|
+
const linked = subject.linkedCameraIds;
|
|
27479
|
+
const meta = {
|
|
27480
|
+
recordId: subject.recordId,
|
|
27481
|
+
...subject.sensorKind !== void 0 ? { sensorKind: subject.sensorKind } : {},
|
|
27482
|
+
ruleId: rule.id,
|
|
27483
|
+
rule: rule.name
|
|
27484
|
+
};
|
|
27485
|
+
if (linked !== void 0) this.logger.debug(linked.length === 0 ? "device event from a sensor linked to no camera — this notification ships text-only" : `device event from a sensor linked to ${linked.length} camera(s), none opted into tracked events — this notification ships text-only`, {
|
|
26255
27486
|
tags: { deviceId: subject.deviceId },
|
|
26256
27487
|
meta: {
|
|
26257
|
-
|
|
26258
|
-
|
|
26259
|
-
ruleId: rule.id,
|
|
26260
|
-
rule: rule.name
|
|
27488
|
+
...meta,
|
|
27489
|
+
linkedCameraIds: [...linked]
|
|
26261
27490
|
}
|
|
26262
27491
|
});
|
|
27492
|
+
else this.logger.warn("device event has no marker track — this notification ships text-only", {
|
|
27493
|
+
tags: { deviceId: subject.deviceId },
|
|
27494
|
+
meta
|
|
27495
|
+
});
|
|
26263
27496
|
}
|
|
26264
27497
|
if (!noOwnerReported && (kind === "audio-window" || kind === "audio-event") && subject.trackId === void 0 && rule.media.attach !== "none") {
|
|
26265
27498
|
noOwnerReported = true;
|
|
@@ -26587,20 +27820,11 @@ var NotificationCenter = class NotificationCenter {
|
|
|
26587
27820
|
...upgrade !== void 0 ? { enhancement: true } : {},
|
|
26588
27821
|
priority: rule.priority,
|
|
26589
27822
|
...rule.template !== void 0 ? { template: rule.template } : {},
|
|
26590
|
-
|
|
26591
|
-
...!noMedia && mediaRule.media.zoneCrop === true && cropZoneIds !== void 0 ? { mediaZoneIds: [...cropZoneIds] } : {},
|
|
26592
|
-
...!noMedia && cropZoneIds !== void 0 ? { triggerZoneIds: [...cropZoneIds] } : {},
|
|
27823
|
+
...noMedia ? { media: "none" } : mediaPolicyFields(mediaRule, cropZoneIds),
|
|
26593
27824
|
...confirm !== void 0 ? {
|
|
26594
27825
|
confirm,
|
|
26595
27826
|
...cooldown !== void 0 ? { cooldown } : {}
|
|
26596
27827
|
} : {},
|
|
26597
|
-
...!noMedia && mediaRule.media.frame !== void 0 ? { mediaFrame: mediaRule.media.frame } : {},
|
|
26598
|
-
...!noMedia && mediaRule.media.profile !== void 0 ? { mediaProfile: mediaRule.media.profile } : {},
|
|
26599
|
-
...!noMedia && mediaRule.media.gif === true ? { mediaGif: true } : {},
|
|
26600
|
-
...!noMedia && mediaRule.media.clip === true ? { mediaClip: true } : {},
|
|
26601
|
-
...!noMedia && mediaRule.media.clipPreRollSec !== void 0 ? { mediaClipPreRollSec: mediaRule.media.clipPreRollSec } : {},
|
|
26602
|
-
...!noMedia && mediaRule.media?.clipSpeed !== void 0 ? { mediaClipSpeed: mediaRule.media.clipSpeed } : {},
|
|
26603
|
-
...!noMedia && mediaRule.media.clipPostRollSec !== void 0 ? { mediaClipPostRollSec: mediaRule.media.clipPostRollSec } : {},
|
|
26604
27828
|
...matchedOn !== void 0 && matchedOn.length > 0 ? { matchedOn } : {},
|
|
26605
27829
|
...group !== void 0 && group.members.length > 1 ? { group: {
|
|
26606
27830
|
key: group.key,
|
|
@@ -26831,8 +28055,6 @@ var NotificationCenter = class NotificationCenter {
|
|
|
26831
28055
|
const devices = rule.conditions.devices;
|
|
26832
28056
|
if (zones !== void 0 && zones.ids.length > 0 && devices !== void 0) zoneScoped.push(...devices);
|
|
26833
28057
|
}
|
|
26834
|
-
const panel = this.alarmPanel;
|
|
26835
|
-
if (panel !== null) gated.push(...alarmContactWatchList(this.rules.list(), panel.deviceId, panel.availableModes()));
|
|
26836
28058
|
this.deviceStates.setWatched(gated);
|
|
26837
28059
|
this.zoneOwners.setWatched(zoneScoped);
|
|
26838
28060
|
this.sceneStates.setWatched(sceneIds);
|
|
@@ -26846,21 +28068,41 @@ var NotificationCenter = class NotificationCenter {
|
|
|
26846
28068
|
}
|
|
26847
28069
|
}
|
|
26848
28070
|
/**
|
|
26849
|
-
*
|
|
28071
|
+
* Read every excluded device's opening and feed it back to the panel.
|
|
26850
28072
|
*
|
|
26851
28073
|
* The reconcile half of the two-phase lift. The EVENT half
|
|
26852
|
-
* ({@link
|
|
26853
|
-
*
|
|
26854
|
-
*
|
|
26855
|
-
*
|
|
26856
|
-
|
|
26857
|
-
|
|
28074
|
+
* ({@link observeOpeningEvent}) is the fast one, but an event can be dropped
|
|
28075
|
+
* (D8), so the tick re-reads the handful of excluded devices — through the
|
|
28076
|
+
* same `readDeviceOpenings` table the arm check uses, so a cover lifts on
|
|
28077
|
+
* `closed` and a lock on `locked` exactly as on the event path. A device the
|
|
28078
|
+
* read cannot answer for, or that reads unknown, changes nothing (D49).
|
|
28079
|
+
* Never throws into the tick.
|
|
28080
|
+
*/
|
|
28081
|
+
async observeAlarmOpenings() {
|
|
26858
28082
|
const panel = this.alarmPanel;
|
|
26859
|
-
|
|
28083
|
+
const read = this.deps.readDeviceOpenings;
|
|
28084
|
+
if (panel === null || read === void 0) return;
|
|
26860
28085
|
const excluded = panel.excludedDevices();
|
|
26861
28086
|
if (excluded.length === 0) return;
|
|
28087
|
+
let openings;
|
|
28088
|
+
try {
|
|
28089
|
+
openings = await read(excluded);
|
|
28090
|
+
} catch (err) {
|
|
28091
|
+
this.logger.warn("alarm exclusion reconcile: the openings read failed — nothing lifted", {
|
|
28092
|
+
tags: { deviceId: panel.deviceId },
|
|
28093
|
+
meta: {
|
|
28094
|
+
excluded: excluded.length,
|
|
28095
|
+
error: String(err)
|
|
28096
|
+
}
|
|
28097
|
+
});
|
|
28098
|
+
return;
|
|
28099
|
+
}
|
|
26862
28100
|
const now = this.now();
|
|
26863
|
-
for (const deviceId of excluded)
|
|
28101
|
+
for (const deviceId of excluded) {
|
|
28102
|
+
const opening = openings.get(deviceId);
|
|
28103
|
+
if (opening?.kind !== "reading") continue;
|
|
28104
|
+
panel.observeOpening(deviceId, opening.reading, now);
|
|
28105
|
+
}
|
|
26864
28106
|
}
|
|
26865
28107
|
/**
|
|
26866
28108
|
* Log the watched occupancy key set, on CHANGE only.
|
|
@@ -26999,7 +28241,7 @@ var NotificationCenter = class NotificationCenter {
|
|
|
26999
28241
|
await this.deviceDirectory.refresh();
|
|
27000
28242
|
await this.zoneOwners.refresh();
|
|
27001
28243
|
this.sceneStates.refresh();
|
|
27002
|
-
this.
|
|
28244
|
+
await this.observeAlarmOpenings();
|
|
27003
28245
|
await this.occupancyStore.pruneExcept(this.activeOccupancyKeys());
|
|
27004
28246
|
}
|
|
27005
28247
|
/**
|
|
@@ -27301,11 +28543,11 @@ var NotificationCenter = class NotificationCenter {
|
|
|
27301
28543
|
const tracks = await this.deps.listRecentTracks(since, TEST_RULE_MAX_RESULTS);
|
|
27302
28544
|
for (const track of tracks) subjects.push(incomingFromTrack(track).subject);
|
|
27303
28545
|
}
|
|
27304
|
-
let lookup = (id) => this.deviceStates.get(id);
|
|
28546
|
+
let lookup = this.deviceStateLookup((id) => this.deviceStates.get(id));
|
|
27305
28547
|
const gated = rule.conditions.deviceState;
|
|
27306
28548
|
if (gated !== void 0 && this.deps.readDeviceStates !== void 0) try {
|
|
27307
28549
|
const fresh = await this.deps.readDeviceStates([gated.deviceId]);
|
|
27308
|
-
lookup = (id) => fresh.get(id) ?? this.deviceStates.get(id);
|
|
28550
|
+
lookup = this.deviceStateLookup((id) => fresh.get(id) ?? this.deviceStates.get(id));
|
|
27309
28551
|
} catch {}
|
|
27310
28552
|
let zoneLookup = this.zoneOwners.lookup();
|
|
27311
28553
|
const zoneScope = rule.conditions.devices;
|
|
@@ -27569,15 +28811,28 @@ function systemEventKeys(kind) {
|
|
|
27569
28811
|
titleKey: `system.${kind}.title`,
|
|
27570
28812
|
bodyKey: `system.${kind}.body.agent`
|
|
27571
28813
|
};
|
|
27572
|
-
if (kind
|
|
28814
|
+
if (PER_MODE_TITLE_KINDS.has(kind)) return {
|
|
27573
28815
|
titleKey: `system.${kind}.title.${TEST_ALARM_MODE}`,
|
|
27574
28816
|
bodyKey: `system.${kind}.body`
|
|
27575
28817
|
};
|
|
28818
|
+
if (kind === "alarm-sensor-bypass") return {
|
|
28819
|
+
titleKey: `system.${kind}.title`,
|
|
28820
|
+
bodyKey: `system.${kind}.body.ignored`
|
|
28821
|
+
};
|
|
27576
28822
|
return {
|
|
27577
28823
|
titleKey: `system.${kind}.title`,
|
|
27578
28824
|
bodyKey: `system.${kind}.body`
|
|
27579
28825
|
};
|
|
27580
28826
|
}
|
|
28827
|
+
/** The alarm kinds whose TITLE key is per arm mode (`….title.<mode>`). */
|
|
28828
|
+
var PER_MODE_TITLE_KINDS = new Set([
|
|
28829
|
+
"alarm-arm-refused",
|
|
28830
|
+
"alarm-check-failed",
|
|
28831
|
+
"alarm-arming",
|
|
28832
|
+
"alarm-armed",
|
|
28833
|
+
"alarm-pending",
|
|
28834
|
+
"alarm-rearmed"
|
|
28835
|
+
]);
|
|
27581
28836
|
/** The arm mode a synthetic alarm event stands in for — see {@link systemEventKeys}. */
|
|
27582
28837
|
var TEST_ALARM_MODE = "away";
|
|
27583
28838
|
/** The runner a synthetic `addon-crash-loop` stands in for. A test fire has no
|
|
@@ -27812,6 +29067,17 @@ var NcTrackGroupSchema = object({
|
|
|
27812
29067
|
sealed: boolean()
|
|
27813
29068
|
});
|
|
27814
29069
|
/**
|
|
29070
|
+
* `nc.getAlarmStatus` — the panel as it is now (admin-only for now; see the
|
|
29071
|
+
* envelope's comment). `deviceId: null`
|
|
29072
|
+
* with `live: null` is a node with no panel, which a client must not read as
|
|
29073
|
+
* "disarmed, nothing open". `live` is the published `NcAlarmConfig.live`
|
|
29074
|
+
* schema, taken from it rather than re-declared.
|
|
29075
|
+
*/
|
|
29076
|
+
var NcAlarmStatusSchema = object({
|
|
29077
|
+
deviceId: number().int().nullable(),
|
|
29078
|
+
live: NcAlarmConfigSchema.shape.live.unwrap().nullable()
|
|
29079
|
+
});
|
|
29080
|
+
/**
|
|
27815
29081
|
* The action catalog — the tRPC contract Group B (the viewer client) consumes
|
|
27816
29082
|
* against `addonId: 'pipeline-analytics'`. Every entry that depends on the
|
|
27817
29083
|
* caller identity is declared `caller: 'required'` so the dispatcher forwards
|
|
@@ -27877,6 +29143,76 @@ var ncActions = defineCustomActions({
|
|
|
27877
29143
|
caller: "required"
|
|
27878
29144
|
}),
|
|
27879
29145
|
/**
|
|
29146
|
+
* The ALARM, for the viewer and the admin Alarm tab (D630).
|
|
29147
|
+
*
|
|
29148
|
+
* Bridge actions and not cap methods, for one reason each way:
|
|
29149
|
+
*
|
|
29150
|
+
* - A REFUSED ARM IS A RESULT HERE. `alarm-panel.arm` is validated by the
|
|
29151
|
+
* hub's generated router against the HOST's copy of the cap, which says
|
|
29152
|
+
* `z.void()` — so on the cap a refusal can only be a thrown string. The
|
|
29153
|
+
* response schema of these actions ({@link NcAlarmArmOutcomeSchema})
|
|
29154
|
+
* lives in the addon and is validated only here, so a refusal travels as
|
|
29155
|
+
* `{ outcome: 'refused', refusalId, blocking }` and the viewer can offer
|
|
29156
|
+
* the Arm anyway bound to exactly that refusal.
|
|
29157
|
+
* - The UIs never send a device id. There is one panel per hub and the
|
|
29158
|
+
* centre already holds it; an id on the wire would be one more thing a
|
|
29159
|
+
* client could get wrong.
|
|
29160
|
+
*
|
|
29161
|
+
* WHO may call what. The WRITES — arm, Arm anyway, disarm — are ADMIN-ONLY,
|
|
29162
|
+
* in the envelope and again in the handler. This is not the cap's authority
|
|
29163
|
+
* and must not pretend to be: `alarm-panel.arm` / `disarm` are DEVICE-scoped
|
|
29164
|
+
* (the host checks the caller's grant on THAT panel), while `addons.custom`
|
|
29165
|
+
* is SYSTEM-scoped and knows nothing about per-device grants. Opening these
|
|
29166
|
+
* to any authenticated user would widen who can disarm the house to anyone
|
|
29167
|
+
* with a login. A non-admin keeps arming and disarming through the
|
|
29168
|
+
* device-scoped cap, exactly as today, and gets past a refusal with the Arm
|
|
29169
|
+
* anyway button on the refusal notification (an HMAC grant bound to that one
|
|
29170
|
+
* refusal).
|
|
29171
|
+
*
|
|
29172
|
+
* `nc.getAlarmStatus` is READ-ONLY, and ADMIN-ONLY too until the viewer
|
|
29173
|
+
* work scopes it. It names every covered device, open or not, with its name
|
|
29174
|
+
* and state — a map of which windows of the house are open, served over the
|
|
29175
|
+
* system-scoped `addons.custom` to ANY login, regardless of which devices
|
|
29176
|
+
* that login may see. The device-scoped way to read a panel is the cap's
|
|
29177
|
+
* `getStatus`; this one opens up once it filters to the caller's grants.
|
|
29178
|
+
* The config pair is the admin's, as `getAlarmConfig` / `setAlarmConfig` are
|
|
29179
|
+
* on the cap: it carries the delays, the recipients and the non-blocking
|
|
29180
|
+
* lists.
|
|
29181
|
+
*/
|
|
29182
|
+
"nc.getAlarmStatus": customAction(object({}), NcAlarmStatusSchema, {
|
|
29183
|
+
auth: "admin",
|
|
29184
|
+
caller: "required"
|
|
29185
|
+
}),
|
|
29186
|
+
"nc.armAlarm": customAction(object({ mode: AlarmArmModeSchema }), NcAlarmArmOutcomeSchema, {
|
|
29187
|
+
kind: "mutation",
|
|
29188
|
+
auth: "admin",
|
|
29189
|
+
caller: "required"
|
|
29190
|
+
}),
|
|
29191
|
+
/**
|
|
29192
|
+
* `panel.armAnyway(refusalId)` — the same bound, one-shot path the refusal
|
|
29193
|
+
* notification's button takes: a second press is `used`, a panel that moved
|
|
29194
|
+
* since is `stale`, and a device that opened since is a NEW refusal.
|
|
29195
|
+
*/
|
|
29196
|
+
"nc.armAlarmAnyway": customAction(object({ refusalId: string().min(1) }), NcAlarmArmOutcomeSchema, {
|
|
29197
|
+
kind: "mutation",
|
|
29198
|
+
auth: "admin",
|
|
29199
|
+
caller: "required"
|
|
29200
|
+
}),
|
|
29201
|
+
"nc.disarmAlarm": customAction(object({}), object({ success: literal(true) }), {
|
|
29202
|
+
kind: "mutation",
|
|
29203
|
+
auth: "admin",
|
|
29204
|
+
caller: "required"
|
|
29205
|
+
}),
|
|
29206
|
+
"nc.getAlarmConfig": customAction(object({}), NcAlarmConfigSchema, {
|
|
29207
|
+
auth: "admin",
|
|
29208
|
+
caller: "required"
|
|
29209
|
+
}),
|
|
29210
|
+
"nc.setAlarmConfig": customAction(object({ patch: NcAlarmSettingsPatchSchema }), NcAlarmConfigSchema, {
|
|
29211
|
+
kind: "mutation",
|
|
29212
|
+
auth: "admin",
|
|
29213
|
+
caller: "required"
|
|
29214
|
+
}),
|
|
29215
|
+
/**
|
|
27880
29216
|
* The profiles a rule's AI section may be PINNED to — the picker's options.
|
|
27881
29217
|
*
|
|
27882
29218
|
* A bridge action and not `llm.listProfiles` straight from the client, for
|
|
@@ -28224,7 +29560,22 @@ function requireCaller$1(caller) {
|
|
|
28224
29560
|
if (!caller || typeof caller.userId !== "string" || caller.userId.length === 0) throw new Error("forbidden: authenticated caller required");
|
|
28225
29561
|
return caller;
|
|
28226
29562
|
}
|
|
29563
|
+
/**
|
|
29564
|
+
* An ADMIN caller, or a refusal. The dispatcher already gates an
|
|
29565
|
+
* `auth: 'admin'` action; this is the same check on this side of the bridge,
|
|
29566
|
+
* so a dispatcher that did not forward the flag cannot open it.
|
|
29567
|
+
*/
|
|
29568
|
+
function requireAdmin(caller) {
|
|
29569
|
+
const c = requireCaller$1(caller);
|
|
29570
|
+
if (!c.isAdmin) throw new Error("forbidden: admin required");
|
|
29571
|
+
return c;
|
|
29572
|
+
}
|
|
28227
29573
|
function makeNcActionHandlers(deps) {
|
|
29574
|
+
/** The alarm half, or a refusal by name — never a made-up "disarmed". */
|
|
29575
|
+
const requireAlarm = (action) => {
|
|
29576
|
+
if (deps.alarm === void 0) throw new Error(`${action}: the alarm is not wired on this node`);
|
|
29577
|
+
return deps.alarm;
|
|
29578
|
+
};
|
|
28228
29579
|
/** A rule the caller may EDIT — must exist and be owned by the caller. */
|
|
28229
29580
|
const assertOwnsRule = (ruleId) => {
|
|
28230
29581
|
const rule = deps.ruleStore.get(ruleId);
|
|
@@ -28445,6 +29796,31 @@ function makeNcActionHandlers(deps) {
|
|
|
28445
29796
|
} });
|
|
28446
29797
|
return { success: true };
|
|
28447
29798
|
},
|
|
29799
|
+
"nc.getAlarmStatus": async (_input, caller) => {
|
|
29800
|
+
requireAdmin(caller);
|
|
29801
|
+
return requireAlarm("nc.getAlarmStatus").status();
|
|
29802
|
+
},
|
|
29803
|
+
"nc.armAlarm": async (input, caller) => {
|
|
29804
|
+
const c = requireAdmin(caller);
|
|
29805
|
+
return requireAlarm("nc.armAlarm").arm(input.mode, c.userId);
|
|
29806
|
+
},
|
|
29807
|
+
"nc.armAlarmAnyway": async (input, caller) => {
|
|
29808
|
+
const c = requireAdmin(caller);
|
|
29809
|
+
return requireAlarm("nc.armAlarmAnyway").armAnyway(input.refusalId, c.userId);
|
|
29810
|
+
},
|
|
29811
|
+
"nc.disarmAlarm": async (_input, caller) => {
|
|
29812
|
+
const c = requireAdmin(caller);
|
|
29813
|
+
await requireAlarm("nc.disarmAlarm").disarm(c.userId);
|
|
29814
|
+
return { success: true };
|
|
29815
|
+
},
|
|
29816
|
+
"nc.getAlarmConfig": async (_input, caller) => {
|
|
29817
|
+
requireAdmin(caller);
|
|
29818
|
+
return requireAlarm("nc.getAlarmConfig").getConfig();
|
|
29819
|
+
},
|
|
29820
|
+
"nc.setAlarmConfig": async (input, caller) => {
|
|
29821
|
+
requireAdmin(caller);
|
|
29822
|
+
return requireAlarm("nc.setAlarmConfig").setConfig(input.patch);
|
|
29823
|
+
},
|
|
28448
29824
|
"nc.listAiProfiles": async (_input, caller) => {
|
|
28449
29825
|
requireCaller$1(caller);
|
|
28450
29826
|
const list = deps.listAiProfiles;
|
|
@@ -70649,8 +72025,27 @@ var LinkedCamerasCache = class {
|
|
|
70649
72025
|
deps;
|
|
70650
72026
|
ttlMs;
|
|
70651
72027
|
index = /* @__PURE__ */ new Map();
|
|
72028
|
+
/**
|
|
72029
|
+
* source deviceId → EVERY camera that has it among its linked devices,
|
|
72030
|
+
* ignoring the tracked-events opt-in. Built in the same loop as
|
|
72031
|
+
* {@link index}; the opt-in governs timeline rows only.
|
|
72032
|
+
*/
|
|
72033
|
+
allIndex = /* @__PURE__ */ new Map();
|
|
70652
72034
|
/** Every camera id seen by the last successful build. */
|
|
70653
72035
|
cameras = /* @__PURE__ */ new Set();
|
|
72036
|
+
/**
|
|
72037
|
+
* Each camera's links as last READ successfully. A later rebuild that cannot
|
|
72038
|
+
* read a camera's links carries these forward rather than dropping them: the
|
|
72039
|
+
* mirror stays authoritative until something contradicts it (D49).
|
|
72040
|
+
*/
|
|
72041
|
+
linksByCamera = /* @__PURE__ */ new Map();
|
|
72042
|
+
/** False until a camera list has been read once. A later failed list keeps
|
|
72043
|
+
* the previous index, and this stays true. */
|
|
72044
|
+
cameraListRead = false;
|
|
72045
|
+
/** Listed cameras whose links have NEVER been read. Non-empty (or no camera
|
|
72046
|
+
* list yet) ⇒ the index has not answered: a device absent from it is
|
|
72047
|
+
* UNKNOWN, not unlinked (D49). */
|
|
72048
|
+
unanswered = [];
|
|
70654
72049
|
/** Ms timestamp of the last build; null = never built / invalidated. */
|
|
70655
72050
|
builtAt = null;
|
|
70656
72051
|
building = null;
|
|
@@ -70664,6 +72059,32 @@ var LinkedCamerasCache = class {
|
|
|
70664
72059
|
return this.index.get(sourceDeviceId) ?? [];
|
|
70665
72060
|
}
|
|
70666
72061
|
/**
|
|
72062
|
+
* EVERY camera that links `sourceDeviceId`, whether or not it opted the
|
|
72063
|
+
* device into tracked events ([] when none). The notification side asks this
|
|
72064
|
+
* one; timeline rows ask {@link camerasFor}.
|
|
72065
|
+
*/
|
|
72066
|
+
async allCamerasFor(sourceDeviceId, nowMs = Date.now()) {
|
|
72067
|
+
await this.ensureFresh(nowMs);
|
|
72068
|
+
return this.allIndex.get(sourceDeviceId) ?? [];
|
|
72069
|
+
}
|
|
72070
|
+
/**
|
|
72071
|
+
* True when the index ANSWERED: a camera list has been read, and every listed
|
|
72072
|
+
* camera's links have been read at least once (a camera whose read failed
|
|
72073
|
+
* later answers with its last good links). False means an empty answer from the other methods
|
|
72074
|
+
* is not knowledge and must not be acted on as "linked to nothing".
|
|
72075
|
+
*/
|
|
72076
|
+
async answered(nowMs = Date.now()) {
|
|
72077
|
+
await this.ensureFresh(nowMs);
|
|
72078
|
+
return this.cameraListRead && this.unanswered.length === 0;
|
|
72079
|
+
}
|
|
72080
|
+
/** What the index could not answer, as of the last build — no refresh. */
|
|
72081
|
+
gap() {
|
|
72082
|
+
return {
|
|
72083
|
+
cameraListRead: this.cameraListRead,
|
|
72084
|
+
unansweredCameraIds: [...this.unanswered]
|
|
72085
|
+
};
|
|
72086
|
+
}
|
|
72087
|
+
/**
|
|
70667
72088
|
* True when `deviceId` is ITSELF a camera. Answered off the same build as
|
|
70668
72089
|
* {@link camerasFor} — a sensor cap bound on a camera needs both facts in
|
|
70669
72090
|
* the same event, and a second enumeration would double the cost of the
|
|
@@ -70678,10 +72099,13 @@ var LinkedCamerasCache = class {
|
|
|
70678
72099
|
this.builtAt = null;
|
|
70679
72100
|
}
|
|
70680
72101
|
/** Test/maintenance hook: replace the index directly. */
|
|
70681
|
-
seed(index, builtAt, cameraIds = []) {
|
|
72102
|
+
seed(index, builtAt, cameraIds = [], allIndex = index) {
|
|
70682
72103
|
this.index = new Map(index);
|
|
72104
|
+
this.allIndex = new Map(allIndex);
|
|
70683
72105
|
this.cameras = new Set(cameraIds);
|
|
70684
72106
|
this.builtAt = builtAt;
|
|
72107
|
+
this.cameraListRead = true;
|
|
72108
|
+
this.unanswered = [];
|
|
70685
72109
|
}
|
|
70686
72110
|
/** Rebuild (single-flight) when never built or past the TTL. */
|
|
70687
72111
|
async ensureFresh(nowMs) {
|
|
@@ -70703,7 +72127,8 @@ var LinkedCamerasCache = class {
|
|
|
70703
72127
|
*
|
|
70704
72128
|
* A batch that REJECTS falls back rather than emptying the index: a stale
|
|
70705
72129
|
* reverse index still routes sensor events, an empty one silently routes
|
|
70706
|
-
* none.
|
|
72130
|
+
* none. A camera whose links could not be read is simply ABSENT from the
|
|
72131
|
+
* returned map — the caller carries its previous links forward.
|
|
70707
72132
|
*/
|
|
70708
72133
|
async resolveLinks(cameraIds) {
|
|
70709
72134
|
const batch = this.deps.linkedDevicesBatch;
|
|
@@ -70718,23 +72143,38 @@ var LinkedCamerasCache = class {
|
|
|
70718
72143
|
const { devices } = await this.deps.linkedDevices.getLinkedDevices({ deviceId: cameraId });
|
|
70719
72144
|
out.set(cameraId, devices);
|
|
70720
72145
|
} catch (err) {
|
|
70721
|
-
this.deps.onError?.("getLinkedDevices", err);
|
|
72146
|
+
this.deps.onError?.("getLinkedDevices", err, cameraId);
|
|
70722
72147
|
}
|
|
70723
72148
|
return out;
|
|
70724
72149
|
}
|
|
70725
72150
|
async rebuild(nowMs) {
|
|
70726
72151
|
try {
|
|
70727
72152
|
const cameraIds = await this.deps.cameras.listCameraIds();
|
|
70728
|
-
const
|
|
72153
|
+
const fresh = await this.resolveLinks(cameraIds);
|
|
72154
|
+
const linksByCamera = /* @__PURE__ */ new Map();
|
|
72155
|
+
const unanswered = [];
|
|
72156
|
+
for (const cameraId of cameraIds) {
|
|
72157
|
+
const links = fresh.get(cameraId) ?? this.linksByCamera.get(cameraId);
|
|
72158
|
+
if (links === void 0) unanswered.push(cameraId);
|
|
72159
|
+
else linksByCamera.set(cameraId, links);
|
|
72160
|
+
}
|
|
70729
72161
|
const next = /* @__PURE__ */ new Map();
|
|
72162
|
+
const nextAll = /* @__PURE__ */ new Map();
|
|
70730
72163
|
for (const cameraId of cameraIds) for (const d of linksByCamera.get(cameraId) ?? []) {
|
|
72164
|
+
const all = nextAll.get(d.deviceId);
|
|
72165
|
+
if (all === void 0) nextAll.set(d.deviceId, [cameraId]);
|
|
72166
|
+
else if (!all.includes(cameraId)) all.push(cameraId);
|
|
70731
72167
|
if (d.producesTrackedEvents === false) continue;
|
|
70732
72168
|
const list = next.get(d.deviceId);
|
|
70733
72169
|
if (list === void 0) next.set(d.deviceId, [cameraId]);
|
|
70734
72170
|
else if (!list.includes(cameraId)) list.push(cameraId);
|
|
70735
72171
|
}
|
|
70736
72172
|
this.index = next;
|
|
72173
|
+
this.allIndex = nextAll;
|
|
70737
72174
|
this.cameras = new Set(cameraIds);
|
|
72175
|
+
this.linksByCamera = linksByCamera;
|
|
72176
|
+
this.unanswered = unanswered;
|
|
72177
|
+
this.cameraListRead = true;
|
|
70738
72178
|
this.builtAt = nowMs;
|
|
70739
72179
|
} catch (err) {
|
|
70740
72180
|
this.deps.onError?.("listCameraIds", err);
|
|
@@ -70758,13 +72198,25 @@ async function resolveSensorEventCameras(cache, data) {
|
|
|
70758
72198
|
const linkedCameraIds = await cache.camerasFor(data.deviceId);
|
|
70759
72199
|
const isCamera = await cache.isCamera(data.deviceId);
|
|
70760
72200
|
const cameraIds = isCamera && selfCameraMayEmit(data.capName) && !linkedCameraIds.includes(data.deviceId) ? [data.deviceId, ...linkedCameraIds] : linkedCameraIds;
|
|
70761
|
-
if (cameraIds.length
|
|
72201
|
+
if (cameraIds.length > 0) return {
|
|
72202
|
+
cameraIds,
|
|
72203
|
+
dropReason: null,
|
|
72204
|
+
linkedCameraIds: []
|
|
72205
|
+
};
|
|
72206
|
+
if (isCamera) return {
|
|
72207
|
+
cameraIds,
|
|
72208
|
+
dropReason: "camera-native-cap",
|
|
72209
|
+
linkedCameraIds: []
|
|
72210
|
+
};
|
|
72211
|
+
if (!await cache.answered()) return {
|
|
70762
72212
|
cameraIds,
|
|
70763
|
-
dropReason:
|
|
72213
|
+
dropReason: "camera-index-unknown",
|
|
72214
|
+
linkedCameraIds: []
|
|
70764
72215
|
};
|
|
70765
72216
|
return {
|
|
70766
72217
|
cameraIds,
|
|
70767
|
-
dropReason:
|
|
72218
|
+
dropReason: "no-camera",
|
|
72219
|
+
linkedCameraIds: await cache.allCamerasFor(data.deviceId)
|
|
70768
72220
|
};
|
|
70769
72221
|
}
|
|
70770
72222
|
/**
|
|
@@ -70774,20 +72226,37 @@ async function resolveSensorEventCameras(cache, data) {
|
|
|
70774
72226
|
* camera 615 — see `SELF_CAMERA_EXCLUDED_CAPS` for the one cap that is not
|
|
70775
72227
|
* self-ingested). The EVENT_KIND_BY_CAP gate exits first so non-sensor cap
|
|
70776
72228
|
* churn costs one map lookup. Returns the number of rows inserted (0 when
|
|
70777
|
-
* unmapped, or when the device
|
|
72229
|
+
* unmapped, or when no camera takes a row for the device — a non-camera
|
|
72230
|
+
* sensor then goes to `onUnowned` instead, evaluated without a row, once the
|
|
72231
|
+
* camera index answered).
|
|
70778
72232
|
* Telemetry-lossy by design (D8) — inserts are best-effort.
|
|
70779
72233
|
*/
|
|
70780
72234
|
async function ingestSensorStateChange(deps, data, timestamp) {
|
|
70781
72235
|
const descriptor = EVENT_KIND_BY_CAP[data.capName];
|
|
70782
72236
|
if (descriptor === void 0) return 0;
|
|
70783
|
-
const { cameraIds, dropReason } = await resolveSensorEventCameras(deps.cache, data);
|
|
72237
|
+
const { cameraIds, dropReason, linkedCameraIds } = await resolveSensorEventCameras(deps.cache, data);
|
|
72238
|
+
const slice = data.slice;
|
|
72239
|
+
const value = slice !== null && slice !== void 0 && typeof slice === "object" && !Array.isArray(slice) ? slice : null;
|
|
72240
|
+
const makeId = deps.makeId ?? (() => `pa-sensor-${randomUUID()}`);
|
|
72241
|
+
if (dropReason === "no-camera" && deps.onUnowned !== void 0) {
|
|
72242
|
+
const ev = {
|
|
72243
|
+
id: makeId(),
|
|
72244
|
+
deviceId: data.deviceId,
|
|
72245
|
+
sourceDeviceId: data.deviceId,
|
|
72246
|
+
kind: descriptor.kind,
|
|
72247
|
+
value,
|
|
72248
|
+
timestamp
|
|
72249
|
+
};
|
|
72250
|
+
deps.onUnowned({
|
|
72251
|
+
event: ev,
|
|
72252
|
+
linkedCameraIds
|
|
72253
|
+
});
|
|
72254
|
+
return 0;
|
|
72255
|
+
}
|
|
70784
72256
|
if (dropReason !== null) {
|
|
70785
72257
|
deps.onDropped?.(dropReason, descriptor.kind);
|
|
70786
72258
|
return 0;
|
|
70787
72259
|
}
|
|
70788
|
-
const slice = data.slice;
|
|
70789
|
-
const value = slice !== null && slice !== void 0 && typeof slice === "object" && !Array.isArray(slice) ? slice : null;
|
|
70790
|
-
const makeId = deps.makeId ?? (() => `pa-sensor-${randomUUID()}`);
|
|
70791
72260
|
let inserted = 0;
|
|
70792
72261
|
for (const cameraId of cameraIds) {
|
|
70793
72262
|
const ev = {
|
|
@@ -71857,12 +73326,12 @@ async function projectSensorMarkers(deps, data, timestamp) {
|
|
|
71857
73326
|
*
|
|
71858
73327
|
* `EVENT_KIND_BY_CAP` covers sixteen caps; `SOURCE_CAP_ACTIVE_FIELD` covers the
|
|
71859
73328
|
* ten that have a boolean whose edge means something. `doorbell`, `button`,
|
|
71860
|
-
* `
|
|
71861
|
-
*
|
|
73329
|
+
* `presence`, `enum-sensor` and `event-emitter` are NOT levels — their ARRIVAL
|
|
73330
|
+
* is the event, and three presses in a row are three markers.
|
|
71862
73331
|
* Gating them would re-ship the failure of 2026-08-08, when camera 615's press
|
|
71863
73332
|
* produced a row, a ring, a matched rule and no marker.
|
|
71864
73333
|
*
|
|
71865
|
-
* ## A cover is a level whose level is a WORD
|
|
73334
|
+
* ## A cover — and a lock — is a level whose level is a WORD
|
|
71866
73335
|
*
|
|
71867
73336
|
* `cover` is the sixteenth cap, and it is neither of the above. Its level is
|
|
71868
73337
|
* the `state` word (`open`, `opening`, `closing`, `closed`, `stopped`), and its
|
|
@@ -71890,6 +73359,39 @@ async function projectSensorMarkers(deps, data, timestamp) {
|
|
|
71890
73359
|
* is reported once as if the word had just changed. The alarm needs covers
|
|
71891
73360
|
* to be eventful to see one open (D630).
|
|
71892
73361
|
*
|
|
73362
|
+
* `lock-control` follows the same rule on its `state` word, with `locked` as
|
|
73363
|
+
* the resting word. It used to pass through as "arrival is the event", and a
|
|
73364
|
+
* lock is exactly what the alarm watches: a placeholder `unlocked` written by
|
|
73365
|
+
* a provider constructor after a respawn was an unlock, and the armed panel
|
|
73366
|
+
* could fire on it (D630).
|
|
73367
|
+
*
|
|
73368
|
+
* ## A placeholder slice is not a state
|
|
73369
|
+
*
|
|
73370
|
+
* Provider constructors write a cold slice before they have read the device —
|
|
73371
|
+
* `{ entryOpen: false, lastChangedAt: 0 }` for an HA binary sensor, `unlocked`
|
|
73372
|
+
* for an HA or Homematic lock, `closed` for an HA cover. `lastChangedAt: 0`
|
|
73373
|
+
* is the one field that says "nobody has read this yet", and a slice carrying
|
|
73374
|
+
* it is dropped BEFORE any cap-specific rule (`cold-slice`) and writes no
|
|
73375
|
+
* memory. Remembered, it became a baseline the real state then "changed"
|
|
73376
|
+
* from: a placeholder `closed` followed by the real `open` was an opening,
|
|
73377
|
+
* and while the panel was armed that opening could lift an exclusion and fire
|
|
73378
|
+
* the siren. Dropped, the real slice is a first sighting and goes through the
|
|
73379
|
+
* freshness floor like any other.
|
|
73380
|
+
*
|
|
73381
|
+
* The change stamp is a three-way convention every provider writes and this
|
|
73382
|
+
* gate and the alarm (`alarm-openings.ts`) both read:
|
|
73383
|
+
*
|
|
73384
|
+
* - `0` — a placeholder: the VALUE is unknown. Dropped here, `unknown` there.
|
|
73385
|
+
* - `> 0` but below the floor — a KNOWN value whose change time is old or
|
|
73386
|
+
* unknown. A provider that REPLAYS a cached value after a respawn and has
|
|
73387
|
+
* no upstream change time (Homematic: XML-RPC `getParamset` carries none)
|
|
73388
|
+
* stamps it `1`, epoch + 1 ms (`HM_SEEDED_CHANGED_AT`). Here it seeds a
|
|
73389
|
+
* baseline and is never a first-sighting event; there it is a real reading,
|
|
73390
|
+
* so an arm check still blocks on a lock that is really unlocked. Stamping
|
|
73391
|
+
* such a replay `now` is the laundering this rules out: a lock unlocked for
|
|
73392
|
+
* hours cleared the floor as a fresh unlock after a hub restart.
|
|
73393
|
+
* - at or above the floor — a change that happened then.
|
|
73394
|
+
*
|
|
71893
73395
|
* ## Nothing here logs, and no drop is silent
|
|
71894
73396
|
*
|
|
71895
73397
|
* The gate is pure towards the world (no bus, no logger, no clock of its own —
|
|
@@ -71904,9 +73406,15 @@ async function projectSensorMarkers(deps, data, timestamp) {
|
|
|
71904
73406
|
* Caps whose level is a string word rather than a boolean: cap → slice field.
|
|
71905
73407
|
* See "A cover is a level whose level is a WORD" above.
|
|
71906
73408
|
*/
|
|
71907
|
-
var WORD_LEVEL_FIELD = {
|
|
73409
|
+
var WORD_LEVEL_FIELD = {
|
|
73410
|
+
cover: "state",
|
|
73411
|
+
"lock-control": "state"
|
|
73412
|
+
};
|
|
71908
73413
|
/** The word a word-level cap rests in — its `false`, for the first-sighting rule. */
|
|
71909
|
-
var WORD_LEVEL_INACTIVE = {
|
|
73414
|
+
var WORD_LEVEL_INACTIVE = {
|
|
73415
|
+
cover: "closed",
|
|
73416
|
+
"lock-control": "locked"
|
|
73417
|
+
};
|
|
71910
73418
|
var SensorTransitionGate = class {
|
|
71911
73419
|
/** Last observed boolean, keyed by `${deviceId}:${capName}`. */
|
|
71912
73420
|
lastValues = /* @__PURE__ */ new Map();
|
|
@@ -71929,11 +73437,13 @@ var SensorTransitionGate = class {
|
|
|
71929
73437
|
/**
|
|
71930
73438
|
* Feed one `DeviceStateChanged` slice for a cap `EVENT_KIND_BY_CAP` maps.
|
|
71931
73439
|
*
|
|
71932
|
-
* A
|
|
71933
|
-
*
|
|
71934
|
-
*
|
|
73440
|
+
* A placeholder slice (`lastChangedAt === 0`) is dropped first, for every
|
|
73441
|
+
* cap, and remembers nothing. Otherwise a cap with no level passes through
|
|
73442
|
+
* unconditionally, and a cap that HAS one passes only when its level moved —
|
|
73443
|
+
* a boolean in either direction, or a word-level cap's word changing.
|
|
71935
73444
|
*/
|
|
71936
73445
|
observe(deviceId, capName, slice) {
|
|
73446
|
+
if (slice["lastChangedAt"] === 0) return this.drop(deviceId, capName, "cold-slice");
|
|
71937
73447
|
const wordField = WORD_LEVEL_FIELD[capName];
|
|
71938
73448
|
if (wordField !== void 0) return this.observeWord(deviceId, capName, slice, wordField);
|
|
71939
73449
|
if (!isSourceCap(capName)) return { changed: true };
|
|
@@ -72018,6 +73528,36 @@ var SensorTransitionGate = class {
|
|
|
72018
73528
|
});
|
|
72019
73529
|
}
|
|
72020
73530
|
};
|
|
73531
|
+
var CameraIndexUnknownDrops = class {
|
|
73532
|
+
drops = /* @__PURE__ */ new Map();
|
|
73533
|
+
windowStartedAt;
|
|
73534
|
+
windowMs;
|
|
73535
|
+
constructor(options) {
|
|
73536
|
+
this.windowStartedAt = options.nowMs;
|
|
73537
|
+
this.windowMs = options.windowMs ?? 6e4;
|
|
73538
|
+
}
|
|
73539
|
+
record(deviceId, capName) {
|
|
73540
|
+
const key = `${deviceId}:${capName}`;
|
|
73541
|
+
const prior = this.drops.get(key);
|
|
73542
|
+
this.drops.set(key, {
|
|
73543
|
+
deviceId,
|
|
73544
|
+
capName,
|
|
73545
|
+
count: (prior?.count ?? 0) + 1
|
|
73546
|
+
});
|
|
73547
|
+
}
|
|
73548
|
+
/**
|
|
73549
|
+
* The window's drops, sorted by `(deviceId, capName)`, once the window has
|
|
73550
|
+
* elapsed; [] before that or when nothing dropped. Taking resets the window.
|
|
73551
|
+
*/
|
|
73552
|
+
takeSummary(nowMs) {
|
|
73553
|
+
if (nowMs - this.windowStartedAt < this.windowMs) return [];
|
|
73554
|
+
this.windowStartedAt = nowMs;
|
|
73555
|
+
if (this.drops.size === 0) return [];
|
|
73556
|
+
const out = [...this.drops.values()].sort((a, b) => a.deviceId - b.deviceId || a.capName.localeCompare(b.capName));
|
|
73557
|
+
this.drops.clear();
|
|
73558
|
+
return out;
|
|
73559
|
+
}
|
|
73560
|
+
};
|
|
72021
73561
|
//#endregion
|
|
72022
73562
|
//#region src/pipeline-analytics/services/synthetic-track.ts
|
|
72023
73563
|
/**
|
|
@@ -77174,6 +78714,8 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
|
|
|
77174
78714
|
startedAtMs: Date.now(),
|
|
77175
78715
|
now: () => Date.now()
|
|
77176
78716
|
});
|
|
78717
|
+
/** `camera-index-unknown` drops, summarised per window — see the file. */
|
|
78718
|
+
cameraIndexUnknownDrops = new CameraIndexUnknownDrops({ nowMs: Date.now() });
|
|
77177
78719
|
/** The ONE projector of non-spatial events into synthetic snapshot-carrying
|
|
77178
78720
|
* tracks — linked sensor/control state changes AND audio markers. */
|
|
77179
78721
|
syntheticTracks = null;
|
|
@@ -78596,6 +80138,7 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
|
|
|
78596
80138
|
previewTexts: center.textCatalog,
|
|
78597
80139
|
...this.ncArtifactIndex !== null ? { artifacts: this.ncArtifactIndex } : {},
|
|
78598
80140
|
artifactShelf: { remove: async (id) => await this.ncArtifactStore?.remove(id) ?? false },
|
|
80141
|
+
alarm: center.alarmActions(),
|
|
78599
80142
|
ruleStatus: {
|
|
78600
80143
|
forRule: (rule) => center.ruleStatusFor(rule),
|
|
78601
80144
|
forTimelapseRule: (rule) => center.timelapseStatusFor(rule, async ({ ruleId, deviceId }) => {
|
|
@@ -78724,19 +80267,12 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
|
|
|
78724
80267
|
const panel = this.alarmPanelDevice;
|
|
78725
80268
|
if (panel === null || this.alarmPanelWired) return;
|
|
78726
80269
|
this.alarmPanelWired = true;
|
|
78727
|
-
center.setAlarmPanel(
|
|
78728
|
-
deviceId: panel.id,
|
|
78729
|
-
settings: () => panel.settings(),
|
|
78730
|
-
applySettings: (patch) => panel.applySettings(patch),
|
|
78731
|
-
availableModes: () => NC_ALARM_MODES,
|
|
78732
|
-
noteRuleTrigger: (source) => panel.noteRuleTrigger(source),
|
|
78733
|
-
excludedDevices: () => panel.excludedDevices(),
|
|
78734
|
-
observeContact: (deviceId, reading, now) => panel.observeContact(deviceId, reading, now)
|
|
78735
|
-
});
|
|
80270
|
+
center.setAlarmPanel(ncAlarmPanelPort(panel));
|
|
78736
80271
|
panel.setPublishHook((report) => center.onAlarmPublish(report));
|
|
78737
80272
|
panel.setArmGuard(center.alarmArmGuard());
|
|
78738
80273
|
center.onAlarmPublish({
|
|
78739
80274
|
state: panel.currentState(),
|
|
80275
|
+
target: panel.currentTarget(),
|
|
78740
80276
|
transition: null
|
|
78741
80277
|
});
|
|
78742
80278
|
this.ctx.logger.info("alarm panel wired to the notification center", { tags: { deviceId: panel.id } });
|
|
@@ -79179,10 +80715,13 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
|
|
|
79179
80715
|
} },
|
|
79180
80716
|
linkedDevices: { getLinkedDevices: (input) => api.deviceManager.getLinkedDevices.query(input) },
|
|
79181
80717
|
linkedDevicesBatch: { getLinkedDevicesBatch: (input) => api.deviceManager.getLinkedDevicesBatch.query(input) },
|
|
79182
|
-
onError: (scope, err) => logger.warn("linked-cameras cache refresh failed", {
|
|
79183
|
-
|
|
79184
|
-
|
|
79185
|
-
|
|
80718
|
+
onError: (scope, err, cameraId) => logger.warn("linked-cameras cache refresh failed", {
|
|
80719
|
+
...cameraId !== void 0 ? { tags: { deviceId: cameraId } } : {},
|
|
80720
|
+
meta: {
|
|
80721
|
+
scope,
|
|
80722
|
+
error: errMsg$1(err)
|
|
80723
|
+
}
|
|
80724
|
+
})
|
|
79186
80725
|
});
|
|
79187
80726
|
const syntheticTracks = new SyntheticTrackMaterializer({
|
|
79188
80727
|
snapshot: { getSnapshot: (i) => api.snapshot.getSnapshot.query(i) },
|
|
@@ -79635,6 +81174,7 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
|
|
|
79635
81174
|
}
|
|
79636
81175
|
return out;
|
|
79637
81176
|
},
|
|
81177
|
+
readDeviceOpenings: (ids) => readAlarmOpenings(ids, (deviceId) => api.deviceManager.loadRuntimeState.query({ deviceId }), this.ctx.logger),
|
|
79638
81178
|
readSceneStates: () => (this.sceneProvider?.listAllEnabled() ?? []).map(({ deviceId, monitor }) => ({
|
|
79639
81179
|
sceneId: monitor.id,
|
|
79640
81180
|
deviceId,
|
|
@@ -85366,11 +86906,13 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
|
|
|
85366
86906
|
if (store === null || cache === null) return;
|
|
85367
86907
|
if (!this.sensorSliceCarriedATransition(data)) return;
|
|
85368
86908
|
const persisted = [];
|
|
86909
|
+
const unowned = [];
|
|
85369
86910
|
try {
|
|
85370
86911
|
await ingestSensorStateChange({
|
|
85371
86912
|
sink: store,
|
|
85372
86913
|
cache,
|
|
85373
86914
|
onPersisted: (ev) => persisted.push(ev),
|
|
86915
|
+
onUnowned: (u) => unowned.push(u),
|
|
85374
86916
|
onDropped: (reason, kind) => this.logSensorIngestDrop(data, reason, kind)
|
|
85375
86917
|
}, data, timestamp);
|
|
85376
86918
|
} catch (err) {
|
|
@@ -85382,6 +86924,7 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
|
|
|
85382
86924
|
}
|
|
85383
86925
|
});
|
|
85384
86926
|
}
|
|
86927
|
+
this.flushCameraIndexUnknownDrops();
|
|
85385
86928
|
const markerByCamera = /* @__PURE__ */ new Map();
|
|
85386
86929
|
const materializer = this.syntheticTracks;
|
|
85387
86930
|
if (materializer !== null) try {
|
|
@@ -85425,7 +86968,39 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
|
|
|
85425
86968
|
}
|
|
85426
86969
|
});
|
|
85427
86970
|
}
|
|
85428
|
-
for (const ev of persisted)
|
|
86971
|
+
for (const ev of persisted) try {
|
|
86972
|
+
this.notificationCenter?.onSensorEventPersisted(ev, markerByCamera.get(ev.deviceId)?.trackId);
|
|
86973
|
+
} catch (err) {
|
|
86974
|
+
this.ctx.logger.warn("sensor event hand-off to the notification centre failed", {
|
|
86975
|
+
tags: { deviceId: ev.deviceId },
|
|
86976
|
+
meta: {
|
|
86977
|
+
kind: ev.kind,
|
|
86978
|
+
sourceDeviceId: ev.sourceDeviceId,
|
|
86979
|
+
error: errMsg$1(err)
|
|
86980
|
+
}
|
|
86981
|
+
});
|
|
86982
|
+
}
|
|
86983
|
+
for (const { event: ev, linkedCameraIds } of unowned) {
|
|
86984
|
+
this.ctx.logger.debug(linkedCameraIds.length === 0 ? "sensor event linked to no camera — evaluated without a timeline row" : `sensor event linked to ${linkedCameraIds.length} camera(s), none opted into tracked events — evaluated without a timeline row`, {
|
|
86985
|
+
tags: { deviceId: ev.sourceDeviceId },
|
|
86986
|
+
meta: {
|
|
86987
|
+
kind: ev.kind,
|
|
86988
|
+
linkedCameraIds: [...linkedCameraIds]
|
|
86989
|
+
}
|
|
86990
|
+
});
|
|
86991
|
+
try {
|
|
86992
|
+
this.notificationCenter?.onUnownedSensorEvent(ev, linkedCameraIds);
|
|
86993
|
+
} catch (err) {
|
|
86994
|
+
this.ctx.logger.warn("sensor event hand-off to the notification centre failed", {
|
|
86995
|
+
tags: { deviceId: ev.sourceDeviceId },
|
|
86996
|
+
meta: {
|
|
86997
|
+
kind: ev.kind,
|
|
86998
|
+
linkedCameraIds: [...linkedCameraIds],
|
|
86999
|
+
error: errMsg$1(err)
|
|
87000
|
+
}
|
|
87001
|
+
});
|
|
87002
|
+
}
|
|
87003
|
+
}
|
|
85429
87004
|
}
|
|
85430
87005
|
/**
|
|
85431
87006
|
* True when this slice carried a real transition of its sensor.
|
|
@@ -85479,6 +87054,29 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
|
|
|
85479
87054
|
});
|
|
85480
87055
|
}
|
|
85481
87056
|
/**
|
|
87057
|
+
* One `info` line per (device, cap) per window for the evaluations refused
|
|
87058
|
+
* because the camera index has not answered, or none. It drops evaluations
|
|
87059
|
+
* (a contact that might trigger the alarm), so it is `info`; it is a COUNT
|
|
87060
|
+
* so a cold-start outage cannot log at bus rate. `unansweredCameraIds` names
|
|
87061
|
+
* the cameras whose links were never read (`cameraListRead: false` = not
|
|
87062
|
+
* even the camera list).
|
|
87063
|
+
*/
|
|
87064
|
+
flushCameraIndexUnknownDrops() {
|
|
87065
|
+
const summary = this.cameraIndexUnknownDrops.takeSummary(Date.now());
|
|
87066
|
+
if (summary.length === 0) return;
|
|
87067
|
+
const gap = this.linkedCamerasCache?.gap();
|
|
87068
|
+
for (const drop of summary) this.ctx.logger.info("sensor events NOT evaluated — the camera index has not answered, so \"linked to no camera\" is unknown", {
|
|
87069
|
+
tags: { deviceId: drop.deviceId },
|
|
87070
|
+
meta: {
|
|
87071
|
+
capName: drop.capName,
|
|
87072
|
+
count: drop.count,
|
|
87073
|
+
reason: "camera-index-unknown",
|
|
87074
|
+
cameraListRead: gap?.cameraListRead ?? false,
|
|
87075
|
+
unansweredCameraIds: [...gap?.unansweredCameraIds ?? []]
|
|
87076
|
+
}
|
|
87077
|
+
});
|
|
87078
|
+
}
|
|
87079
|
+
/**
|
|
85482
87080
|
* Say out loud that a MAPPED sensor cap ingested nothing, and why.
|
|
85483
87081
|
*
|
|
85484
87082
|
* BOTH branches are `debug`, deliberately. After the self-camera fix neither
|
|
@@ -85489,7 +87087,10 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
|
|
|
85489
87087
|
* binds a native `motion` cap on the camera device and motion churns
|
|
85490
87088
|
* continuously (camera 590 flaps at ~2 Hz), so an `info` here would be a
|
|
85491
87089
|
* log flood measured in millions of lines a day — its own outage.
|
|
85492
|
-
* - `no-camera` is every sensor in the fleet that no camera links.
|
|
87090
|
+
* - `no-camera` is every sensor in the fleet that no camera links. Since
|
|
87091
|
+
* D630 §6 it is still EVALUATED by the notification rules (through
|
|
87092
|
+
* `onUnowned`) — this line only says no timeline row was written, and it
|
|
87093
|
+
* fires only when no one took the camera-less event.
|
|
85493
87094
|
*
|
|
85494
87095
|
* What must never happen again is the branch being INVISIBLE: a doorbell
|
|
85495
87096
|
* press that ingested nothing produced not one line for months. A debug line
|
|
@@ -85497,7 +87098,11 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
|
|
|
85497
87098
|
* dark" the moment anyone asks it.
|
|
85498
87099
|
*/
|
|
85499
87100
|
logSensorIngestDrop(data, reason, kind) {
|
|
85500
|
-
|
|
87101
|
+
if (reason === "camera-index-unknown") {
|
|
87102
|
+
this.cameraIndexUnknownDrops.record(data.deviceId, data.capName);
|
|
87103
|
+
return;
|
|
87104
|
+
}
|
|
87105
|
+
const message = reason === "camera-native-cap" ? "sensor-event ingest produced NO row — the camera already emits this cap through its own path" : "sensor-event ingest produced NO row — no camera takes a row for this device, and nothing took it for evaluation";
|
|
85501
87106
|
this.ctx.logger.debug(message, {
|
|
85502
87107
|
tags: { deviceId: data.deviceId },
|
|
85503
87108
|
meta: {
|