@camstack/addon-pipeline 1.2.373 → 1.2.375

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. package/dist/audio-analyzer/index.js +3 -3
  2. package/dist/audio-analyzer/index.mjs +3 -3
  3. package/dist/{custom-model-catalog-UaaDgBow.mjs → custom-model-catalog-DF5qs8u4.mjs} +2 -2
  4. package/dist/{custom-model-catalog-M-ECFeNJ.js → custom-model-catalog-gbLoUFki.js} +2 -2
  5. package/dist/detection-pipeline/index.js +4 -4
  6. package/dist/detection-pipeline/index.mjs +4 -4
  7. package/dist/{dist-QBVFmPYQ.js → dist-C0Mg8e3I.js} +924 -50
  8. package/dist/{dist-BT3uSeR7.mjs → dist-QyeZXkAO.mjs} +919 -51
  9. package/dist/{model-catalogs-C42Mo0jo.js → model-catalogs-BTuX-P0k.js} +1 -1
  10. package/dist/{model-catalogs-ChW0i2P6.mjs → model-catalogs-CIE4Dz4x.mjs} +1 -1
  11. package/dist/motion-wasm/index.js +1 -1
  12. package/dist/motion-wasm/index.mjs +1 -1
  13. package/dist/{node-B6ev-Hqa.js → node-CifGKjsd.js} +1 -1
  14. package/dist/{node-BVRFl1hZ.mjs → node-ndjp9DQO.mjs} +1 -1
  15. package/dist/pipeline-runner/index.js +3 -3
  16. package/dist/pipeline-runner/index.mjs +3 -3
  17. package/dist/{process-memory-Cdf2A2qg.js → process-memory-BMfSP5-i.js} +1 -1
  18. package/dist/{process-memory-DdeedEgU.mjs → process-memory-CaMI-Ry2.mjs} +1 -1
  19. package/dist/recorder/index.js +265 -94
  20. package/dist/recorder/index.mjs +263 -92
  21. package/dist/{segment-demux-js-8CfbEFeD.mjs → segment-demux-js-CLXBgUTo.mjs} +1 -1
  22. package/dist/{segment-demux-js-Dp0OI-2n.js → segment-demux-js-LhwVw18z.js} +1 -1
  23. package/dist/stream-broker/_stub.js +2 -2
  24. package/dist/stream-broker/{_virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-Dl3wrJnM.mjs → _virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-B4MD_B4k.mjs} +2 -2
  25. package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-BvMrdOCD.mjs +26 -0
  26. package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_ui_mf_2_library__loadShare__.js-JVFs9LgI.mjs +26 -0
  27. package/dist/stream-broker/demux-worker-child.js +1 -1
  28. package/dist/stream-broker/demux-worker-child.mjs +1 -1
  29. package/dist/stream-broker/{hostInit-Bq36nWMP.mjs → hostInit-DBDp5YHD.mjs} +2 -2
  30. package/dist/stream-broker/index.js +1229 -288
  31. package/dist/stream-broker/index.mjs +1229 -288
  32. package/dist/stream-broker/remoteEntry.js +1 -1
  33. package/package.json +5 -1
  34. package/dist/retire-root-keys-Csb8z2f3.js +0 -338
  35. package/dist/retire-root-keys-DZvlso2l.mjs +0 -327
  36. package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-C3ssSNKh.mjs +0 -26
  37. package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_ui_mf_2_library__loadShare__.js-BFRtKBOe.mjs +0 -26
@@ -3,12 +3,11 @@ Object.defineProperties(exports, {
3
3
  [Symbol.toStringTag]: { value: "Module" }
4
4
  });
5
5
  const require_chunk = require("../chunk-emK7D4bc.js");
6
- const require_dist = require("../dist-QBVFmPYQ.js");
7
- const require_node = require("../node-B6ev-Hqa.js");
6
+ const require_dist = require("../dist-C0Mg8e3I.js");
7
+ const require_node = require("../node-CifGKjsd.js");
8
8
  const require_restream_intent = require("../restream-intent-7uDNqMKs.js");
9
9
  const require_remote_restream = require("../remote-restream-CO36Sr30.js");
10
- const require_retire_root_keys = require("../retire-root-keys-Csb8z2f3.js");
11
- const require_segment_demux_js = require("../segment-demux-js-Dp0OI-2n.js");
10
+ const require_segment_demux_js = require("../segment-demux-js-LhwVw18z.js");
12
11
  let node_os = require("node:os");
13
12
  node_os = require_chunk.__toESM(node_os);
14
13
  let node_crypto = require("node:crypto");
@@ -364,207 +363,253 @@ function formatProbedSummary(declared, snapshot, opts) {
364
363
  return parts.join(SEP);
365
364
  }
366
365
  //#endregion
367
- //#region src/stream-broker/broker-row-stores.ts
368
- /**
369
- * The stream-broker's flattened settings maps — one row per entry.
370
- *
371
- * ## What this replaced
372
- *
373
- * `stream-broker:addon-settings` / `root` was 33 155 bytes on the reference
374
- * hub, and three keys were 32 735 of it: `probeSnapshots` (17 282 B, 109
375
- * streams), `rtspTokens` (13 147 B, 243 streams) and `profileMap` (2 306 B, 29
376
- * cameras — a per-DEVICE map, so it grows with the fleet). Every read of that
377
- * row parsed all of it, including `BaseAddon.resolveConfig`, and every write
378
- * rewrote all of it — a probe flush every few seconds while streams are live.
379
- *
380
- * Two keys deliberately STAY blob keys on that row, and the reasons are not
381
- * "they are small":
382
- *
383
- * - `rtspEnabled` — brokerId → boolean, EMPTY on the reference hub. Its
384
- * persister replaces the whole map from the set of registered restreamers,
385
- * and reproducing that on rows means an authoritative prune. A prune driven
386
- * by a map that shrinks whenever a broker unregisters is exactly the shape
387
- * that turns an unbind into a wipe (D130, D49), and the twin key
388
- * `rtspTokens` carries a comment about precisely that loss. Nothing is
389
- * bought by moving 0 bytes at that risk.
390
- * - `deviceOverrides` — 354 B, two cameras, same replace-the-whole-map
391
- * persister, written only when an operator edits an override.
392
- *
393
- * The row survives either way: it is also where `BaseAddon.resolveConfig` reads
394
- * the operator's config fields from. The problem was a 33 KB row, not a blob.
395
- */
396
- /**
397
- * The addon id — the settings-store namespace whose `root` row the flattened
398
- * keys are being retired from. Matches `packages/addon-pipeline/package.json`
399
- * (`camstack.addons[].id`).
400
- */
401
- var STREAM_BROKER_ADDON_ID = "stream-broker";
402
- /**
403
- * Every one of these collections is keyed by the BROKER ID
404
- * (`<deviceId>/<camStreamId>`) or by the numeric deviceId. `brokerDeviceId`
405
- * recovers the numeric device from either, for the projected column that makes
406
- * a per-camera question an indexed query — and for `tags: { deviceId }` on
407
- * every line these stores log.
408
- *
409
- * Returns `null` rather than guessing: a key whose head is not a number is a
410
- * key this addon did not write, and inventing a device for it would put a row
411
- * on a camera at random.
412
- */
413
- function brokerDeviceId(key) {
414
- const head = key.includes("/") ? key.slice(0, key.indexOf("/")) : key;
415
- const id = Number(head);
416
- return Number.isInteger(id) && id > 0 ? id : null;
417
- }
418
- /**
419
- * Bound for a whole-map read. `settings-store.query` silently caps an unbounded
420
- * read at 2 000 rows and answers as if that were everything; at 974 devices ×
421
- * up to 5 streams the default is HALF the margin, so every read here names its
422
- * own ceiling and a truncation is reported rather than shrunk.
423
- */
424
- var BROKER_ROWS_LIMIT = 2e4;
425
- /**
426
- * @durable class=mirror owner=stream-broker
427
- * write="one row per broker (`<deviceId>/<camStreamId>`), replaced wholesale from the
428
- * manager's live probe map by the throttled metrics flush and once on shutdown"
429
- * retention="the flush is authoritative: a broker the manager no longer holds loses its
430
- * row. Losing the whole collection costs the settings 'Probed' field showing 'not yet
431
- * probed' until the stream is pulled again — it is re-derived from the next probe."
432
- */
433
- var BROKER_PROBE_SNAPSHOTS_COLLECTION = "stream-broker:probe-snapshots";
434
- var BROKER_PROBE_SNAPSHOTS_SPEC = {
435
- collection: BROKER_PROBE_SNAPSHOTS_COLLECTION,
436
- schema: ProbedSnapshotSchema,
437
- columns: [{
438
- name: "deviceId",
439
- type: "INTEGER"
440
- }, {
441
- name: "at",
442
- type: "INTEGER",
443
- notNull: true
444
- }],
445
- indexes: [{
446
- name: "idx_stream_broker_probe_device",
447
- columns: ["deviceId"]
448
- }],
449
- project: (key, value) => ({
450
- deviceId: brokerDeviceId(key),
451
- at: value.at
452
- }),
453
- deviceIdColumn: "deviceId",
454
- loadLimit: BROKER_ROWS_LIMIT
455
- };
456
- function probeSnapshotsStore(store, logger) {
457
- return new require_retire_root_keys.RowMapStore({
458
- spec: BROKER_PROBE_SNAPSHOTS_SPEC,
459
- store,
460
- logger
461
- });
462
- }
463
- /**
464
- * The token, wrapped in an object rather than stored bare.
465
- *
466
- * A bare string in a `JSON` column round-trips only because the engine's
467
- * decoder JSON-parses a stored string ONLY when it starts with `{` or `[`. That
468
- * is a heuristic, not a contract; a token is opaque hex today and a future
469
- * format that starts with a brace would come back as an object. One field costs
470
- * nothing and makes the row self-describing.
471
- */
472
- var RtspTokenRowSchema = require_dist.object({ token: require_dist.string().min(1) });
473
- /**
474
- * @durable class=ledger owner=stream-broker
475
- * write="one row per broker (`<deviceId>/<camStreamId>`), upserted when the restream
476
- * server mints a token. MERGE only — a token is stable forever per stream unless it
477
- * is explicitly regenerated"
478
- * retention="nothing deletes a row. The persister receives only the CURRENTLY registered
479
- * restreamers, so a replace would erase the token of an unregistered broker and turn
480
- * an external NVR URL into a dead 404 at the next idle or reboot."
481
- */
482
- var BROKER_RTSP_TOKENS_COLLECTION = "stream-broker:rtsp-tokens";
483
- var BROKER_RTSP_TOKENS_SPEC = {
484
- collection: BROKER_RTSP_TOKENS_COLLECTION,
485
- schema: RtspTokenRowSchema,
486
- columns: [{
487
- name: "deviceId",
488
- type: "INTEGER"
489
- }],
490
- indexes: [{
491
- name: "idx_stream_broker_rtsp_tokens_device",
492
- columns: ["deviceId"]
493
- }],
494
- project: (key) => ({ deviceId: brokerDeviceId(key) }),
495
- deviceIdColumn: "deviceId",
496
- loadLimit: BROKER_ROWS_LIMIT
497
- };
498
- function rtspTokensStore(store, logger) {
499
- return new require_retire_root_keys.RowMapStore({
500
- spec: BROKER_RTSP_TOKENS_SPEC,
501
- store,
502
- logger
503
- });
504
- }
505
- /** One camera's profile → camStreamId assignment, plus whether it is automatic. */
366
+ //#region src/stream-broker/broker-device-state.ts
367
+ /**
368
+ * The stream-broker's per-camera state, kept as per-device settings (D870).
369
+ *
370
+ * Three maps used to be collections of their own (`stream-broker:probe-snapshots`,
371
+ * `:profile-map`, `:rtsp-tokens`), each keyed by the camera. A camera's data in
372
+ * a side table is a row somebody must remember to delete when the camera goes;
373
+ * as a KEY of the camera's own settings it is cleared by the central device
374
+ * delete (D865) with everything else.
375
+ *
376
+ * | key | value for one camera |
377
+ * | ---------------- | --------------------------------------------------------- |
378
+ * | `probeSnapshots` | `{ [camStreamId]: ProbedSnapshot }` - what the broker last probed |
379
+ * | `brokerProfile` | `{ map, auto }` - the profile -> camStreamId assignment |
380
+ * | `rtspTokens` | JSON text of `{ [camStreamId]: token }` - a SECRET: declared in `secretStatePaths`, so the settings view stores a keychain marker and the text lives in the keychain |
381
+ *
382
+ * Failure direction: a load that cannot ask the store THROWS (D49). The writers
383
+ * built on {@link DeviceKeyMirror} refuse to write until a load has succeeded,
384
+ * because a replace driven by an empty "known" set would otherwise drop or
385
+ * shrink rows it never saw.
386
+ */
387
+ /** Per-device settings keys. */
388
+ var PROBE_SNAPSHOTS_KEY = "probeSnapshots";
389
+ var BROKER_PROFILE_KEY = "brokerProfile";
390
+ var RTSP_TOKENS_KEY = "rtspTokens";
391
+ /** One camera's profile -> camStreamId assignment, plus whether it is automatic. */
506
392
  var ProfileAssignmentSchema = require_dist.object({
507
393
  map: require_dist.record(require_dist.string(), require_dist.string()),
508
394
  auto: require_dist.boolean()
509
395
  });
396
+ var ProbeSnapshotsOfDeviceSchema = require_dist.record(require_dist.string(), ProbedSnapshotSchema);
397
+ var RtspTokensOfDeviceSchema = require_dist.record(require_dist.string(), require_dist.string().min(1));
510
398
  /**
511
- * @durable class=config owner=stream-broker
512
- * write="one row per camera, replaced wholesale from the manager's assignment map on
513
- * every profile mutation — the map is authoritative and its boot pass PRUNES cameras
514
- * the device registry no longer knows, which is why the write is a replace and not
515
- * an upsert"
516
- * retention="the replace is the retention: a camera dropped from the manager's map loses
517
- * its row. Losing a row costs one automatic re-assignment on the next catalog pull."
518
- */
519
- var BROKER_PROFILE_MAP_COLLECTION = "stream-broker:profile-map";
520
- var BROKER_PROFILE_MAP_SPEC = {
521
- collection: BROKER_PROFILE_MAP_COLLECTION,
522
- schema: ProfileAssignmentSchema,
523
- columns: [{
524
- name: "deviceId",
525
- type: "INTEGER",
526
- notNull: true
527
- }, {
528
- name: "auto",
529
- type: "BOOLEAN",
530
- notNull: true
531
- }],
532
- project: (key, value) => ({
533
- deviceId: Number(key),
534
- auto: value.auto
535
- }),
536
- deviceIdColumn: "deviceId",
537
- loadLimit: BROKER_ROWS_LIMIT
399
+ * A broker id is `<deviceId>/<camStreamId>`. Returns `null` rather than
400
+ * guessing: a key whose head is not a number is a key this addon did not
401
+ * write, and inventing a device for it would put a value on a camera at random.
402
+ */
403
+ function splitBrokerId(brokerId) {
404
+ const slash = brokerId.indexOf("/");
405
+ if (slash <= 0) return null;
406
+ const deviceId = Number(brokerId.slice(0, slash));
407
+ if (!Number.isInteger(deviceId) || deviceId <= 0) return null;
408
+ return {
409
+ deviceId,
410
+ camStreamId: brokerId.slice(slash + 1)
411
+ };
412
+ }
413
+ /** Group `<deviceId>/<camStreamId>` -> value into deviceId -> camStreamId -> value. */
414
+ function groupByDevice(flat) {
415
+ const entries = flat instanceof Map ? [...flat.entries()] : Object.entries(flat);
416
+ const out = /* @__PURE__ */ new Map();
417
+ for (const [brokerId, value] of entries) {
418
+ const parts = splitBrokerId(brokerId);
419
+ if (parts === null) continue;
420
+ const group = out.get(parts.deviceId) ?? {};
421
+ group[parts.camStreamId] = value;
422
+ out.set(parts.deviceId, group);
423
+ }
424
+ return out;
425
+ }
426
+ /** The inverse of {@link groupByDevice}. */
427
+ function flattenByDevice(grouped) {
428
+ const out = /* @__PURE__ */ new Map();
429
+ for (const [deviceId, group] of grouped) for (const [camStreamId, value] of Object.entries(group)) out.set(`${deviceId}/${camStreamId}`, value);
430
+ return out;
431
+ }
432
+ /** A stored value that exists but cannot be read now (see `DeviceKeyMirrorDeps.decode`). */
433
+ var HELD_UNAVAILABLE = Symbol("held-unavailable");
434
+ /**
435
+ * One settings key across the fleet: loaded once, then written by diff.
436
+ *
437
+ * `replace` is authoritative (a camera the caller no longer holds loses the
438
+ * key, but only if this mirror has SEEN it - it never deletes what it did not
439
+ * load); `merge` only ever adds and changes.
440
+ */
441
+ var DeviceKeyMirror = class {
442
+ deps;
443
+ /** The JSON of what the store holds per device, as of the last load or write. */
444
+ held = /* @__PURE__ */ new Map();
445
+ values = /* @__PURE__ */ new Map();
446
+ blocked = /* @__PURE__ */ new Map();
447
+ /** Cameras whose stored value is GONE: a load never re-blocks them. */
448
+ released = /* @__PURE__ */ new Set();
449
+ loaded = false;
450
+ constructor(deps) {
451
+ this.deps = deps;
452
+ }
453
+ /** Read the key of every camera that has it. THROWS when the store cannot be asked. */
454
+ async load() {
455
+ const ids = await this.deps.settings.listDeviceStoreIds();
456
+ const blobs = await this.deps.settings.readDeviceStoreBatch(ids);
457
+ const out = /* @__PURE__ */ new Map();
458
+ this.held.clear();
459
+ this.values.clear();
460
+ this.loaded = false;
461
+ this.blocked.clear();
462
+ for (const [deviceId, blob] of blobs) {
463
+ const stored = blob[this.deps.key];
464
+ if (stored === void 0) continue;
465
+ const value = this.deps.decode(stored);
466
+ if (value === HELD_UNAVAILABLE) {
467
+ if (!this.released.has(deviceId)) this.blocked.set(deviceId, stored);
468
+ continue;
469
+ }
470
+ if (value === null) {
471
+ this.deps.logger.warn("broker device state skipped - it does not parse", {
472
+ tags: { deviceId },
473
+ meta: { key: this.deps.key }
474
+ });
475
+ continue;
476
+ }
477
+ out.set(deviceId, value);
478
+ this.values.set(deviceId, value);
479
+ this.held.set(deviceId, JSON.stringify(this.deps.encode(value)));
480
+ }
481
+ this.loaded = true;
482
+ return out;
483
+ }
484
+ /** Cameras whose stored value exists but could not be read: never written, never replaced. */
485
+ heldDevices() {
486
+ return this.blocked;
487
+ }
488
+ /**
489
+ * Stop protecting a camera whose stored value is GONE (the secret was deleted
490
+ * or the keychain restored without it): there is nothing left to overwrite.
491
+ */
492
+ release(deviceId) {
493
+ this.blocked.delete(deviceId);
494
+ this.released.add(deviceId);
495
+ }
496
+ /** Is this camera's token write (and restream URL) blocked right now? */
497
+ isBlocked(deviceId) {
498
+ return this.blocked.has(deviceId);
499
+ }
500
+ assertLoaded() {
501
+ if (!this.loaded) throw new Error(`broker device state "${this.deps.key}" was never loaded - refusing to write`);
502
+ }
503
+ async writeChanged(next) {
504
+ let written = 0;
505
+ for (const [deviceId, value] of next) {
506
+ if (this.blocked.has(deviceId)) continue;
507
+ const encoded = this.deps.encode(value);
508
+ const text = JSON.stringify(encoded);
509
+ if (this.held.get(deviceId) === text) continue;
510
+ await this.deps.settings.writeDeviceStore(deviceId, { [this.deps.key]: encoded });
511
+ this.held.set(deviceId, text);
512
+ this.values.set(deviceId, value);
513
+ written += 1;
514
+ }
515
+ return written;
516
+ }
517
+ /** Make the store hold exactly `next` for the cameras this mirror knows. */
518
+ async replace(next) {
519
+ this.assertLoaded();
520
+ const written = await this.writeChanged(next);
521
+ let removed = 0;
522
+ for (const deviceId of [...this.held.keys()]) {
523
+ if (next.has(deviceId) || this.blocked.has(deviceId)) continue;
524
+ await this.deps.settings.writeDeviceStore(deviceId, { [this.deps.key]: void 0 });
525
+ this.held.delete(deviceId);
526
+ this.values.delete(deviceId);
527
+ removed += 1;
528
+ }
529
+ return {
530
+ written,
531
+ removed
532
+ };
533
+ }
534
+ /** Add or change; never remove. */
535
+ async merge(next) {
536
+ this.assertLoaded();
537
+ return this.writeChanged(next);
538
+ }
539
+ /** Drop one camera's key (a device that changed hardware under its number). */
540
+ async remove(deviceId) {
541
+ this.assertLoaded();
542
+ if (!this.held.has(deviceId) || this.blocked.has(deviceId)) return false;
543
+ await this.deps.settings.writeDeviceStore(deviceId, { [this.deps.key]: void 0 });
544
+ this.held.delete(deviceId);
545
+ this.values.delete(deviceId);
546
+ return true;
547
+ }
548
+ /** What the store holds for the camera as of the last load or write. */
549
+ current(deviceId) {
550
+ return this.values.get(deviceId);
551
+ }
552
+ /** Whether a load has answered; a writer that was never loaded refuses. */
553
+ get isLoaded() {
554
+ return this.loaded;
555
+ }
538
556
  };
539
- function profileMapStore(store, logger) {
540
- return new require_retire_root_keys.RowMapStore({
541
- spec: BROKER_PROFILE_MAP_SPEC,
542
- store,
543
- logger
557
+ function parseWith(schema) {
558
+ return (stored) => {
559
+ const parsed = schema.safeParse(stored);
560
+ return parsed.success ? parsed.data : null;
561
+ };
562
+ }
563
+ function probeSnapshotsMirror(settings, logger) {
564
+ return new DeviceKeyMirror({
565
+ settings,
566
+ key: PROBE_SNAPSHOTS_KEY,
567
+ logger,
568
+ decode: parseWith(ProbeSnapshotsOfDeviceSchema),
569
+ encode: (value) => value
570
+ });
571
+ }
572
+ function profileMapMirror(settings, logger) {
573
+ return new DeviceKeyMirror({
574
+ settings,
575
+ key: BROKER_PROFILE_KEY,
576
+ logger,
577
+ decode: parseWith(ProfileAssignmentSchema),
578
+ encode: (value) => value
579
+ });
580
+ }
581
+ /** The tokens of one camera are ONE keychain-held text: JSON of `{ camStreamId: token }`. */
582
+ function rtspTokensMirror(settings, logger) {
583
+ return new DeviceKeyMirror({
584
+ settings,
585
+ key: RTSP_TOKENS_KEY,
586
+ logger,
587
+ decode: (stored) => {
588
+ if (require_dist.isSecretMarker(stored)) return HELD_UNAVAILABLE;
589
+ if (typeof stored !== "string") return null;
590
+ try {
591
+ const parsed = RtspTokensOfDeviceSchema.safeParse(JSON.parse(stored));
592
+ return parsed.success ? parsed.data : null;
593
+ } catch {
594
+ return null;
595
+ }
596
+ },
597
+ encode: (value) => JSON.stringify(value)
544
598
  });
545
599
  }
546
600
  /**
547
- * The dead `addon-settings` / `root` keys. A CLAIM that each key is dead,
548
- * enforced by nothing else — the migration script names the same three, keep
549
- * the two in lockstep.
601
+ * Fold freshly minted tokens (`<deviceId>/<camStreamId>` -> token) into what
602
+ * each camera already holds. Only ever adds or changes: a token is stable
603
+ * forever per stream unless explicitly regenerated.
550
604
  */
551
- var STREAM_BROKER_RETIRED_ROOT_KEYS = [
552
- {
553
- key: "probeSnapshots",
554
- successor: BROKER_PROBE_SNAPSHOTS_COLLECTION,
555
- reason: "one row per broker since the flatten; as a blob it was 17 KB of the 33 KB root row, rewritten whole by the throttled metrics flush"
556
- },
557
- {
558
- key: "rtspTokens",
559
- successor: BROKER_RTSP_TOKENS_COLLECTION,
560
- reason: "one row per broker since the flatten; as a blob a single minted token rewrote 33 KB"
561
- },
562
- {
563
- key: "profileMap",
564
- successor: BROKER_PROFILE_MAP_COLLECTION,
565
- reason: "one row per camera since the flatten; a per-DEVICE map, so as a blob it grew with the fleet — 2.3 KB at 29 cameras, ~77 KB at 974"
566
- }
567
- ];
605
+ function mergeRtspTokens(mirror, minted) {
606
+ const out = /* @__PURE__ */ new Map();
607
+ for (const [deviceId, group] of groupByDevice(minted)) out.set(deviceId, {
608
+ ...mirror.current(deviceId),
609
+ ...group
610
+ });
611
+ return out;
612
+ }
568
613
  //#endregion
569
614
  //#region src/stream-broker/live-viewers.ts
570
615
  /**
@@ -813,6 +858,131 @@ function healthEventIsOperatorFacing(camStreamId) {
813
858
  return !PRIVATE_STREAM_PREFIXES.some((prefix) => camStreamId.startsWith(prefix));
814
859
  }
815
860
  //#endregion
861
+ //#region src/stream-broker/stream-broker/stream-health-policy.ts
862
+ /**
863
+ * WHAT a stale stream means for a battery camera (D883).
864
+ *
865
+ * A mains camera whose stream goes quiet for the stale window has lost its
866
+ * stream: that is an outage. A battery camera whose stream goes quiet has,
867
+ * ordinarily, gone to sleep — the designed steady state, not a fault. The
868
+ * broker used to tell the two apart by nothing, so every sleep produced a
869
+ * `stream.offline` per profile and every wake a `stream.online`, and the
870
+ * operator's system-event rule pushed each one.
871
+ *
872
+ * The verdict is made HERE, at the emitter, from the battery's own sleep
873
+ * mirror — never by a filter downstream:
874
+ *
875
+ * - asleep and nobody asked for the stream — NOT an outage (`asleep`);
876
+ * - asleep, a stream was DEMANDED and none arrived — `wake-timeout`;
877
+ * - reported awake and the stream is gone — `connection-error`;
878
+ * - silent past the battery refresh window — `battery-unreachable`.
879
+ *
880
+ * A sleep verdict needs a sleep state that was actually READ. A mirror that
881
+ * was never fed (a restart, a dropped event) is not "asleep": the caller passes
882
+ * `battery: null` and the stream is judged exactly as a mains one (D49 —
883
+ * unknown excludes nothing, and a silent suppression on an unread mirror hid
884
+ * a camera that slept and then died).
885
+ */
886
+ var OUTAGE_REASONS = [
887
+ "stale-timeout",
888
+ "wake-timeout",
889
+ "connection-error",
890
+ "battery-unreachable"
891
+ ];
892
+ /**
893
+ * How long a demand may wait for a sleeping camera's stream before it counts
894
+ * as a failed wake: the firmware wake bound plus the broker settle window of
895
+ * wake-on-play (8 s + 20 s), with margin for the sweep cadence.
896
+ */
897
+ var WAKE_DEMAND_BOUND_MS = 45e3;
898
+ function isStreamOutageReason(reason) {
899
+ return reason !== void 0 && OUTAGE_REASONS.includes(reason);
900
+ }
901
+ function judgeStaleStream(input) {
902
+ if (input.battery === null) return {
903
+ kind: "outage",
904
+ reason: "stale-timeout"
905
+ };
906
+ const presence = require_dist.deriveBatteryPresence({
907
+ status: input.battery.facts,
908
+ nowMs: input.nowMs
909
+ });
910
+ if (presence === "unreachable") return {
911
+ kind: "outage",
912
+ reason: "battery-unreachable"
913
+ };
914
+ if (presence === "awake") return {
915
+ kind: "outage",
916
+ reason: "connection-error"
917
+ };
918
+ if (input.demanded) return {
919
+ kind: "outage",
920
+ reason: "wake-timeout"
921
+ };
922
+ return {
923
+ kind: "suppressed",
924
+ presence
925
+ };
926
+ }
927
+ //#endregion
928
+ //#region src/stream-broker/stream-broker/device-stream-outages.ts
929
+ var DeviceStreamOutages = class {
930
+ byDevice = /* @__PURE__ */ new Map();
931
+ get(deviceId) {
932
+ return this.byDevice.get(deviceId);
933
+ }
934
+ /** Open the device's outage, or add this stream to the one already open. */
935
+ open(deviceId, camStreamId, reason, now) {
936
+ const existing = this.byDevice.get(deviceId);
937
+ if (existing) {
938
+ existing.camStreamIds.add(camStreamId);
939
+ existing.wakeOnly = false;
940
+ existing.lastSignalAt = now;
941
+ return existing;
942
+ }
943
+ const created = {
944
+ reason,
945
+ since: now,
946
+ camStreamIds: new Set([camStreamId]),
947
+ announced: false,
948
+ wakeOnly: false,
949
+ lastSignalAt: now
950
+ };
951
+ this.byDevice.set(deviceId, created);
952
+ return created;
953
+ }
954
+ /**
955
+ * A failed wake with no broker to carry it. Joins the device's open outage
956
+ * (one outage per device) or opens a wake-only one over its REAL streams.
957
+ */
958
+ openWake(deviceId, camStreamIds, reason, now) {
959
+ const existing = this.byDevice.get(deviceId);
960
+ if (existing) {
961
+ existing.lastSignalAt = now;
962
+ return existing;
963
+ }
964
+ const created = {
965
+ reason,
966
+ since: now,
967
+ camStreamIds: new Set(camStreamIds),
968
+ announced: false,
969
+ wakeOnly: true,
970
+ lastSignalAt: now
971
+ };
972
+ this.byDevice.set(deviceId, created);
973
+ return created;
974
+ }
975
+ entries() {
976
+ return this.byDevice.entries();
977
+ }
978
+ close(deviceId) {
979
+ this.byDevice.delete(deviceId);
980
+ }
981
+ clear() {
982
+ this.byDevice.clear();
983
+ }
984
+ };
985
+ //#endregion
816
986
  //#region src/stream-broker/stream-broker/sentry-eligibility.ts
817
987
  function sentryEligible(input) {
818
988
  if (input.batteryOperated === null || input.declaresSignals === null) return {
@@ -5532,6 +5702,18 @@ var RtspListenServer = class RtspListenServer {
5532
5702
  brokerTokens = /* @__PURE__ */ new Map();
5533
5703
  /** token → brokerId (reverse map) */
5534
5704
  tokenBrokers = /* @__PURE__ */ new Map();
5705
+ /**
5706
+ * brokerId → INTERNAL loopback-only alias token (D870). A camera whose
5707
+ * external restream token is held in an unreadable keychain has NO external
5708
+ * URL, but the broker's own consumers (recording, decode, snapshots, grid,
5709
+ * egress) still ride its restream: they get this ephemeral token, which is
5710
+ * never persisted, never handed out through the consumer URL APIs, and only
5711
+ * answers loopback / local-network peers.
5712
+ */
5713
+ internalAliases = /* @__PURE__ */ new Map();
5714
+ internalAliasTokens = /* @__PURE__ */ new Set();
5715
+ /** Which peers may dial an internal alias; replaceable for tests. */
5716
+ aliasPeerPolicy = isClusterLocalAddress;
5535
5717
  /** brokerId → enabled (default true) */
5536
5718
  brokerEnabled = /* @__PURE__ */ new Map();
5537
5719
  /** brokerId → warm-up closure registered alongside the restreamer */
@@ -5562,7 +5744,37 @@ var RtspListenServer = class RtspListenServer {
5562
5744
  if (sentryOpener) this.brokerPrestreamOpeners.set(brokerId, sentryOpener);
5563
5745
  return token;
5564
5746
  }
5747
+ /**
5748
+ * Register (or refresh) the internal loopback-only alias of a broker. Returns
5749
+ * its token. The external token map is untouched, so the alias can never be
5750
+ * listed, persisted or surfaced as a consumer URL.
5751
+ */
5752
+ registerInternalAlias(brokerId, restreamer, warmUp, sentryOpener) {
5753
+ const token = this.internalAliases.get(brokerId) ?? RtspListenServer.generateToken();
5754
+ this.restreamers.set(token, restreamer);
5755
+ this.tokenBrokers.set(token, brokerId);
5756
+ this.internalAliases.set(brokerId, token);
5757
+ this.internalAliasTokens.add(token);
5758
+ if (warmUp) this.brokerWarmUps.set(brokerId, warmUp);
5759
+ if (sentryOpener) this.brokerPrestreamOpeners.set(brokerId, sentryOpener);
5760
+ return token;
5761
+ }
5762
+ /** Replace the peer policy of the internal aliases (tests only). */
5763
+ setAliasPeerPolicy(policy) {
5764
+ this.aliasPeerPolicy = policy;
5765
+ }
5766
+ /** The token an IN-PROCESS consumer dials: the internal alias when there is one, else the external token. */
5767
+ getInternalToken(brokerId) {
5768
+ return this.internalAliases.get(brokerId) ?? this.brokerTokens.get(brokerId);
5769
+ }
5565
5770
  unregisterRestreamer(brokerId) {
5771
+ const alias = this.internalAliases.get(brokerId);
5772
+ if (alias !== void 0) {
5773
+ this.restreamers.delete(alias);
5774
+ this.tokenBrokers.delete(alias);
5775
+ this.internalAliasTokens.delete(alias);
5776
+ this.internalAliases.delete(brokerId);
5777
+ }
5566
5778
  const token = this.brokerTokens.get(brokerId);
5567
5779
  if (token) {
5568
5780
  this.restreamers.delete(token);
@@ -5584,6 +5796,17 @@ var RtspListenServer = class RtspListenServer {
5584
5796
  this.restreamers.set(newToken, restreamer);
5585
5797
  this.brokerTokens.set(brokerId, newToken);
5586
5798
  this.tokenBrokers.set(newToken, brokerId);
5799
+ const alias = this.internalAliases.get(brokerId);
5800
+ if (alias !== void 0) {
5801
+ this.restreamers.delete(alias);
5802
+ this.tokenBrokers.delete(alias);
5803
+ this.internalAliasTokens.delete(alias);
5804
+ const newAlias = RtspListenServer.generateToken();
5805
+ this.restreamers.set(newAlias, restreamer);
5806
+ this.tokenBrokers.set(newAlias, brokerId);
5807
+ this.internalAliases.set(brokerId, newAlias);
5808
+ this.internalAliasTokens.add(newAlias);
5809
+ }
5587
5810
  return newToken;
5588
5811
  }
5589
5812
  /** Get the current token for a broker. */
@@ -5667,6 +5890,11 @@ var RtspListenServer = class RtspListenServer {
5667
5890
  socket.destroy();
5668
5891
  return;
5669
5892
  }
5893
+ if (this.internalAliasTokens.has(lookupPath) && !this.aliasPeerPolicy(socket.remoteAddress)) {
5894
+ socket.write(`RTSP/1.0 403 Forbidden\r\nCSeq: 1\r\n\r\n`);
5895
+ socket.destroy();
5896
+ return;
5897
+ }
5670
5898
  const brokerId = this.tokenBrokers.get(lookupPath);
5671
5899
  if (brokerId && !this.isEnabled(brokerId)) {
5672
5900
  socket.write(`RTSP/1.0 403 Forbidden\r\nCSeq: 1\r\n\r\n`);
@@ -5725,6 +5953,24 @@ var RtspListenServer = class RtspListenServer {
5725
5953
  });
5726
5954
  }
5727
5955
  };
5956
+ /**
5957
+ * Is the peer on the loopback interface or the local network (RFC 1918, link-local,
5958
+ * ULA)? The internal alias answers only these: a recorder or decoder on another
5959
+ * node of the cluster is a legitimate consumer, the public internet is not.
5960
+ */
5961
+ function isClusterLocalAddress(address) {
5962
+ if (address === void 0) return false;
5963
+ const v4 = address.startsWith("::ffff:") ? address.slice(7) : address;
5964
+ if (v4 === "::1") return true;
5965
+ const m = /^(\d+)\.(\d+)\.\d+\.\d+$/.exec(v4);
5966
+ if (m !== null) {
5967
+ const a = Number(m[1]);
5968
+ const b = Number(m[2]);
5969
+ return a === 127 || a === 10 || a === 172 && b >= 16 && b <= 31 || a === 192 && b === 168 || a === 169 && b === 254 || a === 100 && b >= 64 && b <= 127;
5970
+ }
5971
+ const lower = address.toLowerCase();
5972
+ return lower.startsWith("fe80:") || lower.startsWith("fc") || lower.startsWith("fd");
5973
+ }
5728
5974
  //#endregion
5729
5975
  //#region src/stream-broker/rtsp/rtsp-restream-provider.ts
5730
5976
  /**
@@ -5806,6 +6052,24 @@ var RtspRestreamProviderImpl = class {
5806
6052
  enabled: this.server.isEnabled(brokerId)
5807
6053
  };
5808
6054
  }
6055
+ /**
6056
+ * The entry an IN-PROCESS consumer dials (recording, decode, snapshots, grid,
6057
+ * egress): the loopback-only internal alias when the camera's external token
6058
+ * is held back (D870), else the external entry. NEVER hand this out through a
6059
+ * consumer URL API - use {@link getEntry} for that.
6060
+ */
6061
+ getInternalEntry(brokerId, hostname) {
6062
+ const token = this.server.getInternalToken(brokerId);
6063
+ if (!token) return null;
6064
+ const port = this.server.getPort();
6065
+ const host = hostname ?? "127.0.0.1";
6066
+ return {
6067
+ brokerId,
6068
+ url: `rtsp://${host}:${port}/${token}`,
6069
+ mutedUrl: `rtsp://${host}:${port}/${token}/muted`,
6070
+ enabled: this.server.isEnabled(brokerId)
6071
+ };
6072
+ }
5809
6073
  regenerateToken(brokerId) {
5810
6074
  const token = this.server.regenerateToken(brokerId);
5811
6075
  if (token) {
@@ -14416,6 +14680,15 @@ var StreamBroker = class StreamBroker {
14416
14680
  return false;
14417
14681
  }
14418
14682
  /**
14683
+ * Does a consumer want this stream right now (a viewer, a recording, a
14684
+ * detection decode, a camera-reported wake hold)? Sentry sessions and idle
14685
+ * prestream do not count. The stream-health sweep reads it to tell a battery
14686
+ * camera that slept unasked from one that failed to wake for somebody (D883).
14687
+ */
14688
+ hasLiveDemand() {
14689
+ return this.hasLiveIntent();
14690
+ }
14691
+ /**
14419
14692
  * True when at least one consumer needs DECODED audio. The audio-specific
14420
14693
  * subset of {@link hasDemand} — deliberately excludes video-only
14421
14694
  * consumers so a video-only viewer never spins up the AAC/Opus decode
@@ -17315,6 +17588,10 @@ function resolveBrokerTier(brokerId, localLookup, remoteBrokerTiers) {
17315
17588
  */
17316
17589
  var STREAM_STALE_TIMEOUT_MS = 12e4;
17317
17590
  var STREAM_HEALTH_POLL_MS = 15e3;
17591
+ /** How often an unanswered / stale battery probe is asked again. */
17592
+ var BATTERY_PROBE_RETRY_MS = 6e4;
17593
+ /** A wake-only outage with no further failed wake for this long is over: the demand is gone. */
17594
+ var WAKE_ONLY_OUTAGE_TTL_MS = 5 * 6e4;
17318
17595
  /** Compose a SOURCE broker id from a `(deviceId, camStreamId)` tuple. Thin
17319
17596
  * wrapper over the canonical `makeSourceBrokerId` — single source of truth. */
17320
17597
  function brokerIdFor(deviceId, camStreamId) {
@@ -17507,6 +17784,25 @@ var StreamBrokerManager = class StreamBrokerManager {
17507
17784
  * guess which one it was looking at.
17508
17785
  */
17509
17786
  streamOfflineReasonByBroker = /* @__PURE__ */ new Map();
17787
+ /**
17788
+ * Brokers whose stream went quiet because their BATTERY camera is asleep.
17789
+ * Not an outage and never announced (D883); remembered so the wake that ends
17790
+ * it is recognised as a plain wake instead of a `recovered`.
17791
+ */
17792
+ sleepSuppressedBrokers = /* @__PURE__ */ new Set();
17793
+ /**
17794
+ * What the sweep knows of a device's battery: is it one, and what did its
17795
+ * `battery` slice last say. Fed by `BatteryOnStatusChanged` (droppable, empty
17796
+ * after a restart) AND read from the device registry / slice by the sweep
17797
+ * itself — the same two reads wake-on-play makes — so a restart or a lost
17798
+ * event never leaves the verdict standing on an unread mirror (D49).
17799
+ */
17800
+ batteryProbe = /* @__PURE__ */ new Map();
17801
+ batteryProbeInFlight = /* @__PURE__ */ new Set();
17802
+ /** When a consumer's demand on a quiet battery stream was first seen (the wake bound starts there). */
17803
+ demandSince = /* @__PURE__ */ new Map();
17804
+ /** The stream outage a DEVICE is in (one entry per device, however many profiles). */
17805
+ deviceOutages = new DeviceStreamOutages();
17510
17806
  streamHealthTimer;
17511
17807
  /**
17512
17808
  * Routes an audio-chunk `subscriptionId` (Phase 5 / D9) back to its owning
@@ -17539,6 +17835,14 @@ var StreamBrokerManager = class StreamBrokerManager {
17539
17835
  rtspProvider;
17540
17836
  persistedTokens = /* @__PURE__ */ new Map();
17541
17837
  /**
17838
+ * True for a camera whose restream tokens are held in an unreadable keychain
17839
+ * (D870). Such a camera gets NO restream URL - minting a temporary token would
17840
+ * hand consumers a URL that dies as soon as the real one is restored - while
17841
+ * every other camera, and the camera's own streams and recording, are
17842
+ * untouched.
17843
+ */
17844
+ restreamGate = () => false;
17845
+ /**
17542
17846
  * Last runtime probe per broker, captured while the broker was streaming.
17543
17847
  * Survives the stream going idle so the per-stream "Probed" settings field
17544
17848
  * stays populated (with an "as of" tag). In-memory only — repopulates on the
@@ -17738,7 +18042,7 @@ var StreamBrokerManager = class StreamBrokerManager {
17738
18042
  hwaccel: this.hwaccel ?? void 0,
17739
18043
  cameraStreamLookup: (deviceId) => this.getCameraStreamsForDevice(deviceId),
17740
18044
  rtspEntryLookup: (brokerId) => {
17741
- const entry = this.rtspProvider.getEntry(brokerId);
18045
+ const entry = this.rtspProvider.getInternalEntry(brokerId);
17742
18046
  return entry ? {
17743
18047
  url: entry.url,
17744
18048
  mutedUrl: entry.mutedUrl
@@ -17797,7 +18101,7 @@ var StreamBrokerManager = class StreamBrokerManager {
17797
18101
  cameraStreamLookup: (deviceId) => this.getCameraStreamsForDevice(deviceId),
17798
18102
  resolveProfileSource: (deviceId, profile) => this.assignments.get(deviceId)?.map[profile] ?? null,
17799
18103
  rtspEntryLookup: (brokerId) => {
17800
- const entry = this.rtspProvider.getEntry(brokerId);
18104
+ const entry = this.rtspProvider.getInternalEntry(brokerId);
17801
18105
  return entry ? { url: entry.url } : null;
17802
18106
  },
17803
18107
  sourceHasMedia: (brokerId) => {
@@ -18439,6 +18743,7 @@ var StreamBrokerManager = class StreamBrokerManager {
18439
18743
  const deviceId = typeof data.deviceId === "number" ? data.deviceId : null;
18440
18744
  if (deviceId === null) return;
18441
18745
  const sleeping = data.status?.sleeping === true;
18746
+ this.recordBatterySleep(deviceId, data.status);
18442
18747
  this.applyPlaceholderReasonToDevice(deviceId, sleeping ? "sleeping" : "reconnecting");
18443
18748
  });
18444
18749
  bus.subscribe({ category: require_dist.EventCategory.BatteryOnWakeStarted }, (event) => {
@@ -18602,18 +18907,7 @@ var StreamBrokerManager = class StreamBrokerManager {
18602
18907
  await this.batteryWakeOnPlay.run(deviceId, {
18603
18908
  logger: this.logger,
18604
18909
  isBatteryDevice: (id) => this.isBatteryOperated(id),
18605
- readBatteryFacts: async (id) => {
18606
- const slice = await api.deviceState.getCapSlice.query({
18607
- deviceId: id,
18608
- capName: require_dist.batteryCapability.name
18609
- }).catch(() => null);
18610
- const parsed = require_dist.BatteryStatusSchema.safeParse(slice);
18611
- if (!parsed.success) return null;
18612
- return {
18613
- sleeping: parsed.data.sleeping,
18614
- lastContactAt: parsed.data.lastContactAt
18615
- };
18616
- },
18910
+ readBatteryFacts: (id) => this.readBatterySleepFacts(id),
18617
18911
  wakeForStream: async (id, timeoutMs) => api.battery.wakeForStream.mutate({
18618
18912
  deviceId: id,
18619
18913
  timeoutMs
@@ -18621,6 +18915,9 @@ var StreamBrokerManager = class StreamBrokerManager {
18621
18915
  reconcileCatalog: (id) => this.reconcileDeviceCatalog(id),
18622
18916
  ensureBroker: () => this.ensureBrokerForWebrtcSession(deviceId, brokerId, camStreamId),
18623
18917
  hasBroker: demandHasBroker
18918
+ }).catch((err) => {
18919
+ if (err instanceof BatteryStreamUnavailableError) this.announceBatteryWakeFailure(deviceId, camStreamId, err.presence === "unreachable" ? "battery-unreachable" : "wake-timeout");
18920
+ throw err;
18624
18921
  });
18625
18922
  }
18626
18923
  /** Any broker (local or remote-adapter) registered for this device? The
@@ -18861,6 +19158,20 @@ var StreamBrokerManager = class StreamBrokerManager {
18861
19158
  loadPersistedTokens(tokens) {
18862
19159
  this.persistedTokens = new Map(tokens);
18863
19160
  }
19161
+ /** See {@link restreamGate}. */
19162
+ setRestreamGate(gate) {
19163
+ this.restreamGate = gate;
19164
+ }
19165
+ /**
19166
+ * Register the restream URLs of ONE camera now (its tokens became readable, or
19167
+ * were issued fresh): each of its source brokers and the profile restreamers
19168
+ * over them. No broker is torn down or recreated, so nothing stops recording.
19169
+ */
19170
+ registerRestreamersForDevice(deviceId) {
19171
+ if (this.restreamGate(deviceId)) return;
19172
+ for (const [brokerId, broker] of this.brokers) if (brokerId.startsWith(`${deviceId}/`)) this.registerSourceRestreamer(brokerId, broker);
19173
+ this.syncProfileRestreamers(deviceId);
19174
+ }
18864
19175
  /** Upsert a single SOURCE (non-derived) cam-stream into the registry
18865
19176
  * WITHOUT ranking or broker creation. Returns the prior entry (if any)
18866
19177
  * so callers can detect url refresh / already-existed. */
@@ -19604,6 +19915,20 @@ var StreamBrokerManager = class StreamBrokerManager {
19604
19915
  return { success: true };
19605
19916
  }
19606
19917
  /**
19918
+ * Drop a DELETED camera's whole override entry and persist (D870). The
19919
+ * device-removal hook's one reach into the override map; `forgetDeviceHardware`
19920
+ * keeps the entry's non-hardware fields, a deleted camera keeps nothing.
19921
+ */
19922
+ dropDeviceOverride(deviceId) {
19923
+ if (!this.deviceOverrides.delete(deviceId)) return false;
19924
+ this.deviceOverridePersister?.(this.deviceOverrides);
19925
+ return true;
19926
+ }
19927
+ /** Cameras that have an override entry. */
19928
+ listDeviceOverrideIds() {
19929
+ return [...this.deviceOverrides.keys()];
19930
+ }
19931
+ /**
19607
19932
  * The hardware behind `deviceId` was replaced (`deviceManager.migrateDevice`)
19608
19933
  * — forget everything this broker knows that described the OLD box. See the
19609
19934
  * cap doc for the inventory. What deliberately SURVIVES is the role-scoped
@@ -20216,6 +20541,10 @@ var StreamBrokerManager = class StreamBrokerManager {
20216
20541
  this.brokers.clear();
20217
20542
  this.streamHealthByBroker.clear();
20218
20543
  this.streamOfflineReasonByBroker.clear();
20544
+ this.sleepSuppressedBrokers.clear();
20545
+ this.batteryProbe.clear();
20546
+ this.demandSince.clear();
20547
+ this.deviceOutages.clear();
20219
20548
  this.audioSubscriptionBroker.clear();
20220
20549
  await this.rtspServer.stop();
20221
20550
  }
@@ -20238,6 +20567,8 @@ var StreamBrokerManager = class StreamBrokerManager {
20238
20567
  evaluateStreamHealth() {
20239
20568
  if (!this.eventBus) return;
20240
20569
  const now = Date.now();
20570
+ const outageOpenedOn = /* @__PURE__ */ new Map();
20571
+ const cameBackOn = /* @__PURE__ */ new Map();
20241
20572
  for (const [brokerId, broker] of this.brokers) {
20242
20573
  const parsed = this.parseBrokerId(brokerId);
20243
20574
  if (!parsed) continue;
@@ -20247,30 +20578,386 @@ var StreamBrokerManager = class StreamBrokerManager {
20247
20578
  const wasHealthy = this.streamHealthByBroker.get(brokerId);
20248
20579
  const isHealthy = lastPacketAt > 0 && now - lastPacketAt <= STREAM_STALE_TIMEOUT_MS;
20249
20580
  if (!isHealthy && broker.getStats().status === "streaming") broker.redial("stream-health sweep: phantom streaming, no packets within stale window");
20250
- if (wasHealthy === isHealthy) continue;
20581
+ const context = {
20582
+ deviceId,
20583
+ camStreamId,
20584
+ brokerId,
20585
+ sourceType: broker.getActiveSourceType(),
20586
+ lastPacketAt
20587
+ };
20588
+ if (wasHealthy === isHealthy) {
20589
+ if (!isHealthy) this.rejudgeQuietBroker(brokerId, broker, context, now, outageOpenedOn);
20590
+ continue;
20591
+ }
20251
20592
  if (wasHealthy === void 0 && !isHealthy && !broker.suspended) {
20252
20593
  this.streamHealthByBroker.set(brokerId, false);
20253
20594
  continue;
20254
20595
  }
20596
+ let verdict = null;
20597
+ if (!isHealthy && !broker.suspended) {
20598
+ verdict = this.judgeQuietStream(brokerId, deviceId, broker, now);
20599
+ if (verdict === "defer") continue;
20600
+ }
20255
20601
  this.streamHealthByBroker.set(brokerId, isHealthy);
20256
- const reason = isHealthy ? wasHealthy === void 0 || !this.streamOfflineReasonByBroker.has(brokerId) ? "first-packet" : this.streamOfflineReasonByBroker.get(brokerId) === "suspended" ? "resumed" : "recovered" : broker.suspended ? "suspended" : "stale-timeout";
20257
- if (isHealthy) this.streamOfflineReasonByBroker.delete(brokerId);
20258
- else this.streamOfflineReasonByBroker.set(brokerId, reason);
20259
- this.emitStreamHealth(isHealthy, {
20602
+ const wasSleepSuppressed = this.sleepSuppressedBrokers.delete(brokerId);
20603
+ const previousReason = this.streamOfflineReasonByBroker.get(brokerId);
20604
+ if (verdict !== null) {
20605
+ if (verdict.kind === "suppressed") {
20606
+ this.markSleepSuppressed(brokerId, context, verdict.presence, lastPacketAt);
20607
+ continue;
20608
+ }
20609
+ this.openStreamOutage(brokerId, context, verdict.reason, now, outageOpenedOn);
20610
+ continue;
20611
+ }
20612
+ if (!isHealthy) {
20613
+ this.demandSince.delete(brokerId);
20614
+ this.streamOfflineReasonByBroker.set(brokerId, "suspended");
20615
+ this.emitStreamHealth(false, {
20616
+ ...context,
20617
+ reason: "suspended"
20618
+ });
20619
+ continue;
20620
+ }
20621
+ this.streamOfflineReasonByBroker.delete(brokerId);
20622
+ this.demandSince.delete(brokerId);
20623
+ if (wasSleepSuppressed) this.logger.info("battery camera woke and delivers a stream — plain wake, no event", {
20624
+ tags: { deviceId },
20625
+ meta: { camStreamId }
20626
+ });
20627
+ if (this.deviceOutages.get(deviceId) !== void 0) {
20628
+ if (!cameBackOn.has(deviceId)) cameBackOn.set(deviceId, context);
20629
+ continue;
20630
+ }
20631
+ if (wasSleepSuppressed) {
20632
+ this.writeCameraStreamsSlice(deviceId, this.snapshotProfileSlots(deviceId));
20633
+ continue;
20634
+ }
20635
+ const reason = isStreamOutageReason(previousReason) ? "recovered" : previousReason === "suspended" ? "resumed" : "first-packet";
20636
+ this.emitStreamHealth(true, {
20637
+ ...context,
20638
+ reason
20639
+ });
20640
+ }
20641
+ for (const [deviceId, context] of outageOpenedOn) this.announceDeviceOutage(deviceId, context);
20642
+ for (const [deviceId, context] of cameBackOn) this.settleDeviceOutage(deviceId, context);
20643
+ this.expireWakeOnlyOutages(now);
20644
+ }
20645
+ /** Record one stream as part of its device's outage (announced after the pass). */
20646
+ openStreamOutage(brokerId, context, reason, now, outageOpenedOn) {
20647
+ this.sleepSuppressedBrokers.delete(brokerId);
20648
+ this.streamOfflineReasonByBroker.set(brokerId, reason);
20649
+ this.deviceOutages.open(context.deviceId, context.camStreamId, reason, now);
20650
+ if (!outageOpenedOn.has(context.deviceId)) outageOpenedOn.set(context.deviceId, context);
20651
+ }
20652
+ markSleepSuppressed(brokerId, context, presence, lastPacketAt) {
20653
+ this.sleepSuppressedBrokers.add(brokerId);
20654
+ this.streamOfflineReasonByBroker.delete(brokerId);
20655
+ this.logger.info("stream quiet on a sleeping battery camera — not an outage", {
20656
+ tags: { deviceId: context.deviceId },
20657
+ meta: {
20658
+ camStreamId: context.camStreamId,
20659
+ presence,
20660
+ lastPacketAt
20661
+ }
20662
+ });
20663
+ this.writeCameraStreamsSlice(context.deviceId, this.snapshotProfileSlots(context.deviceId));
20664
+ }
20665
+ /**
20666
+ * The verdict on a quiet, non-idle stream. `'defer'` = the device's battery
20667
+ * class is still being asked of the registry; the edge is kept and judged
20668
+ * again. A class that could not be answered, or a slice never read, judges as
20669
+ * a mains camera — never as a sleeper (D49).
20670
+ */
20671
+ judgeQuietStream(brokerId, deviceId, broker, now) {
20672
+ const probe = this.ensureBatteryProbe(deviceId, now);
20673
+ if (probe.state === "pending") return "defer";
20674
+ return judgeStaleStream({
20675
+ battery: probe.isBattery === true && probe.facts !== null ? { facts: probe.facts } : null,
20676
+ demanded: this.demandHeldPastBound(brokerId, broker, now),
20677
+ nowMs: now
20678
+ });
20679
+ }
20680
+ demandHeldPastBound(brokerId, broker, now) {
20681
+ if (!broker.hasLiveDemand()) {
20682
+ this.demandSince.delete(brokerId);
20683
+ return false;
20684
+ }
20685
+ const since = this.demandSince.get(brokerId) ?? now;
20686
+ this.demandSince.set(brokerId, since);
20687
+ return now - since >= WAKE_DEMAND_BOUND_MS;
20688
+ }
20689
+ /**
20690
+ * A broker that stays quiet, pass after pass. Three things can be true of it:
20691
+ * it was suppressed as a sleeper (the camera may have died, or a wake may
20692
+ * have been demanded since); it went idle while holding an outage open; or it
20693
+ * resumed from an idle suspend on a battery camera that never answered.
20694
+ */
20695
+ rejudgeQuietBroker(brokerId, broker, context, now, outageOpenedOn) {
20696
+ const { deviceId } = context;
20697
+ const reason = this.streamOfflineReasonByBroker.get(brokerId);
20698
+ if (isStreamOutageReason(reason) && broker.suspended) {
20699
+ this.dropSuspendedOutage(brokerId, context);
20700
+ return;
20701
+ }
20702
+ const suppressed = this.sleepSuppressedBrokers.has(brokerId);
20703
+ const resumedQuiet = reason === "suspended" && !broker.suspended;
20704
+ if (!suppressed && !resumedQuiet) return;
20705
+ if (broker.suspended) {
20706
+ this.demandSince.delete(brokerId);
20707
+ return;
20708
+ }
20709
+ this.refreshBatteryFactsIfDue(deviceId, now);
20710
+ const probe = this.batteryProbe.get(deviceId);
20711
+ if (probe?.state !== "done" || probe.isBattery !== true || probe.facts === null) return;
20712
+ const verdict = judgeStaleStream({
20713
+ battery: { facts: probe.facts },
20714
+ demanded: this.demandHeldPastBound(brokerId, broker, now),
20715
+ nowMs: now
20716
+ });
20717
+ if (verdict.kind === "suppressed") return;
20718
+ this.logger.warn("quiet battery stream is now an outage", {
20719
+ tags: { deviceId },
20720
+ meta: {
20721
+ camStreamId: context.camStreamId,
20722
+ reason: verdict.reason
20723
+ }
20724
+ });
20725
+ this.openStreamOutage(brokerId, context, verdict.reason, now, outageOpenedOn);
20726
+ }
20727
+ /**
20728
+ * An outage stream whose last consumer left (the broker went idle). Nobody is
20729
+ * waiting for it any more, so the outage ends there — closed cleanly, with
20730
+ * the one online that lets every consumer clear its row (reason `suspended`,
20731
+ * which the notification gate discards).
20732
+ */
20733
+ dropSuspendedOutage(brokerId, context) {
20734
+ const { deviceId, camStreamId } = context;
20735
+ this.demandSince.delete(brokerId);
20736
+ this.streamOfflineReasonByBroker.set(brokerId, "suspended");
20737
+ const outage = this.deviceOutages.get(deviceId);
20738
+ this.logger.info("stream outage dropped — the broker went idle, nobody is waiting", {
20739
+ tags: { deviceId },
20740
+ meta: { camStreamId }
20741
+ });
20742
+ if (!outage) return;
20743
+ outage.camStreamIds.delete(camStreamId);
20744
+ if (outage.camStreamIds.size > 0 || outage.wakeOnly) return;
20745
+ this.closeDeviceOutage(deviceId, outage, "suspended", context);
20746
+ }
20747
+ /** Close a device outage and, when it was announced, say so once (any online closes the rows). */
20748
+ closeDeviceOutage(deviceId, outage, reason, context) {
20749
+ this.deviceOutages.close(deviceId);
20750
+ if (!outage.announced) return;
20751
+ this.emitDeviceStreamHealth(true, deviceId, reason, /* @__PURE__ */ new Set(), context);
20752
+ }
20753
+ /** Wake-only outages end when the failed wakes stop (demand released). */
20754
+ expireWakeOnlyOutages(now) {
20755
+ for (const [deviceId, outage] of [...this.deviceOutages.entries()]) {
20756
+ if (!outage.wakeOnly || now - outage.lastSignalAt < WAKE_ONLY_OUTAGE_TTL_MS) continue;
20757
+ this.logger.info("wake failure outage ended — no further demand", {
20758
+ tags: { deviceId },
20759
+ meta: { reason: outage.reason }
20760
+ });
20761
+ this.closeDeviceOutage(deviceId, outage, "suspended", {
20260
20762
  deviceId,
20763
+ brokerId: String(deviceId),
20764
+ sourceType: "battery-wake",
20765
+ lastPacketAt: 0
20766
+ });
20767
+ }
20768
+ }
20769
+ /** The device's battery probe; starts the read when there is none (or a retry is due). */
20770
+ ensureBatteryProbe(deviceId, now) {
20771
+ const existing = this.batteryProbe.get(deviceId);
20772
+ if (existing === void 0) {
20773
+ if (!this.api) {
20774
+ const none = {
20775
+ state: "done",
20776
+ isBattery: null,
20777
+ facts: null,
20778
+ readAt: now
20779
+ };
20780
+ this.batteryProbe.set(deviceId, none);
20781
+ return none;
20782
+ }
20783
+ const pending = {
20784
+ state: "pending",
20785
+ isBattery: null,
20786
+ facts: null,
20787
+ readAt: now
20788
+ };
20789
+ this.batteryProbe.set(deviceId, pending);
20790
+ this.probeBattery(deviceId);
20791
+ return pending;
20792
+ }
20793
+ if (existing.state === "done" && existing.isBattery === null && this.api && now - existing.readAt >= BATTERY_PROBE_RETRY_MS && !this.batteryProbeInFlight.has(deviceId)) this.probeBattery(deviceId);
20794
+ return existing;
20795
+ }
20796
+ async probeBattery(deviceId) {
20797
+ this.batteryProbeInFlight.add(deviceId);
20798
+ try {
20799
+ const isBattery = await this.isBatteryOperated(deviceId);
20800
+ const facts = isBattery === true ? await this.readBatterySleepFacts(deviceId) : null;
20801
+ const current = this.batteryProbe.get(deviceId);
20802
+ this.batteryProbe.set(deviceId, {
20803
+ state: "done",
20804
+ isBattery: current?.isBattery === true ? true : isBattery,
20805
+ facts: current?.facts ?? facts,
20806
+ readAt: Date.now()
20807
+ });
20808
+ } finally {
20809
+ this.batteryProbeInFlight.delete(deviceId);
20810
+ }
20811
+ }
20812
+ refreshBatteryFactsIfDue(deviceId, now) {
20813
+ const probe = this.batteryProbe.get(deviceId);
20814
+ if (probe?.state !== "done" || probe.isBattery !== true) return;
20815
+ if (now - probe.readAt < BATTERY_PROBE_RETRY_MS || this.batteryProbeInFlight.has(deviceId)) return;
20816
+ this.batteryProbeInFlight.add(deviceId);
20817
+ this.readBatterySleepFacts(deviceId).then((facts) => {
20818
+ const current = this.batteryProbe.get(deviceId);
20819
+ this.batteryProbe.set(deviceId, {
20820
+ state: "done",
20821
+ isBattery: true,
20822
+ facts: facts ?? current?.facts ?? null,
20823
+ readAt: Date.now()
20824
+ });
20825
+ }).finally(() => this.batteryProbeInFlight.delete(deviceId));
20826
+ }
20827
+ /** The device's `battery` slice as sleep facts, or `null` when it cannot be read. */
20828
+ async readBatterySleepFacts(deviceId) {
20829
+ const api = this.api;
20830
+ if (!api) return null;
20831
+ const slice = await api.deviceState.getCapSlice.query({
20832
+ deviceId,
20833
+ capName: require_dist.batteryCapability.name
20834
+ }).catch(() => null);
20835
+ const parsed = require_dist.BatteryStatusSchema.safeParse(slice);
20836
+ if (!parsed.success) return null;
20837
+ return {
20838
+ sleeping: parsed.data.sleeping,
20839
+ lastContactAt: parsed.data.lastContactAt
20840
+ };
20841
+ }
20842
+ recordBatterySleep(deviceId, status) {
20843
+ const previous = this.batteryProbe.get(deviceId);
20844
+ const facts = status && typeof status.sleeping === "boolean" ? {
20845
+ sleeping: status.sleeping,
20846
+ ...typeof status.lastContactAt === "number" ? { lastContactAt: status.lastContactAt } : {}
20847
+ } : null;
20848
+ this.batteryProbe.set(deviceId, {
20849
+ state: "done",
20850
+ isBattery: true,
20851
+ facts: facts ?? previous?.facts ?? null,
20852
+ readAt: Date.now()
20853
+ });
20854
+ }
20855
+ /**
20856
+ * A released broker can no longer carry an outage. When it was the last
20857
+ * stream holding an ANNOUNCED outage open, the outage is closed with an
20858
+ * online (`broker-destroyed`, which the notification gate discards but every
20859
+ * ledger and alert card clears on) — otherwise the row stays open for ever.
20860
+ */
20861
+ forgetReleasedStreamOutage(context) {
20862
+ const { deviceId, camStreamId } = context;
20863
+ const outage = this.deviceOutages.get(deviceId);
20864
+ if (!outage || !outage.camStreamIds.delete(camStreamId)) return;
20865
+ if (outage.camStreamIds.size > 0) return;
20866
+ this.logger.info("stream outage closed — its last stream was released", {
20867
+ tags: { deviceId },
20868
+ meta: {
20869
+ reason: outage.reason,
20261
20870
  camStreamId,
20262
- profile: findProfileForStream(this.assignments.get(deviceId)?.map ?? {}, camStreamId),
20263
- brokerId,
20264
- sourceType: broker.getActiveSourceType(),
20265
- lastPacketAt,
20266
- reason
20871
+ announced: outage.announced
20872
+ }
20873
+ });
20874
+ this.closeDeviceOutage(deviceId, outage, "broker-destroyed", context);
20875
+ }
20876
+ /** The ONE `stream.offline` of a device outage, naming every stream it covers. */
20877
+ announceDeviceOutage(deviceId, context) {
20878
+ const outage = this.deviceOutages.get(deviceId);
20879
+ if (!outage) return;
20880
+ if (outage.announced) {
20881
+ this.logger.debug("stream outage joined an already announced one — no second event", {
20882
+ tags: { deviceId },
20883
+ meta: {
20884
+ camStreamIds: [...outage.camStreamIds],
20885
+ reason: outage.reason
20886
+ }
20267
20887
  });
20888
+ return;
20889
+ }
20890
+ outage.announced = true;
20891
+ this.logger.warn("stream outage", {
20892
+ tags: { deviceId },
20893
+ meta: {
20894
+ reason: outage.reason,
20895
+ camStreamIds: [...outage.camStreamIds]
20896
+ }
20897
+ });
20898
+ this.emitDeviceStreamHealth(false, deviceId, outage.reason, outage.camStreamIds, context);
20899
+ }
20900
+ /**
20901
+ * A broker of a device in outage is delivering again. The device is back when
20902
+ * no stream of it is still down for an outage reason; then, and only then,
20903
+ * the single `stream.online` (`recovered`) goes out.
20904
+ */
20905
+ settleDeviceOutage(deviceId, context) {
20906
+ const outage = this.deviceOutages.get(deviceId);
20907
+ if (!outage) return;
20908
+ for (const camStreamId of [...outage.camStreamIds]) {
20909
+ const brokerId = brokerIdFor(deviceId, camStreamId);
20910
+ if (!isStreamOutageReason(this.streamOfflineReasonByBroker.get(brokerId))) outage.camStreamIds.delete(camStreamId);
20268
20911
  }
20912
+ if (outage.camStreamIds.size > 0) return;
20913
+ this.deviceOutages.close(deviceId);
20914
+ if (!outage.announced) return;
20915
+ this.logger.info("stream outage over — device delivers again", {
20916
+ tags: { deviceId },
20917
+ meta: {
20918
+ reason: outage.reason,
20919
+ outageMs: Date.now() - outage.since
20920
+ }
20921
+ });
20922
+ this.emitDeviceStreamHealth(true, deviceId, "recovered", new Set(context.camStreamId !== void 0 ? [context.camStreamId] : []), context);
20923
+ }
20924
+ /**
20925
+ * A wake somebody ASKED for did not produce a stream (the viewer's play
20926
+ * button, found no broker and a wake that settled nothing). This is the one
20927
+ * failure with no broker to carry it, so it opens the device outage itself.
20928
+ */
20929
+ announceBatteryWakeFailure(deviceId, camStreamId, reason) {
20930
+ if (!this.eventBus) return;
20931
+ const published = [...this.cameraStreams.get(deviceId)?.keys() ?? []];
20932
+ const streamIds = camStreamId !== void 0 && published.includes(camStreamId) ? [camStreamId] : published;
20933
+ this.deviceOutages.openWake(deviceId, streamIds, reason, Date.now());
20934
+ this.announceDeviceOutage(deviceId, {
20935
+ deviceId,
20936
+ ...streamIds[0] !== void 0 ? { camStreamId: streamIds[0] } : {},
20937
+ brokerId: streamIds[0] !== void 0 ? brokerIdFor(deviceId, streamIds[0]) : String(deviceId),
20938
+ sourceType: "battery-wake",
20939
+ lastPacketAt: 0
20940
+ });
20941
+ }
20942
+ emitDeviceStreamHealth(online, deviceId, reason, camStreamIds, context) {
20943
+ const ids = [...camStreamIds];
20944
+ const first = ids[0] ?? context.camStreamId;
20945
+ const assignments = this.assignments.get(deviceId)?.map ?? {};
20946
+ this.emitStreamHealth(online, {
20947
+ ...context,
20948
+ deviceId,
20949
+ ...first !== void 0 ? { camStreamId: first } : {},
20950
+ brokerId: first !== void 0 ? brokerIdFor(deviceId, first) : context.brokerId,
20951
+ profile: first !== void 0 ? findProfileForStream(assignments, first) : null,
20952
+ reason,
20953
+ camStreamIds: ids
20954
+ });
20269
20955
  }
20270
20956
  emitStreamHealth(online, payload) {
20271
20957
  const bus = this.eventBus;
20272
20958
  if (!bus) return;
20273
20959
  const category = online ? "stream.online" : "stream.offline";
20960
+ const profile = payload.profile !== void 0 ? payload.profile : payload.camStreamId !== void 0 ? findProfileForStream(this.assignments.get(payload.deviceId)?.map ?? {}, payload.camStreamId) : null;
20274
20961
  bus.emit({
20275
20962
  id: `${category}-${payload.brokerId}-${Date.now()}`,
20276
20963
  timestamp: /* @__PURE__ */ new Date(),
@@ -20279,7 +20966,10 @@ var StreamBrokerManager = class StreamBrokerManager {
20279
20966
  type: "stream-broker",
20280
20967
  id: payload.brokerId
20281
20968
  },
20282
- data: payload
20969
+ data: {
20970
+ ...payload,
20971
+ profile
20972
+ }
20283
20973
  });
20284
20974
  this.writeCameraStreamsSlice(payload.deviceId, this.snapshotProfileSlots(payload.deviceId));
20285
20975
  }
@@ -20382,6 +21072,7 @@ var StreamBrokerManager = class StreamBrokerManager {
20382
21072
  const sourceBrokerId = brokerIdFor(deviceId, camStreamId);
20383
21073
  const sourceBroker = this.brokers.get(sourceBrokerId);
20384
21074
  if (!sourceBroker) continue;
21075
+ if (this.restreamGate(deviceId)) continue;
20385
21076
  const existingToken = this.persistedTokens.get(profileBrokerId);
20386
21077
  const warmUp = () => sourceBroker.onEncodedData(() => {}, RTSP_DESCRIBE_WARMUP_ATTRIBUTION);
20387
21078
  const openPrestreamWindow = () => sourceBroker.openPrestreamWindow();
@@ -22041,11 +22732,7 @@ var StreamBrokerManager = class StreamBrokerManager {
22041
22732
  }
22042
22733
  this.brokers.set(brokerId, broker);
22043
22734
  this.applyClipRetention(deviceId);
22044
- const existingToken = this.persistedTokens.get(brokerId);
22045
- const warmUp = () => broker.onEncodedData(() => {}, RTSP_DESCRIBE_WARMUP_ATTRIBUTION);
22046
- const openPrestreamWindow = () => broker.openPrestreamWindow();
22047
- this.rtspServer.registerRestreamer(brokerId, broker.getRtspRestreamer(), warmUp, existingToken, openPrestreamWindow);
22048
- if (!existingToken) this.rtspProvider.persistTokens();
22735
+ this.registerSourceRestreamer(brokerId, broker);
22049
22736
  await this.fanOutRegistration(deviceId, brokerId, cam, broker);
22050
22737
  this.syncProfileRestreamers(deviceId);
22051
22738
  if (cam.kind === "derived") {
@@ -22054,6 +22741,28 @@ var StreamBrokerManager = class StreamBrokerManager {
22054
22741
  }
22055
22742
  }
22056
22743
  /**
22744
+ * Register one source broker's RTSP restreamer with its persisted token (or a
22745
+ * freshly minted one). A camera whose tokens are held in an unreadable keychain
22746
+ * gets no EXTERNAL token, only the internal loopback-only alias.
22747
+ */
22748
+ registerSourceRestreamer(brokerId, broker) {
22749
+ const deviceId = parseBrokerId(brokerId)?.deviceId;
22750
+ if (deviceId === void 0) return;
22751
+ if (this.restreamGate(deviceId)) {
22752
+ this.logger.warn("external restream URL withheld - the tokens are held but unreadable", {
22753
+ tags: { deviceId },
22754
+ meta: { brokerId }
22755
+ });
22756
+ this.rtspServer.registerInternalAlias(brokerId, broker.getRtspRestreamer(), () => broker.onEncodedData(() => {}, RTSP_DESCRIBE_WARMUP_ATTRIBUTION), () => broker.openPrestreamWindow());
22757
+ return;
22758
+ }
22759
+ const existingToken = this.persistedTokens.get(brokerId);
22760
+ const warmUp = () => broker.onEncodedData(() => {}, RTSP_DESCRIBE_WARMUP_ATTRIBUTION);
22761
+ const openPrestreamWindow = () => broker.openPrestreamWindow();
22762
+ this.rtspServer.registerRestreamer(brokerId, broker.getRtspRestreamer(), warmUp, existingToken, openPrestreamWindow);
22763
+ if (!existingToken) this.rtspProvider.persistTokens();
22764
+ }
22765
+ /**
22057
22766
  * Tear down the broker for a `(deviceId, camStreamId)` pair. No-op
22058
22767
  * when no broker exists. Called by `retractCameraStream` and by the
22059
22768
  * teardown half of `restartBroker`.
@@ -22076,6 +22785,14 @@ var StreamBrokerManager = class StreamBrokerManager {
22076
22785
  });
22077
22786
  this.streamHealthByBroker.delete(brokerId);
22078
22787
  this.streamOfflineReasonByBroker.delete(brokerId);
22788
+ this.sleepSuppressedBrokers.delete(brokerId);
22789
+ this.forgetReleasedStreamOutage({
22790
+ deviceId,
22791
+ camStreamId,
22792
+ brokerId,
22793
+ sourceType: broker.getActiveSourceType(),
22794
+ lastPacketAt: broker.getLastPacketAt()
22795
+ });
22079
22796
  await broker.stop();
22080
22797
  this.brokers.delete(brokerId);
22081
22798
  this.sentryFrames.delete(brokerId);
@@ -22173,7 +22890,7 @@ var StreamBrokerManager = class StreamBrokerManager {
22173
22890
  };
22174
22891
  } else {
22175
22892
  const sourceBrokerId = brokerIdFor(deviceId, request.camStreamId);
22176
- const entry = this.rtspProvider.getEntry(sourceBrokerId, "127.0.0.1");
22893
+ const entry = this.rtspProvider.getInternalEntry(sourceBrokerId, "127.0.0.1");
22177
22894
  if (!entry) throw new Error(`derived source camStream loopback unavailable: ${sourceBrokerId}`);
22178
22895
  loopbackUrl = entry.url;
22179
22896
  release = async () => {};
@@ -38308,18 +39025,177 @@ function subscribeDeviceRemoval(subscribe, deps) {
38308
39025
  });
38309
39026
  }
38310
39027
  //#endregion
39028
+ //#region src/stream-broker/device-removal-hook.ts
39029
+ /**
39030
+ * The stream-broker's `device-removal-hook` (D870).
39031
+ *
39032
+ * What the broker keeps PER CAMERA is a key of the camera's own settings
39033
+ * (`probeSnapshots`, `brokerProfile`, `rtspTokens` - see `broker-device-state.ts`),
39034
+ * and the central device delete (D865) clears those for every addon. What is
39035
+ * left is the broker's live state and its one addon-level map keyed by camera,
39036
+ * `deviceOverrides`: the hook retracts the camera's streams, purges its slot
39037
+ * assignments and drops its override entry. Idempotent - a camera with nothing
39038
+ * left answers zero counts.
39039
+ */
39040
+ function createBrokerRemovalHook(deps) {
39041
+ return {
39042
+ onDeviceRemoved: async (input) => {
39043
+ const { deviceId } = input;
39044
+ try {
39045
+ await deps.retractDevice(deviceId);
39046
+ } catch (err) {
39047
+ deps.logger.warn("stream-broker: retract of a deleted device failed", {
39048
+ tags: { deviceId },
39049
+ meta: { error: require_dist.errMsg(err) }
39050
+ });
39051
+ throw err;
39052
+ }
39053
+ const overrides = deps.dropDeviceOverride(deviceId) ? 1 : 0;
39054
+ if (overrides > 0) deps.logger.info("stream-broker: removed the override of a deleted device", {
39055
+ tags: { deviceId },
39056
+ meta: { deletedAt: input.deletedAt }
39057
+ });
39058
+ return { counts: { "device-overrides": overrides } };
39059
+ },
39060
+ listDeviceData: async () => deps.overrideDeviceIds().filter((deviceId) => Number.isInteger(deviceId) && deviceId > 0).map((deviceId) => ({
39061
+ store: "device-overrides",
39062
+ deviceId,
39063
+ count: 1
39064
+ }))
39065
+ };
39066
+ }
39067
+ /** Classify every held camera against the keychain: wait for it, or give up on the value. */
39068
+ async function classifyHeld(held, secrets) {
39069
+ const refs = /* @__PURE__ */ new Map();
39070
+ for (const [deviceId, stored] of held) if (require_dist.isSecretMarker(stored)) refs.set(deviceId, stored.$secret);
39071
+ if (refs.size === 0) return [];
39072
+ const resolved = await secrets.resolve([...refs.values()]);
39073
+ const out = [];
39074
+ for (const [deviceId, ref] of refs) {
39075
+ const entry = resolved[ref];
39076
+ if (entry === void 0 || entry.kind === "unavailable") out.push({
39077
+ deviceId,
39078
+ ref,
39079
+ kind: "unavailable",
39080
+ reason: entry?.kind === "unavailable" ? entry.reason : null
39081
+ });
39082
+ else if (entry.kind === "missing") out.push({
39083
+ deviceId,
39084
+ ref,
39085
+ kind: "missing",
39086
+ reason: null
39087
+ });
39088
+ else if (entry.kind === "forbidden") out.push({
39089
+ deviceId,
39090
+ ref,
39091
+ kind: "unavailable",
39092
+ reason: "forbidden"
39093
+ });
39094
+ }
39095
+ return out;
39096
+ }
39097
+ var UNAVAILABLE_TEXT = "The camera's RTSP restream URLs are unavailable (consumers such as Home Assistant or an NVR get errors) until the keychain is readable. Its streams and recording are not affected.";
39098
+ var issuedText = (deviceId) => `New RTSP restream URLs were issued for camera ${deviceId} - update the consumers (NVR, Home Assistant). The previous tokens were lost: the keychain no longer holds them.`;
39099
+ var TokenRecovery = class {
39100
+ deps;
39101
+ /** Cameras alerted as unavailable: the alert is cleared when the value resolves. */
39102
+ waiting = /* @__PURE__ */ new Set();
39103
+ /** Consecutive `missing` reads per camera (D49: two before the value is given up). */
39104
+ missingReads = /* @__PURE__ */ new Map();
39105
+ timer = null;
39106
+ running = false;
39107
+ constructor(deps) {
39108
+ this.deps = deps;
39109
+ }
39110
+ /** Cameras currently blocked. */
39111
+ blocked() {
39112
+ return [...this.deps.mirror.heldDevices().keys()].toSorted((a, b) => a - b);
39113
+ }
39114
+ /** Check now, then on a timer while any camera is still held. */
39115
+ start() {
39116
+ if (this.timer !== null) return;
39117
+ this.tick();
39118
+ this.timer = setInterval(() => void this.tick(), this.deps.recheckMs ?? 3e4);
39119
+ this.timer.unref?.();
39120
+ }
39121
+ stop() {
39122
+ if (this.timer !== null) clearInterval(this.timer);
39123
+ this.timer = null;
39124
+ }
39125
+ /** One scheduled / change-triggered check; never overlaps itself, never throws. */
39126
+ async tick() {
39127
+ if (this.running) return;
39128
+ this.running = true;
39129
+ try {
39130
+ await this.check();
39131
+ } catch (err) {
39132
+ this.deps.logger.warn("restream-token re-check failed", { meta: { error: err instanceof Error ? err.message : String(err) } });
39133
+ } finally {
39134
+ this.running = false;
39135
+ }
39136
+ if (this.blocked().length === 0 && this.waiting.size === 0) this.stop();
39137
+ }
39138
+ /** See the module doc. Idempotent. */
39139
+ async check() {
39140
+ const before = new Set(this.deps.mirror.heldDevices().keys());
39141
+ if (before.size === 0 && this.waiting.size === 0) return;
39142
+ const tokens = await this.deps.mirror.load();
39143
+ const now = new Set(this.deps.mirror.heldDevices().keys());
39144
+ const classified = await classifyHeld(this.deps.mirror.heldDevices(), this.deps.secrets);
39145
+ const released = [];
39146
+ for (const camera of classified) {
39147
+ if (camera.kind === "missing") {
39148
+ const reads = (this.missingReads.get(camera.deviceId) ?? 0) + 1;
39149
+ this.missingReads.set(camera.deviceId, reads);
39150
+ if (reads < 2) continue;
39151
+ this.deps.mirror.release(camera.deviceId);
39152
+ this.missingReads.delete(camera.deviceId);
39153
+ if (this.waiting.delete(camera.deviceId)) await this.deps.alerts.clear(camera.deviceId);
39154
+ released.push(camera.deviceId);
39155
+ continue;
39156
+ }
39157
+ this.missingReads.delete(camera.deviceId);
39158
+ if (this.waiting.has(camera.deviceId)) continue;
39159
+ this.waiting.add(camera.deviceId);
39160
+ this.deps.logger.error("restream tokens held but not readable", {
39161
+ tags: { deviceId: camera.deviceId },
39162
+ meta: {
39163
+ reason: camera.reason,
39164
+ ref: camera.ref
39165
+ }
39166
+ });
39167
+ await this.deps.alerts.raise({
39168
+ deviceId: camera.deviceId,
39169
+ kind: "unavailable",
39170
+ message: UNAVAILABLE_TEXT
39171
+ });
39172
+ }
39173
+ const recovered = [...before].filter((id) => !now.has(id) && !released.includes(id));
39174
+ for (const deviceId of recovered) if (this.waiting.delete(deviceId)) await this.deps.alerts.clear(deviceId);
39175
+ if (recovered.length > 0) this.deps.logger.info("restream tokens readable again", { meta: { cameras: recovered.length } });
39176
+ const cameras = [...recovered, ...released];
39177
+ if (cameras.length > 0) await this.deps.onRegister(tokens, cameras);
39178
+ for (const deviceId of released) {
39179
+ this.deps.logger.error("restream tokens lost - new URLs issued", { tags: { deviceId } });
39180
+ await this.deps.alerts.raise({
39181
+ deviceId,
39182
+ kind: "missing",
39183
+ message: issuedText(deviceId)
39184
+ });
39185
+ }
39186
+ }
39187
+ };
39188
+ //#endregion
38311
39189
  //#region src/stream-broker/addon.ts
38312
39190
  /**
38313
- * Hydrate the persisted RTSP restream state. Tokens come from their own
38314
- * collection (one row per broker); `rtspEnabled` is still a key of the settings
38315
- * row. An unparseable token row is skipped by the store — one dead token, not
38316
- * every token — and an empty set keeps first-boot silence (no loader call, no
38317
- * log).
39191
+ * Hydrate the persisted RTSP restream state. Tokens come from the cameras' own
39192
+ * settings; `rtspEnabled` is still a key of the settings row. An unreadable
39193
+ * token set THROWS (the caller refuses to write tokens afterwards - a
39194
+ * replacement minted over a token that was merely unreadable would turn every
39195
+ * external NVR URL into a 404), and an empty set keeps first-boot silence.
38318
39196
  */
38319
39197
  async function hydratePersistedRtspState(deps) {
38320
- const rows = await deps.tokensStore.readAll();
38321
- const tokens = /* @__PURE__ */ new Map();
38322
- for (const [brokerId, row] of rows) tokens.set(brokerId, row.token);
39198
+ const tokens = flattenByDevice(await deps.tokensMirror.load());
38323
39199
  if (tokens.size > 0) {
38324
39200
  deps.loadTokens(tokens);
38325
39201
  deps.logger.info("Loaded persisted RTSP tokens", { meta: { tokenCount: tokens.size } });
@@ -38388,6 +39264,8 @@ var PROBE_SNAPSHOTS_PERSIST_INTERVAL_MS = 6e4;
38388
39264
  * the sample as absent (the RTCP Receiver-Report path drives the ladder anyway). */
38389
39265
  var NETWORK_QUALITY_STATS_TIMEOUT_MS = 2e3;
38390
39266
  var StreamBrokerAddon = class extends require_dist.BaseAddon {
39267
+ /** The restream tokens of a camera are a per-device setting held in the keychain (D870). */
39268
+ secretStatePaths = ["rtspTokens"];
38391
39269
  brokerManager = null;
38392
39270
  /** The node's pinned ffmpeg, mirrored here so the recorded-playback demuxers
38393
39271
  * (segment-demux/segment-audio) spawn the same binary as the transcode
@@ -38444,6 +39322,7 @@ var StreamBrokerAddon = class extends require_dist.BaseAddon {
38444
39322
  * restarts). Loaded on activate, flushed throttled from the metrics loop and
38445
39323
  * once on shutdown. */
38446
39324
  probeSnapshots = null;
39325
+ removalHook = null;
38447
39326
  lastProbePersistAt = 0;
38448
39327
  constructor() {
38449
39328
  super({
@@ -38506,24 +39385,26 @@ var StreamBrokerAddon = class extends require_dist.BaseAddon {
38506
39385
  logger: this.ctx.logger
38507
39386
  });
38508
39387
  await this.ffmpegWiring.load();
38509
- await this.declareRowCollections();
38510
- await this.purgeFlattenedSettingsKeys();
39388
+ const deviceSettings = this.deviceSettings();
38511
39389
  const rtspProvider = this.brokerManager.getRtspRestreamProvider();
38512
39390
  const brokerManager = this.brokerManager;
38513
- const rtspTokens = rtspTokensStore(this.ctx.api.settingsStore, this.ctx.logger);
39391
+ const rtspTokens = rtspTokensMirror(deviceSettings, this.ctx.logger);
38514
39392
  const rtspEnabledState = this.state("rtspEnabled", RtspEnabledSchema, {});
38515
39393
  try {
38516
39394
  await hydratePersistedRtspState({
38517
- tokensStore: rtspTokens,
39395
+ tokensMirror: rtspTokens,
38518
39396
  enabledState: rtspEnabledState,
38519
39397
  loadTokens: (tokens) => brokerManager.loadPersistedTokens(tokens),
38520
39398
  loadEnabled: (states) => rtspProvider.loadPersistedEnabled(states),
38521
39399
  logger: this.ctx.logger
38522
39400
  });
38523
- } catch {}
39401
+ } catch (err) {
39402
+ this.ctx.logger.warn("Failed to load persisted RTSP state - tokens will not be written", { meta: { error: require_dist.errMsg(err) } });
39403
+ }
39404
+ this.startTokenRecovery(rtspTokens, brokerManager);
38524
39405
  rtspProvider.setTokenPersister(async (tokens) => {
38525
39406
  try {
38526
- for (const [brokerId, token] of tokens) await rtspTokens.write(brokerId, { token });
39407
+ await rtspTokens.merge(mergeRtspTokens(rtspTokens, tokens));
38527
39408
  this.ctx.logger.info("Persisted RTSP tokens", { meta: { current: tokens.size } });
38528
39409
  } catch (err) {
38529
39410
  this.ctx.logger.warn("Failed to persist RTSP tokens", { meta: { error: require_dist.errMsg(err) } });
@@ -38556,24 +39437,22 @@ var StreamBrokerAddon = class extends require_dist.BaseAddon {
38556
39437
  this.ctx.logger.warn("Failed to persist device overrides", { meta: { error: require_dist.errMsg(err) } });
38557
39438
  });
38558
39439
  });
38559
- const profileMap = profileMapStore(this.ctx.api.settingsStore, this.ctx.logger);
39440
+ const profileMap = profileMapMirror(deviceSettings, this.ctx.logger);
38560
39441
  const profileMapPersister = (assignments) => {
38561
39442
  const next = /* @__PURE__ */ new Map();
38562
- for (const [deviceId, assignment] of assignments) next.set(`${deviceId}`, {
39443
+ for (const [deviceId, assignment] of assignments) next.set(deviceId, {
38563
39444
  map: { ...assignment.map },
38564
39445
  auto: assignment.auto
38565
39446
  });
38566
- profileMap.replaceAll(next).catch((err) => {
39447
+ profileMap.replace(next).catch((err) => {
38567
39448
  this.ctx.logger.warn("Failed to persist profile map", { meta: { error: require_dist.errMsg(err) } });
38568
39449
  });
38569
39450
  };
38570
39451
  this.brokerManager.setProfileMapPersister(profileMapPersister);
38571
39452
  try {
38572
- const rows = await profileMap.readAll();
39453
+ const rows = await profileMap.load();
38573
39454
  const loaded = /* @__PURE__ */ new Map();
38574
- for (const [rawKey, val] of rows) {
38575
- const deviceId = Number(rawKey);
38576
- if (!Number.isInteger(deviceId)) continue;
39455
+ for (const [deviceId, val] of rows) {
38577
39456
  const map = {};
38578
39457
  for (const profile of PROFILE_KEYS) {
38579
39458
  const v = val.map[profile];
@@ -38589,18 +39468,18 @@ var StreamBrokerAddon = class extends require_dist.BaseAddon {
38589
39468
  } catch (err) {
38590
39469
  this.ctx.logger.warn("Failed to load profile map", { meta: { error: require_dist.errMsg(err) } });
38591
39470
  }
38592
- const probeSnapshots = probeSnapshotsStore(this.ctx.api.settingsStore, this.ctx.logger);
39471
+ const probeSnapshots = probeSnapshotsMirror(deviceSettings, this.ctx.logger);
38593
39472
  this.probeSnapshots = probeSnapshots;
38594
39473
  try {
38595
- const rows = await probeSnapshots.readAll();
38596
- const blob = Object.fromEntries(rows);
39474
+ const rows = await probeSnapshots.load();
39475
+ const blob = Object.fromEntries(flattenByDevice(rows));
38597
39476
  this.brokerManager.loadProbeSnapshots(blob);
38598
- this.ctx.logger.info("Loaded persisted probe snapshots", { meta: { streamCount: rows.size } });
39477
+ this.ctx.logger.info("Loaded persisted probe snapshots", { meta: { streamCount: Object.keys(blob).length } });
38599
39478
  } catch (err) {
38600
39479
  this.ctx.logger.warn("Failed to load probe snapshots", { meta: { error: require_dist.errMsg(err) } });
38601
39480
  }
38602
39481
  this.brokerManager.setDeviceHardwareForgetHook(async (deviceId) => {
38603
- const rtspTokenRowsDeleted = await rtspTokens.deleteWhere({ where: { deviceId } });
39482
+ const rtspTokenRowsDeleted = await rtspTokens.remove(deviceId) ? 1 : 0;
38604
39483
  this.flushProbeSnapshots(Date.now(), true);
38605
39484
  return { rtspTokenRowsDeleted };
38606
39485
  });
@@ -38858,6 +39737,14 @@ var StreamBrokerAddon = class extends require_dist.BaseAddon {
38858
39737
  } catch (err) {
38859
39738
  this.ctx.logger.warn("stream-broker: client-log ingest failed to serve", { meta: { error: require_dist.errMsg(err) } });
38860
39739
  }
39740
+ this.removalHook = createBrokerRemovalHook({
39741
+ logger: this.ctx.logger,
39742
+ retractDevice: async (deviceId) => {
39743
+ await retractRemovedDevice(deviceId, this.deviceRemovalDeps());
39744
+ },
39745
+ dropDeviceOverride: (deviceId) => brokerManager.dropDeviceOverride(deviceId),
39746
+ overrideDeviceIds: () => brokerManager.listDeviceOverrideIds()
39747
+ });
38861
39748
  return [
38862
39749
  {
38863
39750
  capability: require_dist.streamBrokerCapability,
@@ -38876,9 +39763,90 @@ var StreamBrokerAddon = class extends require_dist.BaseAddon {
38876
39763
  provider: widgetsProvider
38877
39764
  },
38878
39765
  logChannelsRegistration,
38879
- loadContributionRegistration
39766
+ loadContributionRegistration,
39767
+ {
39768
+ capability: require_dist.deviceRemovalHookCapability,
39769
+ provider: this.removalHook
39770
+ }
38880
39771
  ];
38881
39772
  }
39773
+ /**
39774
+ * Watch the cameras whose restream tokens are held but unreadable (D870): no
39775
+ * restream URL is registered for them (the manager's gate), an alert says so,
39776
+ * and when a value resolves ONLY that camera's restreamers are registered with
39777
+ * the stored tokens - no teardown. A lost secret is released after two
39778
+ * consistent reads and gets new, persisted tokens.
39779
+ */
39780
+ startTokenRecovery(mirror, brokerManager) {
39781
+ const secrets = this.ctx.secrets;
39782
+ if (secrets === void 0) {
39783
+ if (mirror.heldDevices().size > 0) this.ctx.logger.error("restream tokens look held but this addon has no keychain access", { meta: { cameras: mirror.heldDevices().size } });
39784
+ return;
39785
+ }
39786
+ brokerManager.setRestreamGate((deviceId) => mirror.isBlocked(deviceId));
39787
+ if (mirror.heldDevices().size === 0) return;
39788
+ const alertId = (deviceId) => `stream-broker-tokens:${deviceId}`;
39789
+ const warn = (what) => (err) => this.ctx.logger.warn(what, { meta: { error: require_dist.errMsg(err) } });
39790
+ const recovery = new TokenRecovery({
39791
+ mirror,
39792
+ secrets,
39793
+ logger: this.ctx.logger,
39794
+ alerts: {
39795
+ raise: async ({ deviceId, kind, message }) => {
39796
+ const now = Date.now();
39797
+ await this.ctx.api.alerts.emit.mutate({
39798
+ id: alertId(deviceId),
39799
+ category: "streams",
39800
+ severity: "warning",
39801
+ title: kind === "missing" ? "New RTSP restream URLs issued" : "RTSP restream URLs unavailable",
39802
+ message,
39803
+ status: "active",
39804
+ read: false,
39805
+ createdAt: now,
39806
+ updatedAt: now,
39807
+ source: {
39808
+ type: "device",
39809
+ id: String(deviceId)
39810
+ },
39811
+ metadata: {
39812
+ deviceId,
39813
+ kind
39814
+ }
39815
+ }).catch(warn("could not raise the restream-token alert"));
39816
+ },
39817
+ clear: async (deviceId) => {
39818
+ await this.ctx.api.alerts.update.mutate({
39819
+ alertId: alertId(deviceId),
39820
+ patch: {
39821
+ status: "completed",
39822
+ updatedAt: Date.now()
39823
+ }
39824
+ }).catch(warn("could not clear the restream-token alert"));
39825
+ }
39826
+ },
39827
+ onRegister: async (tokens, cameras) => {
39828
+ brokerManager.loadPersistedTokens(flattenByDevice(tokens));
39829
+ for (const deviceId of cameras) brokerManager.registerRestreamersForDevice(deviceId);
39830
+ }
39831
+ });
39832
+ recovery.start();
39833
+ const unsubscribe = secrets.onChange((ref) => {
39834
+ if (ref.startsWith("secret://stream-broker/device/")) recovery.tick();
39835
+ });
39836
+ this.ctx.addDisposer(() => {
39837
+ recovery.stop();
39838
+ unsubscribe();
39839
+ });
39840
+ }
39841
+ async onHubReachable() {
39842
+ const removalHook = this.removalHook;
39843
+ if (removalHook === null) return;
39844
+ this.ctx.addDisposer(require_dist.armRemovalReconcile({
39845
+ api: this.ctx.api,
39846
+ logger: this.ctx.logger,
39847
+ remove: (input) => removalHook.onDeviceRemoved(input)
39848
+ }));
39849
+ }
38882
39850
  async onShutdown() {
38883
39851
  this.logChannels?.stop();
38884
39852
  this.logChannels = null;
@@ -38979,41 +39947,14 @@ var StreamBrokerAddon = class extends require_dist.BaseAddon {
38979
39947
  this.flushProbeSnapshots(timestamp, false);
38980
39948
  }
38981
39949
  /**
38982
- * Declare the three row collections that replaced the `probeSnapshots`,
38983
- * `rtspTokens` and `profileMap` blob keys. Idempotent — a re-declaration of
38984
- * the same shape is a no-op in the engine.
38985
- *
38986
- * Not swallowed: a broker whose `stream-broker:rtsp-tokens` is undeclared
38987
- * mints a new token for every stream and every external NVR URL in the
38988
- * installation goes dead. Crashing loudly is bounded by `CrashSupervisor`
38989
- * (D6); silently reissuing tokens is not.
38990
- */
38991
- async declareRowCollections() {
38992
- const store = this.ctx.api.settingsStore;
38993
- const logger = this.ctx.logger;
38994
- await probeSnapshotsStore(store, logger).declare();
38995
- await rtspTokensStore(store, logger).declare();
38996
- await profileMapStore(store, logger).declare();
38997
- }
38998
- /**
38999
- * Drop the flattened keys from `stream-broker:addon-settings` / `root`, each
39000
- * gated on its own successor collection being non-empty. `rtspEnabled` and
39001
- * `deviceOverrides` stay — see `broker-row-stores.ts` for why.
39002
- *
39003
- * Best-effort: a purge that throws must not take the broker down. The keys
39004
- * are dead weight, not a fault.
39950
+ * The per-device settings, as the broker persists through them. Resolved
39951
+ * reads/writes (keychain markers never reach this code), and a context with
39952
+ * no settings is a broker that cannot start.
39005
39953
  */
39006
- async purgeFlattenedSettingsKeys() {
39007
- try {
39008
- await require_retire_root_keys.retireRootKeys({
39009
- store: this.ctx.api.settingsStore,
39010
- addonId: STREAM_BROKER_ADDON_ID,
39011
- logger: this.ctx.logger,
39012
- specs: STREAM_BROKER_RETIRED_ROOT_KEYS
39013
- });
39014
- } catch (err) {
39015
- this.ctx.logger.warn("stream-broker: retiring the flattened settings keys failed", { meta: { error: require_dist.errMsg(err) } });
39016
- }
39954
+ deviceSettings() {
39955
+ const settings = this.ctx.settings;
39956
+ if (settings === void 0) throw new Error("stream-broker: device settings are not available");
39957
+ return settings;
39017
39958
  }
39018
39959
  /**
39019
39960
  * Persist the manager's probe map to the durable handle. Throttled to
@@ -39026,7 +39967,7 @@ var StreamBrokerAddon = class extends require_dist.BaseAddon {
39026
39967
  if (!store || !manager) return;
39027
39968
  if (!force && now - this.lastProbePersistAt < PROBE_SNAPSHOTS_PERSIST_INTERVAL_MS) return;
39028
39969
  this.lastProbePersistAt = now;
39029
- store.replaceAll(new Map(Object.entries(manager.getProbeSnapshots()))).catch((err) => {
39970
+ store.replace(groupByDevice(manager.getProbeSnapshots())).catch((err) => {
39030
39971
  this.ctx.logger.warn("Failed to persist probe snapshots", { meta: { error: require_dist.errMsg(err) } });
39031
39972
  });
39032
39973
  }