@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.
- package/dist/audio-analyzer/index.js +3 -3
- package/dist/audio-analyzer/index.mjs +3 -3
- package/dist/{custom-model-catalog-UaaDgBow.mjs → custom-model-catalog-DF5qs8u4.mjs} +2 -2
- package/dist/{custom-model-catalog-M-ECFeNJ.js → custom-model-catalog-gbLoUFki.js} +2 -2
- package/dist/detection-pipeline/index.js +4 -4
- package/dist/detection-pipeline/index.mjs +4 -4
- package/dist/{dist-QBVFmPYQ.js → dist-C0Mg8e3I.js} +924 -50
- package/dist/{dist-BT3uSeR7.mjs → dist-QyeZXkAO.mjs} +919 -51
- package/dist/{model-catalogs-C42Mo0jo.js → model-catalogs-BTuX-P0k.js} +1 -1
- package/dist/{model-catalogs-ChW0i2P6.mjs → model-catalogs-CIE4Dz4x.mjs} +1 -1
- package/dist/motion-wasm/index.js +1 -1
- package/dist/motion-wasm/index.mjs +1 -1
- package/dist/{node-B6ev-Hqa.js → node-CifGKjsd.js} +1 -1
- package/dist/{node-BVRFl1hZ.mjs → node-ndjp9DQO.mjs} +1 -1
- package/dist/pipeline-runner/index.js +3 -3
- package/dist/pipeline-runner/index.mjs +3 -3
- package/dist/{process-memory-Cdf2A2qg.js → process-memory-BMfSP5-i.js} +1 -1
- package/dist/{process-memory-DdeedEgU.mjs → process-memory-CaMI-Ry2.mjs} +1 -1
- package/dist/recorder/index.js +265 -94
- package/dist/recorder/index.mjs +263 -92
- package/dist/{segment-demux-js-8CfbEFeD.mjs → segment-demux-js-CLXBgUTo.mjs} +1 -1
- package/dist/{segment-demux-js-Dp0OI-2n.js → segment-demux-js-LhwVw18z.js} +1 -1
- package/dist/stream-broker/_stub.js +2 -2
- 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
- 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
- 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
- package/dist/stream-broker/demux-worker-child.js +1 -1
- package/dist/stream-broker/demux-worker-child.mjs +1 -1
- package/dist/stream-broker/{hostInit-Bq36nWMP.mjs → hostInit-DBDp5YHD.mjs} +2 -2
- package/dist/stream-broker/index.js +1229 -288
- package/dist/stream-broker/index.mjs +1229 -288
- package/dist/stream-broker/remoteEntry.js +1 -1
- package/package.json +5 -1
- package/dist/retire-root-keys-Csb8z2f3.js +0 -338
- package/dist/retire-root-keys-DZvlso2l.mjs +0 -327
- 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
- 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
|
@@ -1,10 +1,9 @@
|
|
|
1
1
|
import { r as __toESM } from "../chunk-DnnnRqeS.mjs";
|
|
2
|
-
import {
|
|
3
|
-
import { i as NO_COST_CLAIM, n as Fmp4FragmentChild, o as probeFfmpegBinary, r as Fmp4FragmentPlane, t as ChildCostRegistry } from "../node-
|
|
2
|
+
import { An as errMsg, At as gifFromMp4Args, Bt as memoizePinnedFfmpeg, D as G711_SCALE_CORRECTION_DB, Dn as isSoftwareDecode, Dt as ffmpegRate, En as invocationFromEncodeProfile, Ft as loadContributionCapability, Gn as isSecretMarker, I as RATE_CONTROL_RELAXED, It as logChannelsCapability, J as addonWidgetsSourceCapability, Jn as maskUrlCredentials, Kn as makeProfileBrokerId, L as RATE_CONTROL_TIGHT, Mn as BaseAddon, Nn as CAM_PROFILE_ORDER, On as DeviceFeature, Pn as CamProfileSchema, Qt as rankStreams, Rn as createEvent, Vn as expandAudioChunkToF32le, W as RingBuffer, Wn as isEvent, Xn as parseProfileBrokerId, Yn as nodePin, Z as armRemovalReconcile, an as resolveEgressDecodeHwAccel, ar as literal, bn as webrtcSessionCapability, bt as egressTransportFromRequest, c as CLIP_STILL_REASON_CODE_HEADER, dr as union, dt as declareLogChannel, er as _enum, fr as EventCategory, gn as streamBrokerCapability, it as cameraStreamsCapability, jn as BOOT_RECOVERY_BACKOFF_MS, kn as DeviceType, kt as gifDeriveSpeed, lr as record, lt as createHwAccelCache, nr as boolean, o as BatteryStatusSchema, or as number, ot as clipExportFailureOf, pt as deriveBatteryPresence, qn as makeSourceBrokerId, rr as discriminatedUnion, s as CLIP_BROKER_PACED_RATES, sr as object, tr as array, tt as batteryCapability, ur as string, ut as createLogChannelsProvider, vt as deviceRemovalHookCapability, w as EncodeProfileSchema, wn as buildFfmpegArgs, xn as AUDIO_PRESETS, yt as egressTranscodeSharingKey } from "../dist-QyeZXkAO.mjs";
|
|
3
|
+
import { i as NO_COST_CLAIM, n as Fmp4FragmentChild, o as probeFfmpegBinary, r as Fmp4FragmentPlane, t as ChildCostRegistry } from "../node-ndjp9DQO.mjs";
|
|
4
4
|
import { a as parseRestreamPath, d as PREBUFFER_MAX_MS, f as capPreRollRequestMs, i as isLiveEdgeOnlyDial, m as resolvePrebuffer, n as NO_DECLARATION, p as prebufferRetentionMs, s as sdpVariantFor, u as withSentryIntent } from "../restream-intent-DyGmLAU9.mjs";
|
|
5
5
|
import { n as profileForStreamId } from "../remote-restream-BeHi78PZ.mjs";
|
|
6
|
-
import { n as
|
|
7
|
-
import { a as require_mp4box_all, c as parseNalLengthSize, i as runFfmpegOnce, n as demuxSegmentPreferJs, o as avccSampleToAnnexB, r as FfmpegFallbackRefusedError, s as buildKeyframeAnnexB, t as demuxSegmentFullJs } from "../segment-demux-js-8CfbEFeD.mjs";
|
|
6
|
+
import { a as require_mp4box_all, c as parseNalLengthSize, i as runFfmpegOnce, n as demuxSegmentPreferJs, o as avccSampleToAnnexB, r as FfmpegFallbackRefusedError, s as buildKeyframeAnnexB, t as demuxSegmentFullJs } from "../segment-demux-js-CLXBgUTo.mjs";
|
|
8
7
|
import { createRequire } from "node:module";
|
|
9
8
|
import * as os$1 from "node:os";
|
|
10
9
|
import { networkInterfaces, tmpdir } from "node:os";
|
|
@@ -360,207 +359,253 @@ function formatProbedSummary(declared, snapshot, opts) {
|
|
|
360
359
|
return parts.join(SEP);
|
|
361
360
|
}
|
|
362
361
|
//#endregion
|
|
363
|
-
//#region src/stream-broker/broker-
|
|
364
|
-
/**
|
|
365
|
-
* The stream-broker's
|
|
366
|
-
*
|
|
367
|
-
*
|
|
368
|
-
*
|
|
369
|
-
*
|
|
370
|
-
*
|
|
371
|
-
*
|
|
372
|
-
*
|
|
373
|
-
*
|
|
374
|
-
*
|
|
375
|
-
*
|
|
376
|
-
*
|
|
377
|
-
*
|
|
378
|
-
*
|
|
379
|
-
*
|
|
380
|
-
*
|
|
381
|
-
*
|
|
382
|
-
*
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
* The row survives either way: it is also where `BaseAddon.resolveConfig` reads
|
|
390
|
-
* the operator's config fields from. The problem was a 33 KB row, not a blob.
|
|
391
|
-
*/
|
|
392
|
-
/**
|
|
393
|
-
* The addon id — the settings-store namespace whose `root` row the flattened
|
|
394
|
-
* keys are being retired from. Matches `packages/addon-pipeline/package.json`
|
|
395
|
-
* (`camstack.addons[].id`).
|
|
396
|
-
*/
|
|
397
|
-
var STREAM_BROKER_ADDON_ID = "stream-broker";
|
|
398
|
-
/**
|
|
399
|
-
* Every one of these collections is keyed by the BROKER ID
|
|
400
|
-
* (`<deviceId>/<camStreamId>`) or by the numeric deviceId. `brokerDeviceId`
|
|
401
|
-
* recovers the numeric device from either, for the projected column that makes
|
|
402
|
-
* a per-camera question an indexed query — and for `tags: { deviceId }` on
|
|
403
|
-
* every line these stores log.
|
|
404
|
-
*
|
|
405
|
-
* Returns `null` rather than guessing: a key whose head is not a number is a
|
|
406
|
-
* key this addon did not write, and inventing a device for it would put a row
|
|
407
|
-
* on a camera at random.
|
|
408
|
-
*/
|
|
409
|
-
function brokerDeviceId(key) {
|
|
410
|
-
const head = key.includes("/") ? key.slice(0, key.indexOf("/")) : key;
|
|
411
|
-
const id = Number(head);
|
|
412
|
-
return Number.isInteger(id) && id > 0 ? id : null;
|
|
413
|
-
}
|
|
414
|
-
/**
|
|
415
|
-
* Bound for a whole-map read. `settings-store.query` silently caps an unbounded
|
|
416
|
-
* read at 2 000 rows and answers as if that were everything; at 974 devices ×
|
|
417
|
-
* up to 5 streams the default is HALF the margin, so every read here names its
|
|
418
|
-
* own ceiling and a truncation is reported rather than shrunk.
|
|
419
|
-
*/
|
|
420
|
-
var BROKER_ROWS_LIMIT = 2e4;
|
|
421
|
-
/**
|
|
422
|
-
* @durable class=mirror owner=stream-broker
|
|
423
|
-
* write="one row per broker (`<deviceId>/<camStreamId>`), replaced wholesale from the
|
|
424
|
-
* manager's live probe map by the throttled metrics flush and once on shutdown"
|
|
425
|
-
* retention="the flush is authoritative: a broker the manager no longer holds loses its
|
|
426
|
-
* row. Losing the whole collection costs the settings 'Probed' field showing 'not yet
|
|
427
|
-
* probed' until the stream is pulled again — it is re-derived from the next probe."
|
|
428
|
-
*/
|
|
429
|
-
var BROKER_PROBE_SNAPSHOTS_COLLECTION = "stream-broker:probe-snapshots";
|
|
430
|
-
var BROKER_PROBE_SNAPSHOTS_SPEC = {
|
|
431
|
-
collection: BROKER_PROBE_SNAPSHOTS_COLLECTION,
|
|
432
|
-
schema: ProbedSnapshotSchema,
|
|
433
|
-
columns: [{
|
|
434
|
-
name: "deviceId",
|
|
435
|
-
type: "INTEGER"
|
|
436
|
-
}, {
|
|
437
|
-
name: "at",
|
|
438
|
-
type: "INTEGER",
|
|
439
|
-
notNull: true
|
|
440
|
-
}],
|
|
441
|
-
indexes: [{
|
|
442
|
-
name: "idx_stream_broker_probe_device",
|
|
443
|
-
columns: ["deviceId"]
|
|
444
|
-
}],
|
|
445
|
-
project: (key, value) => ({
|
|
446
|
-
deviceId: brokerDeviceId(key),
|
|
447
|
-
at: value.at
|
|
448
|
-
}),
|
|
449
|
-
deviceIdColumn: "deviceId",
|
|
450
|
-
loadLimit: BROKER_ROWS_LIMIT
|
|
451
|
-
};
|
|
452
|
-
function probeSnapshotsStore(store, logger) {
|
|
453
|
-
return new RowMapStore({
|
|
454
|
-
spec: BROKER_PROBE_SNAPSHOTS_SPEC,
|
|
455
|
-
store,
|
|
456
|
-
logger
|
|
457
|
-
});
|
|
458
|
-
}
|
|
459
|
-
/**
|
|
460
|
-
* The token, wrapped in an object rather than stored bare.
|
|
461
|
-
*
|
|
462
|
-
* A bare string in a `JSON` column round-trips only because the engine's
|
|
463
|
-
* decoder JSON-parses a stored string ONLY when it starts with `{` or `[`. That
|
|
464
|
-
* is a heuristic, not a contract; a token is opaque hex today and a future
|
|
465
|
-
* format that starts with a brace would come back as an object. One field costs
|
|
466
|
-
* nothing and makes the row self-describing.
|
|
467
|
-
*/
|
|
468
|
-
var RtspTokenRowSchema = object({ token: string().min(1) });
|
|
469
|
-
/**
|
|
470
|
-
* @durable class=ledger owner=stream-broker
|
|
471
|
-
* write="one row per broker (`<deviceId>/<camStreamId>`), upserted when the restream
|
|
472
|
-
* server mints a token. MERGE only — a token is stable forever per stream unless it
|
|
473
|
-
* is explicitly regenerated"
|
|
474
|
-
* retention="nothing deletes a row. The persister receives only the CURRENTLY registered
|
|
475
|
-
* restreamers, so a replace would erase the token of an unregistered broker and turn
|
|
476
|
-
* an external NVR URL into a dead 404 at the next idle or reboot."
|
|
477
|
-
*/
|
|
478
|
-
var BROKER_RTSP_TOKENS_COLLECTION = "stream-broker:rtsp-tokens";
|
|
479
|
-
var BROKER_RTSP_TOKENS_SPEC = {
|
|
480
|
-
collection: BROKER_RTSP_TOKENS_COLLECTION,
|
|
481
|
-
schema: RtspTokenRowSchema,
|
|
482
|
-
columns: [{
|
|
483
|
-
name: "deviceId",
|
|
484
|
-
type: "INTEGER"
|
|
485
|
-
}],
|
|
486
|
-
indexes: [{
|
|
487
|
-
name: "idx_stream_broker_rtsp_tokens_device",
|
|
488
|
-
columns: ["deviceId"]
|
|
489
|
-
}],
|
|
490
|
-
project: (key) => ({ deviceId: brokerDeviceId(key) }),
|
|
491
|
-
deviceIdColumn: "deviceId",
|
|
492
|
-
loadLimit: BROKER_ROWS_LIMIT
|
|
493
|
-
};
|
|
494
|
-
function rtspTokensStore(store, logger) {
|
|
495
|
-
return new RowMapStore({
|
|
496
|
-
spec: BROKER_RTSP_TOKENS_SPEC,
|
|
497
|
-
store,
|
|
498
|
-
logger
|
|
499
|
-
});
|
|
500
|
-
}
|
|
501
|
-
/** One camera's profile → camStreamId assignment, plus whether it is automatic. */
|
|
362
|
+
//#region src/stream-broker/broker-device-state.ts
|
|
363
|
+
/**
|
|
364
|
+
* The stream-broker's per-camera state, kept as per-device settings (D870).
|
|
365
|
+
*
|
|
366
|
+
* Three maps used to be collections of their own (`stream-broker:probe-snapshots`,
|
|
367
|
+
* `:profile-map`, `:rtsp-tokens`), each keyed by the camera. A camera's data in
|
|
368
|
+
* a side table is a row somebody must remember to delete when the camera goes;
|
|
369
|
+
* as a KEY of the camera's own settings it is cleared by the central device
|
|
370
|
+
* delete (D865) with everything else.
|
|
371
|
+
*
|
|
372
|
+
* | key | value for one camera |
|
|
373
|
+
* | ---------------- | --------------------------------------------------------- |
|
|
374
|
+
* | `probeSnapshots` | `{ [camStreamId]: ProbedSnapshot }` - what the broker last probed |
|
|
375
|
+
* | `brokerProfile` | `{ map, auto }` - the profile -> camStreamId assignment |
|
|
376
|
+
* | `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 |
|
|
377
|
+
*
|
|
378
|
+
* Failure direction: a load that cannot ask the store THROWS (D49). The writers
|
|
379
|
+
* built on {@link DeviceKeyMirror} refuse to write until a load has succeeded,
|
|
380
|
+
* because a replace driven by an empty "known" set would otherwise drop or
|
|
381
|
+
* shrink rows it never saw.
|
|
382
|
+
*/
|
|
383
|
+
/** Per-device settings keys. */
|
|
384
|
+
var PROBE_SNAPSHOTS_KEY = "probeSnapshots";
|
|
385
|
+
var BROKER_PROFILE_KEY = "brokerProfile";
|
|
386
|
+
var RTSP_TOKENS_KEY = "rtspTokens";
|
|
387
|
+
/** One camera's profile -> camStreamId assignment, plus whether it is automatic. */
|
|
502
388
|
var ProfileAssignmentSchema = object({
|
|
503
389
|
map: record(string(), string()),
|
|
504
390
|
auto: boolean()
|
|
505
391
|
});
|
|
392
|
+
var ProbeSnapshotsOfDeviceSchema = record(string(), ProbedSnapshotSchema);
|
|
393
|
+
var RtspTokensOfDeviceSchema = record(string(), string().min(1));
|
|
506
394
|
/**
|
|
507
|
-
*
|
|
508
|
-
*
|
|
509
|
-
*
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
}
|
|
532
|
-
|
|
533
|
-
|
|
395
|
+
* A broker id is `<deviceId>/<camStreamId>`. Returns `null` rather than
|
|
396
|
+
* guessing: a key whose head is not a number is a key this addon did not
|
|
397
|
+
* write, and inventing a device for it would put a value on a camera at random.
|
|
398
|
+
*/
|
|
399
|
+
function splitBrokerId(brokerId) {
|
|
400
|
+
const slash = brokerId.indexOf("/");
|
|
401
|
+
if (slash <= 0) return null;
|
|
402
|
+
const deviceId = Number(brokerId.slice(0, slash));
|
|
403
|
+
if (!Number.isInteger(deviceId) || deviceId <= 0) return null;
|
|
404
|
+
return {
|
|
405
|
+
deviceId,
|
|
406
|
+
camStreamId: brokerId.slice(slash + 1)
|
|
407
|
+
};
|
|
408
|
+
}
|
|
409
|
+
/** Group `<deviceId>/<camStreamId>` -> value into deviceId -> camStreamId -> value. */
|
|
410
|
+
function groupByDevice(flat) {
|
|
411
|
+
const entries = flat instanceof Map ? [...flat.entries()] : Object.entries(flat);
|
|
412
|
+
const out = /* @__PURE__ */ new Map();
|
|
413
|
+
for (const [brokerId, value] of entries) {
|
|
414
|
+
const parts = splitBrokerId(brokerId);
|
|
415
|
+
if (parts === null) continue;
|
|
416
|
+
const group = out.get(parts.deviceId) ?? {};
|
|
417
|
+
group[parts.camStreamId] = value;
|
|
418
|
+
out.set(parts.deviceId, group);
|
|
419
|
+
}
|
|
420
|
+
return out;
|
|
421
|
+
}
|
|
422
|
+
/** The inverse of {@link groupByDevice}. */
|
|
423
|
+
function flattenByDevice(grouped) {
|
|
424
|
+
const out = /* @__PURE__ */ new Map();
|
|
425
|
+
for (const [deviceId, group] of grouped) for (const [camStreamId, value] of Object.entries(group)) out.set(`${deviceId}/${camStreamId}`, value);
|
|
426
|
+
return out;
|
|
427
|
+
}
|
|
428
|
+
/** A stored value that exists but cannot be read now (see `DeviceKeyMirrorDeps.decode`). */
|
|
429
|
+
var HELD_UNAVAILABLE = Symbol("held-unavailable");
|
|
430
|
+
/**
|
|
431
|
+
* One settings key across the fleet: loaded once, then written by diff.
|
|
432
|
+
*
|
|
433
|
+
* `replace` is authoritative (a camera the caller no longer holds loses the
|
|
434
|
+
* key, but only if this mirror has SEEN it - it never deletes what it did not
|
|
435
|
+
* load); `merge` only ever adds and changes.
|
|
436
|
+
*/
|
|
437
|
+
var DeviceKeyMirror = class {
|
|
438
|
+
deps;
|
|
439
|
+
/** The JSON of what the store holds per device, as of the last load or write. */
|
|
440
|
+
held = /* @__PURE__ */ new Map();
|
|
441
|
+
values = /* @__PURE__ */ new Map();
|
|
442
|
+
blocked = /* @__PURE__ */ new Map();
|
|
443
|
+
/** Cameras whose stored value is GONE: a load never re-blocks them. */
|
|
444
|
+
released = /* @__PURE__ */ new Set();
|
|
445
|
+
loaded = false;
|
|
446
|
+
constructor(deps) {
|
|
447
|
+
this.deps = deps;
|
|
448
|
+
}
|
|
449
|
+
/** Read the key of every camera that has it. THROWS when the store cannot be asked. */
|
|
450
|
+
async load() {
|
|
451
|
+
const ids = await this.deps.settings.listDeviceStoreIds();
|
|
452
|
+
const blobs = await this.deps.settings.readDeviceStoreBatch(ids);
|
|
453
|
+
const out = /* @__PURE__ */ new Map();
|
|
454
|
+
this.held.clear();
|
|
455
|
+
this.values.clear();
|
|
456
|
+
this.loaded = false;
|
|
457
|
+
this.blocked.clear();
|
|
458
|
+
for (const [deviceId, blob] of blobs) {
|
|
459
|
+
const stored = blob[this.deps.key];
|
|
460
|
+
if (stored === void 0) continue;
|
|
461
|
+
const value = this.deps.decode(stored);
|
|
462
|
+
if (value === HELD_UNAVAILABLE) {
|
|
463
|
+
if (!this.released.has(deviceId)) this.blocked.set(deviceId, stored);
|
|
464
|
+
continue;
|
|
465
|
+
}
|
|
466
|
+
if (value === null) {
|
|
467
|
+
this.deps.logger.warn("broker device state skipped - it does not parse", {
|
|
468
|
+
tags: { deviceId },
|
|
469
|
+
meta: { key: this.deps.key }
|
|
470
|
+
});
|
|
471
|
+
continue;
|
|
472
|
+
}
|
|
473
|
+
out.set(deviceId, value);
|
|
474
|
+
this.values.set(deviceId, value);
|
|
475
|
+
this.held.set(deviceId, JSON.stringify(this.deps.encode(value)));
|
|
476
|
+
}
|
|
477
|
+
this.loaded = true;
|
|
478
|
+
return out;
|
|
479
|
+
}
|
|
480
|
+
/** Cameras whose stored value exists but could not be read: never written, never replaced. */
|
|
481
|
+
heldDevices() {
|
|
482
|
+
return this.blocked;
|
|
483
|
+
}
|
|
484
|
+
/**
|
|
485
|
+
* Stop protecting a camera whose stored value is GONE (the secret was deleted
|
|
486
|
+
* or the keychain restored without it): there is nothing left to overwrite.
|
|
487
|
+
*/
|
|
488
|
+
release(deviceId) {
|
|
489
|
+
this.blocked.delete(deviceId);
|
|
490
|
+
this.released.add(deviceId);
|
|
491
|
+
}
|
|
492
|
+
/** Is this camera's token write (and restream URL) blocked right now? */
|
|
493
|
+
isBlocked(deviceId) {
|
|
494
|
+
return this.blocked.has(deviceId);
|
|
495
|
+
}
|
|
496
|
+
assertLoaded() {
|
|
497
|
+
if (!this.loaded) throw new Error(`broker device state "${this.deps.key}" was never loaded - refusing to write`);
|
|
498
|
+
}
|
|
499
|
+
async writeChanged(next) {
|
|
500
|
+
let written = 0;
|
|
501
|
+
for (const [deviceId, value] of next) {
|
|
502
|
+
if (this.blocked.has(deviceId)) continue;
|
|
503
|
+
const encoded = this.deps.encode(value);
|
|
504
|
+
const text = JSON.stringify(encoded);
|
|
505
|
+
if (this.held.get(deviceId) === text) continue;
|
|
506
|
+
await this.deps.settings.writeDeviceStore(deviceId, { [this.deps.key]: encoded });
|
|
507
|
+
this.held.set(deviceId, text);
|
|
508
|
+
this.values.set(deviceId, value);
|
|
509
|
+
written += 1;
|
|
510
|
+
}
|
|
511
|
+
return written;
|
|
512
|
+
}
|
|
513
|
+
/** Make the store hold exactly `next` for the cameras this mirror knows. */
|
|
514
|
+
async replace(next) {
|
|
515
|
+
this.assertLoaded();
|
|
516
|
+
const written = await this.writeChanged(next);
|
|
517
|
+
let removed = 0;
|
|
518
|
+
for (const deviceId of [...this.held.keys()]) {
|
|
519
|
+
if (next.has(deviceId) || this.blocked.has(deviceId)) continue;
|
|
520
|
+
await this.deps.settings.writeDeviceStore(deviceId, { [this.deps.key]: void 0 });
|
|
521
|
+
this.held.delete(deviceId);
|
|
522
|
+
this.values.delete(deviceId);
|
|
523
|
+
removed += 1;
|
|
524
|
+
}
|
|
525
|
+
return {
|
|
526
|
+
written,
|
|
527
|
+
removed
|
|
528
|
+
};
|
|
529
|
+
}
|
|
530
|
+
/** Add or change; never remove. */
|
|
531
|
+
async merge(next) {
|
|
532
|
+
this.assertLoaded();
|
|
533
|
+
return this.writeChanged(next);
|
|
534
|
+
}
|
|
535
|
+
/** Drop one camera's key (a device that changed hardware under its number). */
|
|
536
|
+
async remove(deviceId) {
|
|
537
|
+
this.assertLoaded();
|
|
538
|
+
if (!this.held.has(deviceId) || this.blocked.has(deviceId)) return false;
|
|
539
|
+
await this.deps.settings.writeDeviceStore(deviceId, { [this.deps.key]: void 0 });
|
|
540
|
+
this.held.delete(deviceId);
|
|
541
|
+
this.values.delete(deviceId);
|
|
542
|
+
return true;
|
|
543
|
+
}
|
|
544
|
+
/** What the store holds for the camera as of the last load or write. */
|
|
545
|
+
current(deviceId) {
|
|
546
|
+
return this.values.get(deviceId);
|
|
547
|
+
}
|
|
548
|
+
/** Whether a load has answered; a writer that was never loaded refuses. */
|
|
549
|
+
get isLoaded() {
|
|
550
|
+
return this.loaded;
|
|
551
|
+
}
|
|
534
552
|
};
|
|
535
|
-
function
|
|
536
|
-
return
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
553
|
+
function parseWith(schema) {
|
|
554
|
+
return (stored) => {
|
|
555
|
+
const parsed = schema.safeParse(stored);
|
|
556
|
+
return parsed.success ? parsed.data : null;
|
|
557
|
+
};
|
|
558
|
+
}
|
|
559
|
+
function probeSnapshotsMirror(settings, logger) {
|
|
560
|
+
return new DeviceKeyMirror({
|
|
561
|
+
settings,
|
|
562
|
+
key: PROBE_SNAPSHOTS_KEY,
|
|
563
|
+
logger,
|
|
564
|
+
decode: parseWith(ProbeSnapshotsOfDeviceSchema),
|
|
565
|
+
encode: (value) => value
|
|
566
|
+
});
|
|
567
|
+
}
|
|
568
|
+
function profileMapMirror(settings, logger) {
|
|
569
|
+
return new DeviceKeyMirror({
|
|
570
|
+
settings,
|
|
571
|
+
key: BROKER_PROFILE_KEY,
|
|
572
|
+
logger,
|
|
573
|
+
decode: parseWith(ProfileAssignmentSchema),
|
|
574
|
+
encode: (value) => value
|
|
575
|
+
});
|
|
576
|
+
}
|
|
577
|
+
/** The tokens of one camera are ONE keychain-held text: JSON of `{ camStreamId: token }`. */
|
|
578
|
+
function rtspTokensMirror(settings, logger) {
|
|
579
|
+
return new DeviceKeyMirror({
|
|
580
|
+
settings,
|
|
581
|
+
key: RTSP_TOKENS_KEY,
|
|
582
|
+
logger,
|
|
583
|
+
decode: (stored) => {
|
|
584
|
+
if (isSecretMarker(stored)) return HELD_UNAVAILABLE;
|
|
585
|
+
if (typeof stored !== "string") return null;
|
|
586
|
+
try {
|
|
587
|
+
const parsed = RtspTokensOfDeviceSchema.safeParse(JSON.parse(stored));
|
|
588
|
+
return parsed.success ? parsed.data : null;
|
|
589
|
+
} catch {
|
|
590
|
+
return null;
|
|
591
|
+
}
|
|
592
|
+
},
|
|
593
|
+
encode: (value) => JSON.stringify(value)
|
|
540
594
|
});
|
|
541
595
|
}
|
|
542
596
|
/**
|
|
543
|
-
*
|
|
544
|
-
*
|
|
545
|
-
*
|
|
597
|
+
* Fold freshly minted tokens (`<deviceId>/<camStreamId>` -> token) into what
|
|
598
|
+
* each camera already holds. Only ever adds or changes: a token is stable
|
|
599
|
+
* forever per stream unless explicitly regenerated.
|
|
546
600
|
*/
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
}
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
successor: BROKER_RTSP_TOKENS_COLLECTION,
|
|
556
|
-
reason: "one row per broker since the flatten; as a blob a single minted token rewrote 33 KB"
|
|
557
|
-
},
|
|
558
|
-
{
|
|
559
|
-
key: "profileMap",
|
|
560
|
-
successor: BROKER_PROFILE_MAP_COLLECTION,
|
|
561
|
-
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"
|
|
562
|
-
}
|
|
563
|
-
];
|
|
601
|
+
function mergeRtspTokens(mirror, minted) {
|
|
602
|
+
const out = /* @__PURE__ */ new Map();
|
|
603
|
+
for (const [deviceId, group] of groupByDevice(minted)) out.set(deviceId, {
|
|
604
|
+
...mirror.current(deviceId),
|
|
605
|
+
...group
|
|
606
|
+
});
|
|
607
|
+
return out;
|
|
608
|
+
}
|
|
564
609
|
//#endregion
|
|
565
610
|
//#region src/stream-broker/live-viewers.ts
|
|
566
611
|
/**
|
|
@@ -809,6 +854,131 @@ function healthEventIsOperatorFacing(camStreamId) {
|
|
|
809
854
|
return !PRIVATE_STREAM_PREFIXES.some((prefix) => camStreamId.startsWith(prefix));
|
|
810
855
|
}
|
|
811
856
|
//#endregion
|
|
857
|
+
//#region src/stream-broker/stream-broker/stream-health-policy.ts
|
|
858
|
+
/**
|
|
859
|
+
* WHAT a stale stream means for a battery camera (D883).
|
|
860
|
+
*
|
|
861
|
+
* A mains camera whose stream goes quiet for the stale window has lost its
|
|
862
|
+
* stream: that is an outage. A battery camera whose stream goes quiet has,
|
|
863
|
+
* ordinarily, gone to sleep — the designed steady state, not a fault. The
|
|
864
|
+
* broker used to tell the two apart by nothing, so every sleep produced a
|
|
865
|
+
* `stream.offline` per profile and every wake a `stream.online`, and the
|
|
866
|
+
* operator's system-event rule pushed each one.
|
|
867
|
+
*
|
|
868
|
+
* The verdict is made HERE, at the emitter, from the battery's own sleep
|
|
869
|
+
* mirror — never by a filter downstream:
|
|
870
|
+
*
|
|
871
|
+
* - asleep and nobody asked for the stream — NOT an outage (`asleep`);
|
|
872
|
+
* - asleep, a stream was DEMANDED and none arrived — `wake-timeout`;
|
|
873
|
+
* - reported awake and the stream is gone — `connection-error`;
|
|
874
|
+
* - silent past the battery refresh window — `battery-unreachable`.
|
|
875
|
+
*
|
|
876
|
+
* A sleep verdict needs a sleep state that was actually READ. A mirror that
|
|
877
|
+
* was never fed (a restart, a dropped event) is not "asleep": the caller passes
|
|
878
|
+
* `battery: null` and the stream is judged exactly as a mains one (D49 —
|
|
879
|
+
* unknown excludes nothing, and a silent suppression on an unread mirror hid
|
|
880
|
+
* a camera that slept and then died).
|
|
881
|
+
*/
|
|
882
|
+
var OUTAGE_REASONS = [
|
|
883
|
+
"stale-timeout",
|
|
884
|
+
"wake-timeout",
|
|
885
|
+
"connection-error",
|
|
886
|
+
"battery-unreachable"
|
|
887
|
+
];
|
|
888
|
+
/**
|
|
889
|
+
* How long a demand may wait for a sleeping camera's stream before it counts
|
|
890
|
+
* as a failed wake: the firmware wake bound plus the broker settle window of
|
|
891
|
+
* wake-on-play (8 s + 20 s), with margin for the sweep cadence.
|
|
892
|
+
*/
|
|
893
|
+
var WAKE_DEMAND_BOUND_MS = 45e3;
|
|
894
|
+
function isStreamOutageReason(reason) {
|
|
895
|
+
return reason !== void 0 && OUTAGE_REASONS.includes(reason);
|
|
896
|
+
}
|
|
897
|
+
function judgeStaleStream(input) {
|
|
898
|
+
if (input.battery === null) return {
|
|
899
|
+
kind: "outage",
|
|
900
|
+
reason: "stale-timeout"
|
|
901
|
+
};
|
|
902
|
+
const presence = deriveBatteryPresence({
|
|
903
|
+
status: input.battery.facts,
|
|
904
|
+
nowMs: input.nowMs
|
|
905
|
+
});
|
|
906
|
+
if (presence === "unreachable") return {
|
|
907
|
+
kind: "outage",
|
|
908
|
+
reason: "battery-unreachable"
|
|
909
|
+
};
|
|
910
|
+
if (presence === "awake") return {
|
|
911
|
+
kind: "outage",
|
|
912
|
+
reason: "connection-error"
|
|
913
|
+
};
|
|
914
|
+
if (input.demanded) return {
|
|
915
|
+
kind: "outage",
|
|
916
|
+
reason: "wake-timeout"
|
|
917
|
+
};
|
|
918
|
+
return {
|
|
919
|
+
kind: "suppressed",
|
|
920
|
+
presence
|
|
921
|
+
};
|
|
922
|
+
}
|
|
923
|
+
//#endregion
|
|
924
|
+
//#region src/stream-broker/stream-broker/device-stream-outages.ts
|
|
925
|
+
var DeviceStreamOutages = class {
|
|
926
|
+
byDevice = /* @__PURE__ */ new Map();
|
|
927
|
+
get(deviceId) {
|
|
928
|
+
return this.byDevice.get(deviceId);
|
|
929
|
+
}
|
|
930
|
+
/** Open the device's outage, or add this stream to the one already open. */
|
|
931
|
+
open(deviceId, camStreamId, reason, now) {
|
|
932
|
+
const existing = this.byDevice.get(deviceId);
|
|
933
|
+
if (existing) {
|
|
934
|
+
existing.camStreamIds.add(camStreamId);
|
|
935
|
+
existing.wakeOnly = false;
|
|
936
|
+
existing.lastSignalAt = now;
|
|
937
|
+
return existing;
|
|
938
|
+
}
|
|
939
|
+
const created = {
|
|
940
|
+
reason,
|
|
941
|
+
since: now,
|
|
942
|
+
camStreamIds: new Set([camStreamId]),
|
|
943
|
+
announced: false,
|
|
944
|
+
wakeOnly: false,
|
|
945
|
+
lastSignalAt: now
|
|
946
|
+
};
|
|
947
|
+
this.byDevice.set(deviceId, created);
|
|
948
|
+
return created;
|
|
949
|
+
}
|
|
950
|
+
/**
|
|
951
|
+
* A failed wake with no broker to carry it. Joins the device's open outage
|
|
952
|
+
* (one outage per device) or opens a wake-only one over its REAL streams.
|
|
953
|
+
*/
|
|
954
|
+
openWake(deviceId, camStreamIds, reason, now) {
|
|
955
|
+
const existing = this.byDevice.get(deviceId);
|
|
956
|
+
if (existing) {
|
|
957
|
+
existing.lastSignalAt = now;
|
|
958
|
+
return existing;
|
|
959
|
+
}
|
|
960
|
+
const created = {
|
|
961
|
+
reason,
|
|
962
|
+
since: now,
|
|
963
|
+
camStreamIds: new Set(camStreamIds),
|
|
964
|
+
announced: false,
|
|
965
|
+
wakeOnly: true,
|
|
966
|
+
lastSignalAt: now
|
|
967
|
+
};
|
|
968
|
+
this.byDevice.set(deviceId, created);
|
|
969
|
+
return created;
|
|
970
|
+
}
|
|
971
|
+
entries() {
|
|
972
|
+
return this.byDevice.entries();
|
|
973
|
+
}
|
|
974
|
+
close(deviceId) {
|
|
975
|
+
this.byDevice.delete(deviceId);
|
|
976
|
+
}
|
|
977
|
+
clear() {
|
|
978
|
+
this.byDevice.clear();
|
|
979
|
+
}
|
|
980
|
+
};
|
|
981
|
+
//#endregion
|
|
812
982
|
//#region src/stream-broker/stream-broker/sentry-eligibility.ts
|
|
813
983
|
function sentryEligible(input) {
|
|
814
984
|
if (input.batteryOperated === null || input.declaresSignals === null) return {
|
|
@@ -5528,6 +5698,18 @@ var RtspListenServer = class RtspListenServer {
|
|
|
5528
5698
|
brokerTokens = /* @__PURE__ */ new Map();
|
|
5529
5699
|
/** token → brokerId (reverse map) */
|
|
5530
5700
|
tokenBrokers = /* @__PURE__ */ new Map();
|
|
5701
|
+
/**
|
|
5702
|
+
* brokerId → INTERNAL loopback-only alias token (D870). A camera whose
|
|
5703
|
+
* external restream token is held in an unreadable keychain has NO external
|
|
5704
|
+
* URL, but the broker's own consumers (recording, decode, snapshots, grid,
|
|
5705
|
+
* egress) still ride its restream: they get this ephemeral token, which is
|
|
5706
|
+
* never persisted, never handed out through the consumer URL APIs, and only
|
|
5707
|
+
* answers loopback / local-network peers.
|
|
5708
|
+
*/
|
|
5709
|
+
internalAliases = /* @__PURE__ */ new Map();
|
|
5710
|
+
internalAliasTokens = /* @__PURE__ */ new Set();
|
|
5711
|
+
/** Which peers may dial an internal alias; replaceable for tests. */
|
|
5712
|
+
aliasPeerPolicy = isClusterLocalAddress;
|
|
5531
5713
|
/** brokerId → enabled (default true) */
|
|
5532
5714
|
brokerEnabled = /* @__PURE__ */ new Map();
|
|
5533
5715
|
/** brokerId → warm-up closure registered alongside the restreamer */
|
|
@@ -5558,7 +5740,37 @@ var RtspListenServer = class RtspListenServer {
|
|
|
5558
5740
|
if (sentryOpener) this.brokerPrestreamOpeners.set(brokerId, sentryOpener);
|
|
5559
5741
|
return token;
|
|
5560
5742
|
}
|
|
5743
|
+
/**
|
|
5744
|
+
* Register (or refresh) the internal loopback-only alias of a broker. Returns
|
|
5745
|
+
* its token. The external token map is untouched, so the alias can never be
|
|
5746
|
+
* listed, persisted or surfaced as a consumer URL.
|
|
5747
|
+
*/
|
|
5748
|
+
registerInternalAlias(brokerId, restreamer, warmUp, sentryOpener) {
|
|
5749
|
+
const token = this.internalAliases.get(brokerId) ?? RtspListenServer.generateToken();
|
|
5750
|
+
this.restreamers.set(token, restreamer);
|
|
5751
|
+
this.tokenBrokers.set(token, brokerId);
|
|
5752
|
+
this.internalAliases.set(brokerId, token);
|
|
5753
|
+
this.internalAliasTokens.add(token);
|
|
5754
|
+
if (warmUp) this.brokerWarmUps.set(brokerId, warmUp);
|
|
5755
|
+
if (sentryOpener) this.brokerPrestreamOpeners.set(brokerId, sentryOpener);
|
|
5756
|
+
return token;
|
|
5757
|
+
}
|
|
5758
|
+
/** Replace the peer policy of the internal aliases (tests only). */
|
|
5759
|
+
setAliasPeerPolicy(policy) {
|
|
5760
|
+
this.aliasPeerPolicy = policy;
|
|
5761
|
+
}
|
|
5762
|
+
/** The token an IN-PROCESS consumer dials: the internal alias when there is one, else the external token. */
|
|
5763
|
+
getInternalToken(brokerId) {
|
|
5764
|
+
return this.internalAliases.get(brokerId) ?? this.brokerTokens.get(brokerId);
|
|
5765
|
+
}
|
|
5561
5766
|
unregisterRestreamer(brokerId) {
|
|
5767
|
+
const alias = this.internalAliases.get(brokerId);
|
|
5768
|
+
if (alias !== void 0) {
|
|
5769
|
+
this.restreamers.delete(alias);
|
|
5770
|
+
this.tokenBrokers.delete(alias);
|
|
5771
|
+
this.internalAliasTokens.delete(alias);
|
|
5772
|
+
this.internalAliases.delete(brokerId);
|
|
5773
|
+
}
|
|
5562
5774
|
const token = this.brokerTokens.get(brokerId);
|
|
5563
5775
|
if (token) {
|
|
5564
5776
|
this.restreamers.delete(token);
|
|
@@ -5580,6 +5792,17 @@ var RtspListenServer = class RtspListenServer {
|
|
|
5580
5792
|
this.restreamers.set(newToken, restreamer);
|
|
5581
5793
|
this.brokerTokens.set(brokerId, newToken);
|
|
5582
5794
|
this.tokenBrokers.set(newToken, brokerId);
|
|
5795
|
+
const alias = this.internalAliases.get(brokerId);
|
|
5796
|
+
if (alias !== void 0) {
|
|
5797
|
+
this.restreamers.delete(alias);
|
|
5798
|
+
this.tokenBrokers.delete(alias);
|
|
5799
|
+
this.internalAliasTokens.delete(alias);
|
|
5800
|
+
const newAlias = RtspListenServer.generateToken();
|
|
5801
|
+
this.restreamers.set(newAlias, restreamer);
|
|
5802
|
+
this.tokenBrokers.set(newAlias, brokerId);
|
|
5803
|
+
this.internalAliases.set(brokerId, newAlias);
|
|
5804
|
+
this.internalAliasTokens.add(newAlias);
|
|
5805
|
+
}
|
|
5583
5806
|
return newToken;
|
|
5584
5807
|
}
|
|
5585
5808
|
/** Get the current token for a broker. */
|
|
@@ -5663,6 +5886,11 @@ var RtspListenServer = class RtspListenServer {
|
|
|
5663
5886
|
socket.destroy();
|
|
5664
5887
|
return;
|
|
5665
5888
|
}
|
|
5889
|
+
if (this.internalAliasTokens.has(lookupPath) && !this.aliasPeerPolicy(socket.remoteAddress)) {
|
|
5890
|
+
socket.write(`RTSP/1.0 403 Forbidden\r\nCSeq: 1\r\n\r\n`);
|
|
5891
|
+
socket.destroy();
|
|
5892
|
+
return;
|
|
5893
|
+
}
|
|
5666
5894
|
const brokerId = this.tokenBrokers.get(lookupPath);
|
|
5667
5895
|
if (brokerId && !this.isEnabled(brokerId)) {
|
|
5668
5896
|
socket.write(`RTSP/1.0 403 Forbidden\r\nCSeq: 1\r\n\r\n`);
|
|
@@ -5721,6 +5949,24 @@ var RtspListenServer = class RtspListenServer {
|
|
|
5721
5949
|
});
|
|
5722
5950
|
}
|
|
5723
5951
|
};
|
|
5952
|
+
/**
|
|
5953
|
+
* Is the peer on the loopback interface or the local network (RFC 1918, link-local,
|
|
5954
|
+
* ULA)? The internal alias answers only these: a recorder or decoder on another
|
|
5955
|
+
* node of the cluster is a legitimate consumer, the public internet is not.
|
|
5956
|
+
*/
|
|
5957
|
+
function isClusterLocalAddress(address) {
|
|
5958
|
+
if (address === void 0) return false;
|
|
5959
|
+
const v4 = address.startsWith("::ffff:") ? address.slice(7) : address;
|
|
5960
|
+
if (v4 === "::1") return true;
|
|
5961
|
+
const m = /^(\d+)\.(\d+)\.\d+\.\d+$/.exec(v4);
|
|
5962
|
+
if (m !== null) {
|
|
5963
|
+
const a = Number(m[1]);
|
|
5964
|
+
const b = Number(m[2]);
|
|
5965
|
+
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;
|
|
5966
|
+
}
|
|
5967
|
+
const lower = address.toLowerCase();
|
|
5968
|
+
return lower.startsWith("fe80:") || lower.startsWith("fc") || lower.startsWith("fd");
|
|
5969
|
+
}
|
|
5724
5970
|
//#endregion
|
|
5725
5971
|
//#region src/stream-broker/rtsp/rtsp-restream-provider.ts
|
|
5726
5972
|
/**
|
|
@@ -5802,6 +6048,24 @@ var RtspRestreamProviderImpl = class {
|
|
|
5802
6048
|
enabled: this.server.isEnabled(brokerId)
|
|
5803
6049
|
};
|
|
5804
6050
|
}
|
|
6051
|
+
/**
|
|
6052
|
+
* The entry an IN-PROCESS consumer dials (recording, decode, snapshots, grid,
|
|
6053
|
+
* egress): the loopback-only internal alias when the camera's external token
|
|
6054
|
+
* is held back (D870), else the external entry. NEVER hand this out through a
|
|
6055
|
+
* consumer URL API - use {@link getEntry} for that.
|
|
6056
|
+
*/
|
|
6057
|
+
getInternalEntry(brokerId, hostname) {
|
|
6058
|
+
const token = this.server.getInternalToken(brokerId);
|
|
6059
|
+
if (!token) return null;
|
|
6060
|
+
const port = this.server.getPort();
|
|
6061
|
+
const host = hostname ?? "127.0.0.1";
|
|
6062
|
+
return {
|
|
6063
|
+
brokerId,
|
|
6064
|
+
url: `rtsp://${host}:${port}/${token}`,
|
|
6065
|
+
mutedUrl: `rtsp://${host}:${port}/${token}/muted`,
|
|
6066
|
+
enabled: this.server.isEnabled(brokerId)
|
|
6067
|
+
};
|
|
6068
|
+
}
|
|
5805
6069
|
regenerateToken(brokerId) {
|
|
5806
6070
|
const token = this.server.regenerateToken(brokerId);
|
|
5807
6071
|
if (token) {
|
|
@@ -14412,6 +14676,15 @@ var StreamBroker = class StreamBroker {
|
|
|
14412
14676
|
return false;
|
|
14413
14677
|
}
|
|
14414
14678
|
/**
|
|
14679
|
+
* Does a consumer want this stream right now (a viewer, a recording, a
|
|
14680
|
+
* detection decode, a camera-reported wake hold)? Sentry sessions and idle
|
|
14681
|
+
* prestream do not count. The stream-health sweep reads it to tell a battery
|
|
14682
|
+
* camera that slept unasked from one that failed to wake for somebody (D883).
|
|
14683
|
+
*/
|
|
14684
|
+
hasLiveDemand() {
|
|
14685
|
+
return this.hasLiveIntent();
|
|
14686
|
+
}
|
|
14687
|
+
/**
|
|
14415
14688
|
* True when at least one consumer needs DECODED audio. The audio-specific
|
|
14416
14689
|
* subset of {@link hasDemand} — deliberately excludes video-only
|
|
14417
14690
|
* consumers so a video-only viewer never spins up the AAC/Opus decode
|
|
@@ -17311,6 +17584,10 @@ function resolveBrokerTier(brokerId, localLookup, remoteBrokerTiers) {
|
|
|
17311
17584
|
*/
|
|
17312
17585
|
var STREAM_STALE_TIMEOUT_MS = 12e4;
|
|
17313
17586
|
var STREAM_HEALTH_POLL_MS = 15e3;
|
|
17587
|
+
/** How often an unanswered / stale battery probe is asked again. */
|
|
17588
|
+
var BATTERY_PROBE_RETRY_MS = 6e4;
|
|
17589
|
+
/** A wake-only outage with no further failed wake for this long is over: the demand is gone. */
|
|
17590
|
+
var WAKE_ONLY_OUTAGE_TTL_MS = 5 * 6e4;
|
|
17314
17591
|
/** Compose a SOURCE broker id from a `(deviceId, camStreamId)` tuple. Thin
|
|
17315
17592
|
* wrapper over the canonical `makeSourceBrokerId` — single source of truth. */
|
|
17316
17593
|
function brokerIdFor(deviceId, camStreamId) {
|
|
@@ -17503,6 +17780,25 @@ var StreamBrokerManager = class StreamBrokerManager {
|
|
|
17503
17780
|
* guess which one it was looking at.
|
|
17504
17781
|
*/
|
|
17505
17782
|
streamOfflineReasonByBroker = /* @__PURE__ */ new Map();
|
|
17783
|
+
/**
|
|
17784
|
+
* Brokers whose stream went quiet because their BATTERY camera is asleep.
|
|
17785
|
+
* Not an outage and never announced (D883); remembered so the wake that ends
|
|
17786
|
+
* it is recognised as a plain wake instead of a `recovered`.
|
|
17787
|
+
*/
|
|
17788
|
+
sleepSuppressedBrokers = /* @__PURE__ */ new Set();
|
|
17789
|
+
/**
|
|
17790
|
+
* What the sweep knows of a device's battery: is it one, and what did its
|
|
17791
|
+
* `battery` slice last say. Fed by `BatteryOnStatusChanged` (droppable, empty
|
|
17792
|
+
* after a restart) AND read from the device registry / slice by the sweep
|
|
17793
|
+
* itself — the same two reads wake-on-play makes — so a restart or a lost
|
|
17794
|
+
* event never leaves the verdict standing on an unread mirror (D49).
|
|
17795
|
+
*/
|
|
17796
|
+
batteryProbe = /* @__PURE__ */ new Map();
|
|
17797
|
+
batteryProbeInFlight = /* @__PURE__ */ new Set();
|
|
17798
|
+
/** When a consumer's demand on a quiet battery stream was first seen (the wake bound starts there). */
|
|
17799
|
+
demandSince = /* @__PURE__ */ new Map();
|
|
17800
|
+
/** The stream outage a DEVICE is in (one entry per device, however many profiles). */
|
|
17801
|
+
deviceOutages = new DeviceStreamOutages();
|
|
17506
17802
|
streamHealthTimer;
|
|
17507
17803
|
/**
|
|
17508
17804
|
* Routes an audio-chunk `subscriptionId` (Phase 5 / D9) back to its owning
|
|
@@ -17535,6 +17831,14 @@ var StreamBrokerManager = class StreamBrokerManager {
|
|
|
17535
17831
|
rtspProvider;
|
|
17536
17832
|
persistedTokens = /* @__PURE__ */ new Map();
|
|
17537
17833
|
/**
|
|
17834
|
+
* True for a camera whose restream tokens are held in an unreadable keychain
|
|
17835
|
+
* (D870). Such a camera gets NO restream URL - minting a temporary token would
|
|
17836
|
+
* hand consumers a URL that dies as soon as the real one is restored - while
|
|
17837
|
+
* every other camera, and the camera's own streams and recording, are
|
|
17838
|
+
* untouched.
|
|
17839
|
+
*/
|
|
17840
|
+
restreamGate = () => false;
|
|
17841
|
+
/**
|
|
17538
17842
|
* Last runtime probe per broker, captured while the broker was streaming.
|
|
17539
17843
|
* Survives the stream going idle so the per-stream "Probed" settings field
|
|
17540
17844
|
* stays populated (with an "as of" tag). In-memory only — repopulates on the
|
|
@@ -17734,7 +18038,7 @@ var StreamBrokerManager = class StreamBrokerManager {
|
|
|
17734
18038
|
hwaccel: this.hwaccel ?? void 0,
|
|
17735
18039
|
cameraStreamLookup: (deviceId) => this.getCameraStreamsForDevice(deviceId),
|
|
17736
18040
|
rtspEntryLookup: (brokerId) => {
|
|
17737
|
-
const entry = this.rtspProvider.
|
|
18041
|
+
const entry = this.rtspProvider.getInternalEntry(brokerId);
|
|
17738
18042
|
return entry ? {
|
|
17739
18043
|
url: entry.url,
|
|
17740
18044
|
mutedUrl: entry.mutedUrl
|
|
@@ -17793,7 +18097,7 @@ var StreamBrokerManager = class StreamBrokerManager {
|
|
|
17793
18097
|
cameraStreamLookup: (deviceId) => this.getCameraStreamsForDevice(deviceId),
|
|
17794
18098
|
resolveProfileSource: (deviceId, profile) => this.assignments.get(deviceId)?.map[profile] ?? null,
|
|
17795
18099
|
rtspEntryLookup: (brokerId) => {
|
|
17796
|
-
const entry = this.rtspProvider.
|
|
18100
|
+
const entry = this.rtspProvider.getInternalEntry(brokerId);
|
|
17797
18101
|
return entry ? { url: entry.url } : null;
|
|
17798
18102
|
},
|
|
17799
18103
|
sourceHasMedia: (brokerId) => {
|
|
@@ -18435,6 +18739,7 @@ var StreamBrokerManager = class StreamBrokerManager {
|
|
|
18435
18739
|
const deviceId = typeof data.deviceId === "number" ? data.deviceId : null;
|
|
18436
18740
|
if (deviceId === null) return;
|
|
18437
18741
|
const sleeping = data.status?.sleeping === true;
|
|
18742
|
+
this.recordBatterySleep(deviceId, data.status);
|
|
18438
18743
|
this.applyPlaceholderReasonToDevice(deviceId, sleeping ? "sleeping" : "reconnecting");
|
|
18439
18744
|
});
|
|
18440
18745
|
bus.subscribe({ category: EventCategory.BatteryOnWakeStarted }, (event) => {
|
|
@@ -18598,18 +18903,7 @@ var StreamBrokerManager = class StreamBrokerManager {
|
|
|
18598
18903
|
await this.batteryWakeOnPlay.run(deviceId, {
|
|
18599
18904
|
logger: this.logger,
|
|
18600
18905
|
isBatteryDevice: (id) => this.isBatteryOperated(id),
|
|
18601
|
-
readBatteryFacts:
|
|
18602
|
-
const slice = await api.deviceState.getCapSlice.query({
|
|
18603
|
-
deviceId: id,
|
|
18604
|
-
capName: batteryCapability.name
|
|
18605
|
-
}).catch(() => null);
|
|
18606
|
-
const parsed = BatteryStatusSchema.safeParse(slice);
|
|
18607
|
-
if (!parsed.success) return null;
|
|
18608
|
-
return {
|
|
18609
|
-
sleeping: parsed.data.sleeping,
|
|
18610
|
-
lastContactAt: parsed.data.lastContactAt
|
|
18611
|
-
};
|
|
18612
|
-
},
|
|
18906
|
+
readBatteryFacts: (id) => this.readBatterySleepFacts(id),
|
|
18613
18907
|
wakeForStream: async (id, timeoutMs) => api.battery.wakeForStream.mutate({
|
|
18614
18908
|
deviceId: id,
|
|
18615
18909
|
timeoutMs
|
|
@@ -18617,6 +18911,9 @@ var StreamBrokerManager = class StreamBrokerManager {
|
|
|
18617
18911
|
reconcileCatalog: (id) => this.reconcileDeviceCatalog(id),
|
|
18618
18912
|
ensureBroker: () => this.ensureBrokerForWebrtcSession(deviceId, brokerId, camStreamId),
|
|
18619
18913
|
hasBroker: demandHasBroker
|
|
18914
|
+
}).catch((err) => {
|
|
18915
|
+
if (err instanceof BatteryStreamUnavailableError) this.announceBatteryWakeFailure(deviceId, camStreamId, err.presence === "unreachable" ? "battery-unreachable" : "wake-timeout");
|
|
18916
|
+
throw err;
|
|
18620
18917
|
});
|
|
18621
18918
|
}
|
|
18622
18919
|
/** Any broker (local or remote-adapter) registered for this device? The
|
|
@@ -18857,6 +19154,20 @@ var StreamBrokerManager = class StreamBrokerManager {
|
|
|
18857
19154
|
loadPersistedTokens(tokens) {
|
|
18858
19155
|
this.persistedTokens = new Map(tokens);
|
|
18859
19156
|
}
|
|
19157
|
+
/** See {@link restreamGate}. */
|
|
19158
|
+
setRestreamGate(gate) {
|
|
19159
|
+
this.restreamGate = gate;
|
|
19160
|
+
}
|
|
19161
|
+
/**
|
|
19162
|
+
* Register the restream URLs of ONE camera now (its tokens became readable, or
|
|
19163
|
+
* were issued fresh): each of its source brokers and the profile restreamers
|
|
19164
|
+
* over them. No broker is torn down or recreated, so nothing stops recording.
|
|
19165
|
+
*/
|
|
19166
|
+
registerRestreamersForDevice(deviceId) {
|
|
19167
|
+
if (this.restreamGate(deviceId)) return;
|
|
19168
|
+
for (const [brokerId, broker] of this.brokers) if (brokerId.startsWith(`${deviceId}/`)) this.registerSourceRestreamer(brokerId, broker);
|
|
19169
|
+
this.syncProfileRestreamers(deviceId);
|
|
19170
|
+
}
|
|
18860
19171
|
/** Upsert a single SOURCE (non-derived) cam-stream into the registry
|
|
18861
19172
|
* WITHOUT ranking or broker creation. Returns the prior entry (if any)
|
|
18862
19173
|
* so callers can detect url refresh / already-existed. */
|
|
@@ -19600,6 +19911,20 @@ var StreamBrokerManager = class StreamBrokerManager {
|
|
|
19600
19911
|
return { success: true };
|
|
19601
19912
|
}
|
|
19602
19913
|
/**
|
|
19914
|
+
* Drop a DELETED camera's whole override entry and persist (D870). The
|
|
19915
|
+
* device-removal hook's one reach into the override map; `forgetDeviceHardware`
|
|
19916
|
+
* keeps the entry's non-hardware fields, a deleted camera keeps nothing.
|
|
19917
|
+
*/
|
|
19918
|
+
dropDeviceOverride(deviceId) {
|
|
19919
|
+
if (!this.deviceOverrides.delete(deviceId)) return false;
|
|
19920
|
+
this.deviceOverridePersister?.(this.deviceOverrides);
|
|
19921
|
+
return true;
|
|
19922
|
+
}
|
|
19923
|
+
/** Cameras that have an override entry. */
|
|
19924
|
+
listDeviceOverrideIds() {
|
|
19925
|
+
return [...this.deviceOverrides.keys()];
|
|
19926
|
+
}
|
|
19927
|
+
/**
|
|
19603
19928
|
* The hardware behind `deviceId` was replaced (`deviceManager.migrateDevice`)
|
|
19604
19929
|
* — forget everything this broker knows that described the OLD box. See the
|
|
19605
19930
|
* cap doc for the inventory. What deliberately SURVIVES is the role-scoped
|
|
@@ -20212,6 +20537,10 @@ var StreamBrokerManager = class StreamBrokerManager {
|
|
|
20212
20537
|
this.brokers.clear();
|
|
20213
20538
|
this.streamHealthByBroker.clear();
|
|
20214
20539
|
this.streamOfflineReasonByBroker.clear();
|
|
20540
|
+
this.sleepSuppressedBrokers.clear();
|
|
20541
|
+
this.batteryProbe.clear();
|
|
20542
|
+
this.demandSince.clear();
|
|
20543
|
+
this.deviceOutages.clear();
|
|
20215
20544
|
this.audioSubscriptionBroker.clear();
|
|
20216
20545
|
await this.rtspServer.stop();
|
|
20217
20546
|
}
|
|
@@ -20234,6 +20563,8 @@ var StreamBrokerManager = class StreamBrokerManager {
|
|
|
20234
20563
|
evaluateStreamHealth() {
|
|
20235
20564
|
if (!this.eventBus) return;
|
|
20236
20565
|
const now = Date.now();
|
|
20566
|
+
const outageOpenedOn = /* @__PURE__ */ new Map();
|
|
20567
|
+
const cameBackOn = /* @__PURE__ */ new Map();
|
|
20237
20568
|
for (const [brokerId, broker] of this.brokers) {
|
|
20238
20569
|
const parsed = this.parseBrokerId(brokerId);
|
|
20239
20570
|
if (!parsed) continue;
|
|
@@ -20243,30 +20574,386 @@ var StreamBrokerManager = class StreamBrokerManager {
|
|
|
20243
20574
|
const wasHealthy = this.streamHealthByBroker.get(brokerId);
|
|
20244
20575
|
const isHealthy = lastPacketAt > 0 && now - lastPacketAt <= STREAM_STALE_TIMEOUT_MS;
|
|
20245
20576
|
if (!isHealthy && broker.getStats().status === "streaming") broker.redial("stream-health sweep: phantom streaming, no packets within stale window");
|
|
20246
|
-
|
|
20577
|
+
const context = {
|
|
20578
|
+
deviceId,
|
|
20579
|
+
camStreamId,
|
|
20580
|
+
brokerId,
|
|
20581
|
+
sourceType: broker.getActiveSourceType(),
|
|
20582
|
+
lastPacketAt
|
|
20583
|
+
};
|
|
20584
|
+
if (wasHealthy === isHealthy) {
|
|
20585
|
+
if (!isHealthy) this.rejudgeQuietBroker(brokerId, broker, context, now, outageOpenedOn);
|
|
20586
|
+
continue;
|
|
20587
|
+
}
|
|
20247
20588
|
if (wasHealthy === void 0 && !isHealthy && !broker.suspended) {
|
|
20248
20589
|
this.streamHealthByBroker.set(brokerId, false);
|
|
20249
20590
|
continue;
|
|
20250
20591
|
}
|
|
20592
|
+
let verdict = null;
|
|
20593
|
+
if (!isHealthy && !broker.suspended) {
|
|
20594
|
+
verdict = this.judgeQuietStream(brokerId, deviceId, broker, now);
|
|
20595
|
+
if (verdict === "defer") continue;
|
|
20596
|
+
}
|
|
20251
20597
|
this.streamHealthByBroker.set(brokerId, isHealthy);
|
|
20252
|
-
const
|
|
20253
|
-
|
|
20254
|
-
|
|
20255
|
-
|
|
20598
|
+
const wasSleepSuppressed = this.sleepSuppressedBrokers.delete(brokerId);
|
|
20599
|
+
const previousReason = this.streamOfflineReasonByBroker.get(brokerId);
|
|
20600
|
+
if (verdict !== null) {
|
|
20601
|
+
if (verdict.kind === "suppressed") {
|
|
20602
|
+
this.markSleepSuppressed(brokerId, context, verdict.presence, lastPacketAt);
|
|
20603
|
+
continue;
|
|
20604
|
+
}
|
|
20605
|
+
this.openStreamOutage(brokerId, context, verdict.reason, now, outageOpenedOn);
|
|
20606
|
+
continue;
|
|
20607
|
+
}
|
|
20608
|
+
if (!isHealthy) {
|
|
20609
|
+
this.demandSince.delete(brokerId);
|
|
20610
|
+
this.streamOfflineReasonByBroker.set(brokerId, "suspended");
|
|
20611
|
+
this.emitStreamHealth(false, {
|
|
20612
|
+
...context,
|
|
20613
|
+
reason: "suspended"
|
|
20614
|
+
});
|
|
20615
|
+
continue;
|
|
20616
|
+
}
|
|
20617
|
+
this.streamOfflineReasonByBroker.delete(brokerId);
|
|
20618
|
+
this.demandSince.delete(brokerId);
|
|
20619
|
+
if (wasSleepSuppressed) this.logger.info("battery camera woke and delivers a stream — plain wake, no event", {
|
|
20620
|
+
tags: { deviceId },
|
|
20621
|
+
meta: { camStreamId }
|
|
20622
|
+
});
|
|
20623
|
+
if (this.deviceOutages.get(deviceId) !== void 0) {
|
|
20624
|
+
if (!cameBackOn.has(deviceId)) cameBackOn.set(deviceId, context);
|
|
20625
|
+
continue;
|
|
20626
|
+
}
|
|
20627
|
+
if (wasSleepSuppressed) {
|
|
20628
|
+
this.writeCameraStreamsSlice(deviceId, this.snapshotProfileSlots(deviceId));
|
|
20629
|
+
continue;
|
|
20630
|
+
}
|
|
20631
|
+
const reason = isStreamOutageReason(previousReason) ? "recovered" : previousReason === "suspended" ? "resumed" : "first-packet";
|
|
20632
|
+
this.emitStreamHealth(true, {
|
|
20633
|
+
...context,
|
|
20634
|
+
reason
|
|
20635
|
+
});
|
|
20636
|
+
}
|
|
20637
|
+
for (const [deviceId, context] of outageOpenedOn) this.announceDeviceOutage(deviceId, context);
|
|
20638
|
+
for (const [deviceId, context] of cameBackOn) this.settleDeviceOutage(deviceId, context);
|
|
20639
|
+
this.expireWakeOnlyOutages(now);
|
|
20640
|
+
}
|
|
20641
|
+
/** Record one stream as part of its device's outage (announced after the pass). */
|
|
20642
|
+
openStreamOutage(brokerId, context, reason, now, outageOpenedOn) {
|
|
20643
|
+
this.sleepSuppressedBrokers.delete(brokerId);
|
|
20644
|
+
this.streamOfflineReasonByBroker.set(brokerId, reason);
|
|
20645
|
+
this.deviceOutages.open(context.deviceId, context.camStreamId, reason, now);
|
|
20646
|
+
if (!outageOpenedOn.has(context.deviceId)) outageOpenedOn.set(context.deviceId, context);
|
|
20647
|
+
}
|
|
20648
|
+
markSleepSuppressed(brokerId, context, presence, lastPacketAt) {
|
|
20649
|
+
this.sleepSuppressedBrokers.add(brokerId);
|
|
20650
|
+
this.streamOfflineReasonByBroker.delete(brokerId);
|
|
20651
|
+
this.logger.info("stream quiet on a sleeping battery camera — not an outage", {
|
|
20652
|
+
tags: { deviceId: context.deviceId },
|
|
20653
|
+
meta: {
|
|
20654
|
+
camStreamId: context.camStreamId,
|
|
20655
|
+
presence,
|
|
20656
|
+
lastPacketAt
|
|
20657
|
+
}
|
|
20658
|
+
});
|
|
20659
|
+
this.writeCameraStreamsSlice(context.deviceId, this.snapshotProfileSlots(context.deviceId));
|
|
20660
|
+
}
|
|
20661
|
+
/**
|
|
20662
|
+
* The verdict on a quiet, non-idle stream. `'defer'` = the device's battery
|
|
20663
|
+
* class is still being asked of the registry; the edge is kept and judged
|
|
20664
|
+
* again. A class that could not be answered, or a slice never read, judges as
|
|
20665
|
+
* a mains camera — never as a sleeper (D49).
|
|
20666
|
+
*/
|
|
20667
|
+
judgeQuietStream(brokerId, deviceId, broker, now) {
|
|
20668
|
+
const probe = this.ensureBatteryProbe(deviceId, now);
|
|
20669
|
+
if (probe.state === "pending") return "defer";
|
|
20670
|
+
return judgeStaleStream({
|
|
20671
|
+
battery: probe.isBattery === true && probe.facts !== null ? { facts: probe.facts } : null,
|
|
20672
|
+
demanded: this.demandHeldPastBound(brokerId, broker, now),
|
|
20673
|
+
nowMs: now
|
|
20674
|
+
});
|
|
20675
|
+
}
|
|
20676
|
+
demandHeldPastBound(brokerId, broker, now) {
|
|
20677
|
+
if (!broker.hasLiveDemand()) {
|
|
20678
|
+
this.demandSince.delete(brokerId);
|
|
20679
|
+
return false;
|
|
20680
|
+
}
|
|
20681
|
+
const since = this.demandSince.get(brokerId) ?? now;
|
|
20682
|
+
this.demandSince.set(brokerId, since);
|
|
20683
|
+
return now - since >= WAKE_DEMAND_BOUND_MS;
|
|
20684
|
+
}
|
|
20685
|
+
/**
|
|
20686
|
+
* A broker that stays quiet, pass after pass. Three things can be true of it:
|
|
20687
|
+
* it was suppressed as a sleeper (the camera may have died, or a wake may
|
|
20688
|
+
* have been demanded since); it went idle while holding an outage open; or it
|
|
20689
|
+
* resumed from an idle suspend on a battery camera that never answered.
|
|
20690
|
+
*/
|
|
20691
|
+
rejudgeQuietBroker(brokerId, broker, context, now, outageOpenedOn) {
|
|
20692
|
+
const { deviceId } = context;
|
|
20693
|
+
const reason = this.streamOfflineReasonByBroker.get(brokerId);
|
|
20694
|
+
if (isStreamOutageReason(reason) && broker.suspended) {
|
|
20695
|
+
this.dropSuspendedOutage(brokerId, context);
|
|
20696
|
+
return;
|
|
20697
|
+
}
|
|
20698
|
+
const suppressed = this.sleepSuppressedBrokers.has(brokerId);
|
|
20699
|
+
const resumedQuiet = reason === "suspended" && !broker.suspended;
|
|
20700
|
+
if (!suppressed && !resumedQuiet) return;
|
|
20701
|
+
if (broker.suspended) {
|
|
20702
|
+
this.demandSince.delete(brokerId);
|
|
20703
|
+
return;
|
|
20704
|
+
}
|
|
20705
|
+
this.refreshBatteryFactsIfDue(deviceId, now);
|
|
20706
|
+
const probe = this.batteryProbe.get(deviceId);
|
|
20707
|
+
if (probe?.state !== "done" || probe.isBattery !== true || probe.facts === null) return;
|
|
20708
|
+
const verdict = judgeStaleStream({
|
|
20709
|
+
battery: { facts: probe.facts },
|
|
20710
|
+
demanded: this.demandHeldPastBound(brokerId, broker, now),
|
|
20711
|
+
nowMs: now
|
|
20712
|
+
});
|
|
20713
|
+
if (verdict.kind === "suppressed") return;
|
|
20714
|
+
this.logger.warn("quiet battery stream is now an outage", {
|
|
20715
|
+
tags: { deviceId },
|
|
20716
|
+
meta: {
|
|
20717
|
+
camStreamId: context.camStreamId,
|
|
20718
|
+
reason: verdict.reason
|
|
20719
|
+
}
|
|
20720
|
+
});
|
|
20721
|
+
this.openStreamOutage(brokerId, context, verdict.reason, now, outageOpenedOn);
|
|
20722
|
+
}
|
|
20723
|
+
/**
|
|
20724
|
+
* An outage stream whose last consumer left (the broker went idle). Nobody is
|
|
20725
|
+
* waiting for it any more, so the outage ends there — closed cleanly, with
|
|
20726
|
+
* the one online that lets every consumer clear its row (reason `suspended`,
|
|
20727
|
+
* which the notification gate discards).
|
|
20728
|
+
*/
|
|
20729
|
+
dropSuspendedOutage(brokerId, context) {
|
|
20730
|
+
const { deviceId, camStreamId } = context;
|
|
20731
|
+
this.demandSince.delete(brokerId);
|
|
20732
|
+
this.streamOfflineReasonByBroker.set(brokerId, "suspended");
|
|
20733
|
+
const outage = this.deviceOutages.get(deviceId);
|
|
20734
|
+
this.logger.info("stream outage dropped — the broker went idle, nobody is waiting", {
|
|
20735
|
+
tags: { deviceId },
|
|
20736
|
+
meta: { camStreamId }
|
|
20737
|
+
});
|
|
20738
|
+
if (!outage) return;
|
|
20739
|
+
outage.camStreamIds.delete(camStreamId);
|
|
20740
|
+
if (outage.camStreamIds.size > 0 || outage.wakeOnly) return;
|
|
20741
|
+
this.closeDeviceOutage(deviceId, outage, "suspended", context);
|
|
20742
|
+
}
|
|
20743
|
+
/** Close a device outage and, when it was announced, say so once (any online closes the rows). */
|
|
20744
|
+
closeDeviceOutage(deviceId, outage, reason, context) {
|
|
20745
|
+
this.deviceOutages.close(deviceId);
|
|
20746
|
+
if (!outage.announced) return;
|
|
20747
|
+
this.emitDeviceStreamHealth(true, deviceId, reason, /* @__PURE__ */ new Set(), context);
|
|
20748
|
+
}
|
|
20749
|
+
/** Wake-only outages end when the failed wakes stop (demand released). */
|
|
20750
|
+
expireWakeOnlyOutages(now) {
|
|
20751
|
+
for (const [deviceId, outage] of [...this.deviceOutages.entries()]) {
|
|
20752
|
+
if (!outage.wakeOnly || now - outage.lastSignalAt < WAKE_ONLY_OUTAGE_TTL_MS) continue;
|
|
20753
|
+
this.logger.info("wake failure outage ended — no further demand", {
|
|
20754
|
+
tags: { deviceId },
|
|
20755
|
+
meta: { reason: outage.reason }
|
|
20756
|
+
});
|
|
20757
|
+
this.closeDeviceOutage(deviceId, outage, "suspended", {
|
|
20256
20758
|
deviceId,
|
|
20759
|
+
brokerId: String(deviceId),
|
|
20760
|
+
sourceType: "battery-wake",
|
|
20761
|
+
lastPacketAt: 0
|
|
20762
|
+
});
|
|
20763
|
+
}
|
|
20764
|
+
}
|
|
20765
|
+
/** The device's battery probe; starts the read when there is none (or a retry is due). */
|
|
20766
|
+
ensureBatteryProbe(deviceId, now) {
|
|
20767
|
+
const existing = this.batteryProbe.get(deviceId);
|
|
20768
|
+
if (existing === void 0) {
|
|
20769
|
+
if (!this.api) {
|
|
20770
|
+
const none = {
|
|
20771
|
+
state: "done",
|
|
20772
|
+
isBattery: null,
|
|
20773
|
+
facts: null,
|
|
20774
|
+
readAt: now
|
|
20775
|
+
};
|
|
20776
|
+
this.batteryProbe.set(deviceId, none);
|
|
20777
|
+
return none;
|
|
20778
|
+
}
|
|
20779
|
+
const pending = {
|
|
20780
|
+
state: "pending",
|
|
20781
|
+
isBattery: null,
|
|
20782
|
+
facts: null,
|
|
20783
|
+
readAt: now
|
|
20784
|
+
};
|
|
20785
|
+
this.batteryProbe.set(deviceId, pending);
|
|
20786
|
+
this.probeBattery(deviceId);
|
|
20787
|
+
return pending;
|
|
20788
|
+
}
|
|
20789
|
+
if (existing.state === "done" && existing.isBattery === null && this.api && now - existing.readAt >= BATTERY_PROBE_RETRY_MS && !this.batteryProbeInFlight.has(deviceId)) this.probeBattery(deviceId);
|
|
20790
|
+
return existing;
|
|
20791
|
+
}
|
|
20792
|
+
async probeBattery(deviceId) {
|
|
20793
|
+
this.batteryProbeInFlight.add(deviceId);
|
|
20794
|
+
try {
|
|
20795
|
+
const isBattery = await this.isBatteryOperated(deviceId);
|
|
20796
|
+
const facts = isBattery === true ? await this.readBatterySleepFacts(deviceId) : null;
|
|
20797
|
+
const current = this.batteryProbe.get(deviceId);
|
|
20798
|
+
this.batteryProbe.set(deviceId, {
|
|
20799
|
+
state: "done",
|
|
20800
|
+
isBattery: current?.isBattery === true ? true : isBattery,
|
|
20801
|
+
facts: current?.facts ?? facts,
|
|
20802
|
+
readAt: Date.now()
|
|
20803
|
+
});
|
|
20804
|
+
} finally {
|
|
20805
|
+
this.batteryProbeInFlight.delete(deviceId);
|
|
20806
|
+
}
|
|
20807
|
+
}
|
|
20808
|
+
refreshBatteryFactsIfDue(deviceId, now) {
|
|
20809
|
+
const probe = this.batteryProbe.get(deviceId);
|
|
20810
|
+
if (probe?.state !== "done" || probe.isBattery !== true) return;
|
|
20811
|
+
if (now - probe.readAt < BATTERY_PROBE_RETRY_MS || this.batteryProbeInFlight.has(deviceId)) return;
|
|
20812
|
+
this.batteryProbeInFlight.add(deviceId);
|
|
20813
|
+
this.readBatterySleepFacts(deviceId).then((facts) => {
|
|
20814
|
+
const current = this.batteryProbe.get(deviceId);
|
|
20815
|
+
this.batteryProbe.set(deviceId, {
|
|
20816
|
+
state: "done",
|
|
20817
|
+
isBattery: true,
|
|
20818
|
+
facts: facts ?? current?.facts ?? null,
|
|
20819
|
+
readAt: Date.now()
|
|
20820
|
+
});
|
|
20821
|
+
}).finally(() => this.batteryProbeInFlight.delete(deviceId));
|
|
20822
|
+
}
|
|
20823
|
+
/** The device's `battery` slice as sleep facts, or `null` when it cannot be read. */
|
|
20824
|
+
async readBatterySleepFacts(deviceId) {
|
|
20825
|
+
const api = this.api;
|
|
20826
|
+
if (!api) return null;
|
|
20827
|
+
const slice = await api.deviceState.getCapSlice.query({
|
|
20828
|
+
deviceId,
|
|
20829
|
+
capName: batteryCapability.name
|
|
20830
|
+
}).catch(() => null);
|
|
20831
|
+
const parsed = BatteryStatusSchema.safeParse(slice);
|
|
20832
|
+
if (!parsed.success) return null;
|
|
20833
|
+
return {
|
|
20834
|
+
sleeping: parsed.data.sleeping,
|
|
20835
|
+
lastContactAt: parsed.data.lastContactAt
|
|
20836
|
+
};
|
|
20837
|
+
}
|
|
20838
|
+
recordBatterySleep(deviceId, status) {
|
|
20839
|
+
const previous = this.batteryProbe.get(deviceId);
|
|
20840
|
+
const facts = status && typeof status.sleeping === "boolean" ? {
|
|
20841
|
+
sleeping: status.sleeping,
|
|
20842
|
+
...typeof status.lastContactAt === "number" ? { lastContactAt: status.lastContactAt } : {}
|
|
20843
|
+
} : null;
|
|
20844
|
+
this.batteryProbe.set(deviceId, {
|
|
20845
|
+
state: "done",
|
|
20846
|
+
isBattery: true,
|
|
20847
|
+
facts: facts ?? previous?.facts ?? null,
|
|
20848
|
+
readAt: Date.now()
|
|
20849
|
+
});
|
|
20850
|
+
}
|
|
20851
|
+
/**
|
|
20852
|
+
* A released broker can no longer carry an outage. When it was the last
|
|
20853
|
+
* stream holding an ANNOUNCED outage open, the outage is closed with an
|
|
20854
|
+
* online (`broker-destroyed`, which the notification gate discards but every
|
|
20855
|
+
* ledger and alert card clears on) — otherwise the row stays open for ever.
|
|
20856
|
+
*/
|
|
20857
|
+
forgetReleasedStreamOutage(context) {
|
|
20858
|
+
const { deviceId, camStreamId } = context;
|
|
20859
|
+
const outage = this.deviceOutages.get(deviceId);
|
|
20860
|
+
if (!outage || !outage.camStreamIds.delete(camStreamId)) return;
|
|
20861
|
+
if (outage.camStreamIds.size > 0) return;
|
|
20862
|
+
this.logger.info("stream outage closed — its last stream was released", {
|
|
20863
|
+
tags: { deviceId },
|
|
20864
|
+
meta: {
|
|
20865
|
+
reason: outage.reason,
|
|
20257
20866
|
camStreamId,
|
|
20258
|
-
|
|
20259
|
-
|
|
20260
|
-
|
|
20261
|
-
|
|
20262
|
-
|
|
20867
|
+
announced: outage.announced
|
|
20868
|
+
}
|
|
20869
|
+
});
|
|
20870
|
+
this.closeDeviceOutage(deviceId, outage, "broker-destroyed", context);
|
|
20871
|
+
}
|
|
20872
|
+
/** The ONE `stream.offline` of a device outage, naming every stream it covers. */
|
|
20873
|
+
announceDeviceOutage(deviceId, context) {
|
|
20874
|
+
const outage = this.deviceOutages.get(deviceId);
|
|
20875
|
+
if (!outage) return;
|
|
20876
|
+
if (outage.announced) {
|
|
20877
|
+
this.logger.debug("stream outage joined an already announced one — no second event", {
|
|
20878
|
+
tags: { deviceId },
|
|
20879
|
+
meta: {
|
|
20880
|
+
camStreamIds: [...outage.camStreamIds],
|
|
20881
|
+
reason: outage.reason
|
|
20882
|
+
}
|
|
20263
20883
|
});
|
|
20884
|
+
return;
|
|
20885
|
+
}
|
|
20886
|
+
outage.announced = true;
|
|
20887
|
+
this.logger.warn("stream outage", {
|
|
20888
|
+
tags: { deviceId },
|
|
20889
|
+
meta: {
|
|
20890
|
+
reason: outage.reason,
|
|
20891
|
+
camStreamIds: [...outage.camStreamIds]
|
|
20892
|
+
}
|
|
20893
|
+
});
|
|
20894
|
+
this.emitDeviceStreamHealth(false, deviceId, outage.reason, outage.camStreamIds, context);
|
|
20895
|
+
}
|
|
20896
|
+
/**
|
|
20897
|
+
* A broker of a device in outage is delivering again. The device is back when
|
|
20898
|
+
* no stream of it is still down for an outage reason; then, and only then,
|
|
20899
|
+
* the single `stream.online` (`recovered`) goes out.
|
|
20900
|
+
*/
|
|
20901
|
+
settleDeviceOutage(deviceId, context) {
|
|
20902
|
+
const outage = this.deviceOutages.get(deviceId);
|
|
20903
|
+
if (!outage) return;
|
|
20904
|
+
for (const camStreamId of [...outage.camStreamIds]) {
|
|
20905
|
+
const brokerId = brokerIdFor(deviceId, camStreamId);
|
|
20906
|
+
if (!isStreamOutageReason(this.streamOfflineReasonByBroker.get(brokerId))) outage.camStreamIds.delete(camStreamId);
|
|
20264
20907
|
}
|
|
20908
|
+
if (outage.camStreamIds.size > 0) return;
|
|
20909
|
+
this.deviceOutages.close(deviceId);
|
|
20910
|
+
if (!outage.announced) return;
|
|
20911
|
+
this.logger.info("stream outage over — device delivers again", {
|
|
20912
|
+
tags: { deviceId },
|
|
20913
|
+
meta: {
|
|
20914
|
+
reason: outage.reason,
|
|
20915
|
+
outageMs: Date.now() - outage.since
|
|
20916
|
+
}
|
|
20917
|
+
});
|
|
20918
|
+
this.emitDeviceStreamHealth(true, deviceId, "recovered", new Set(context.camStreamId !== void 0 ? [context.camStreamId] : []), context);
|
|
20919
|
+
}
|
|
20920
|
+
/**
|
|
20921
|
+
* A wake somebody ASKED for did not produce a stream (the viewer's play
|
|
20922
|
+
* button, found no broker and a wake that settled nothing). This is the one
|
|
20923
|
+
* failure with no broker to carry it, so it opens the device outage itself.
|
|
20924
|
+
*/
|
|
20925
|
+
announceBatteryWakeFailure(deviceId, camStreamId, reason) {
|
|
20926
|
+
if (!this.eventBus) return;
|
|
20927
|
+
const published = [...this.cameraStreams.get(deviceId)?.keys() ?? []];
|
|
20928
|
+
const streamIds = camStreamId !== void 0 && published.includes(camStreamId) ? [camStreamId] : published;
|
|
20929
|
+
this.deviceOutages.openWake(deviceId, streamIds, reason, Date.now());
|
|
20930
|
+
this.announceDeviceOutage(deviceId, {
|
|
20931
|
+
deviceId,
|
|
20932
|
+
...streamIds[0] !== void 0 ? { camStreamId: streamIds[0] } : {},
|
|
20933
|
+
brokerId: streamIds[0] !== void 0 ? brokerIdFor(deviceId, streamIds[0]) : String(deviceId),
|
|
20934
|
+
sourceType: "battery-wake",
|
|
20935
|
+
lastPacketAt: 0
|
|
20936
|
+
});
|
|
20937
|
+
}
|
|
20938
|
+
emitDeviceStreamHealth(online, deviceId, reason, camStreamIds, context) {
|
|
20939
|
+
const ids = [...camStreamIds];
|
|
20940
|
+
const first = ids[0] ?? context.camStreamId;
|
|
20941
|
+
const assignments = this.assignments.get(deviceId)?.map ?? {};
|
|
20942
|
+
this.emitStreamHealth(online, {
|
|
20943
|
+
...context,
|
|
20944
|
+
deviceId,
|
|
20945
|
+
...first !== void 0 ? { camStreamId: first } : {},
|
|
20946
|
+
brokerId: first !== void 0 ? brokerIdFor(deviceId, first) : context.brokerId,
|
|
20947
|
+
profile: first !== void 0 ? findProfileForStream(assignments, first) : null,
|
|
20948
|
+
reason,
|
|
20949
|
+
camStreamIds: ids
|
|
20950
|
+
});
|
|
20265
20951
|
}
|
|
20266
20952
|
emitStreamHealth(online, payload) {
|
|
20267
20953
|
const bus = this.eventBus;
|
|
20268
20954
|
if (!bus) return;
|
|
20269
20955
|
const category = online ? "stream.online" : "stream.offline";
|
|
20956
|
+
const profile = payload.profile !== void 0 ? payload.profile : payload.camStreamId !== void 0 ? findProfileForStream(this.assignments.get(payload.deviceId)?.map ?? {}, payload.camStreamId) : null;
|
|
20270
20957
|
bus.emit({
|
|
20271
20958
|
id: `${category}-${payload.brokerId}-${Date.now()}`,
|
|
20272
20959
|
timestamp: /* @__PURE__ */ new Date(),
|
|
@@ -20275,7 +20962,10 @@ var StreamBrokerManager = class StreamBrokerManager {
|
|
|
20275
20962
|
type: "stream-broker",
|
|
20276
20963
|
id: payload.brokerId
|
|
20277
20964
|
},
|
|
20278
|
-
data:
|
|
20965
|
+
data: {
|
|
20966
|
+
...payload,
|
|
20967
|
+
profile
|
|
20968
|
+
}
|
|
20279
20969
|
});
|
|
20280
20970
|
this.writeCameraStreamsSlice(payload.deviceId, this.snapshotProfileSlots(payload.deviceId));
|
|
20281
20971
|
}
|
|
@@ -20378,6 +21068,7 @@ var StreamBrokerManager = class StreamBrokerManager {
|
|
|
20378
21068
|
const sourceBrokerId = brokerIdFor(deviceId, camStreamId);
|
|
20379
21069
|
const sourceBroker = this.brokers.get(sourceBrokerId);
|
|
20380
21070
|
if (!sourceBroker) continue;
|
|
21071
|
+
if (this.restreamGate(deviceId)) continue;
|
|
20381
21072
|
const existingToken = this.persistedTokens.get(profileBrokerId);
|
|
20382
21073
|
const warmUp = () => sourceBroker.onEncodedData(() => {}, RTSP_DESCRIBE_WARMUP_ATTRIBUTION);
|
|
20383
21074
|
const openPrestreamWindow = () => sourceBroker.openPrestreamWindow();
|
|
@@ -22037,11 +22728,7 @@ var StreamBrokerManager = class StreamBrokerManager {
|
|
|
22037
22728
|
}
|
|
22038
22729
|
this.brokers.set(brokerId, broker);
|
|
22039
22730
|
this.applyClipRetention(deviceId);
|
|
22040
|
-
|
|
22041
|
-
const warmUp = () => broker.onEncodedData(() => {}, RTSP_DESCRIBE_WARMUP_ATTRIBUTION);
|
|
22042
|
-
const openPrestreamWindow = () => broker.openPrestreamWindow();
|
|
22043
|
-
this.rtspServer.registerRestreamer(brokerId, broker.getRtspRestreamer(), warmUp, existingToken, openPrestreamWindow);
|
|
22044
|
-
if (!existingToken) this.rtspProvider.persistTokens();
|
|
22731
|
+
this.registerSourceRestreamer(brokerId, broker);
|
|
22045
22732
|
await this.fanOutRegistration(deviceId, brokerId, cam, broker);
|
|
22046
22733
|
this.syncProfileRestreamers(deviceId);
|
|
22047
22734
|
if (cam.kind === "derived") {
|
|
@@ -22050,6 +22737,28 @@ var StreamBrokerManager = class StreamBrokerManager {
|
|
|
22050
22737
|
}
|
|
22051
22738
|
}
|
|
22052
22739
|
/**
|
|
22740
|
+
* Register one source broker's RTSP restreamer with its persisted token (or a
|
|
22741
|
+
* freshly minted one). A camera whose tokens are held in an unreadable keychain
|
|
22742
|
+
* gets no EXTERNAL token, only the internal loopback-only alias.
|
|
22743
|
+
*/
|
|
22744
|
+
registerSourceRestreamer(brokerId, broker) {
|
|
22745
|
+
const deviceId = parseBrokerId(brokerId)?.deviceId;
|
|
22746
|
+
if (deviceId === void 0) return;
|
|
22747
|
+
if (this.restreamGate(deviceId)) {
|
|
22748
|
+
this.logger.warn("external restream URL withheld - the tokens are held but unreadable", {
|
|
22749
|
+
tags: { deviceId },
|
|
22750
|
+
meta: { brokerId }
|
|
22751
|
+
});
|
|
22752
|
+
this.rtspServer.registerInternalAlias(brokerId, broker.getRtspRestreamer(), () => broker.onEncodedData(() => {}, RTSP_DESCRIBE_WARMUP_ATTRIBUTION), () => broker.openPrestreamWindow());
|
|
22753
|
+
return;
|
|
22754
|
+
}
|
|
22755
|
+
const existingToken = this.persistedTokens.get(brokerId);
|
|
22756
|
+
const warmUp = () => broker.onEncodedData(() => {}, RTSP_DESCRIBE_WARMUP_ATTRIBUTION);
|
|
22757
|
+
const openPrestreamWindow = () => broker.openPrestreamWindow();
|
|
22758
|
+
this.rtspServer.registerRestreamer(brokerId, broker.getRtspRestreamer(), warmUp, existingToken, openPrestreamWindow);
|
|
22759
|
+
if (!existingToken) this.rtspProvider.persistTokens();
|
|
22760
|
+
}
|
|
22761
|
+
/**
|
|
22053
22762
|
* Tear down the broker for a `(deviceId, camStreamId)` pair. No-op
|
|
22054
22763
|
* when no broker exists. Called by `retractCameraStream` and by the
|
|
22055
22764
|
* teardown half of `restartBroker`.
|
|
@@ -22072,6 +22781,14 @@ var StreamBrokerManager = class StreamBrokerManager {
|
|
|
22072
22781
|
});
|
|
22073
22782
|
this.streamHealthByBroker.delete(brokerId);
|
|
22074
22783
|
this.streamOfflineReasonByBroker.delete(brokerId);
|
|
22784
|
+
this.sleepSuppressedBrokers.delete(brokerId);
|
|
22785
|
+
this.forgetReleasedStreamOutage({
|
|
22786
|
+
deviceId,
|
|
22787
|
+
camStreamId,
|
|
22788
|
+
brokerId,
|
|
22789
|
+
sourceType: broker.getActiveSourceType(),
|
|
22790
|
+
lastPacketAt: broker.getLastPacketAt()
|
|
22791
|
+
});
|
|
22075
22792
|
await broker.stop();
|
|
22076
22793
|
this.brokers.delete(brokerId);
|
|
22077
22794
|
this.sentryFrames.delete(brokerId);
|
|
@@ -22169,7 +22886,7 @@ var StreamBrokerManager = class StreamBrokerManager {
|
|
|
22169
22886
|
};
|
|
22170
22887
|
} else {
|
|
22171
22888
|
const sourceBrokerId = brokerIdFor(deviceId, request.camStreamId);
|
|
22172
|
-
const entry = this.rtspProvider.
|
|
22889
|
+
const entry = this.rtspProvider.getInternalEntry(sourceBrokerId, "127.0.0.1");
|
|
22173
22890
|
if (!entry) throw new Error(`derived source camStream loopback unavailable: ${sourceBrokerId}`);
|
|
22174
22891
|
loopbackUrl = entry.url;
|
|
22175
22892
|
release = async () => {};
|
|
@@ -38304,18 +39021,177 @@ function subscribeDeviceRemoval(subscribe, deps) {
|
|
|
38304
39021
|
});
|
|
38305
39022
|
}
|
|
38306
39023
|
//#endregion
|
|
39024
|
+
//#region src/stream-broker/device-removal-hook.ts
|
|
39025
|
+
/**
|
|
39026
|
+
* The stream-broker's `device-removal-hook` (D870).
|
|
39027
|
+
*
|
|
39028
|
+
* What the broker keeps PER CAMERA is a key of the camera's own settings
|
|
39029
|
+
* (`probeSnapshots`, `brokerProfile`, `rtspTokens` - see `broker-device-state.ts`),
|
|
39030
|
+
* and the central device delete (D865) clears those for every addon. What is
|
|
39031
|
+
* left is the broker's live state and its one addon-level map keyed by camera,
|
|
39032
|
+
* `deviceOverrides`: the hook retracts the camera's streams, purges its slot
|
|
39033
|
+
* assignments and drops its override entry. Idempotent - a camera with nothing
|
|
39034
|
+
* left answers zero counts.
|
|
39035
|
+
*/
|
|
39036
|
+
function createBrokerRemovalHook(deps) {
|
|
39037
|
+
return {
|
|
39038
|
+
onDeviceRemoved: async (input) => {
|
|
39039
|
+
const { deviceId } = input;
|
|
39040
|
+
try {
|
|
39041
|
+
await deps.retractDevice(deviceId);
|
|
39042
|
+
} catch (err) {
|
|
39043
|
+
deps.logger.warn("stream-broker: retract of a deleted device failed", {
|
|
39044
|
+
tags: { deviceId },
|
|
39045
|
+
meta: { error: errMsg(err) }
|
|
39046
|
+
});
|
|
39047
|
+
throw err;
|
|
39048
|
+
}
|
|
39049
|
+
const overrides = deps.dropDeviceOverride(deviceId) ? 1 : 0;
|
|
39050
|
+
if (overrides > 0) deps.logger.info("stream-broker: removed the override of a deleted device", {
|
|
39051
|
+
tags: { deviceId },
|
|
39052
|
+
meta: { deletedAt: input.deletedAt }
|
|
39053
|
+
});
|
|
39054
|
+
return { counts: { "device-overrides": overrides } };
|
|
39055
|
+
},
|
|
39056
|
+
listDeviceData: async () => deps.overrideDeviceIds().filter((deviceId) => Number.isInteger(deviceId) && deviceId > 0).map((deviceId) => ({
|
|
39057
|
+
store: "device-overrides",
|
|
39058
|
+
deviceId,
|
|
39059
|
+
count: 1
|
|
39060
|
+
}))
|
|
39061
|
+
};
|
|
39062
|
+
}
|
|
39063
|
+
/** Classify every held camera against the keychain: wait for it, or give up on the value. */
|
|
39064
|
+
async function classifyHeld(held, secrets) {
|
|
39065
|
+
const refs = /* @__PURE__ */ new Map();
|
|
39066
|
+
for (const [deviceId, stored] of held) if (isSecretMarker(stored)) refs.set(deviceId, stored.$secret);
|
|
39067
|
+
if (refs.size === 0) return [];
|
|
39068
|
+
const resolved = await secrets.resolve([...refs.values()]);
|
|
39069
|
+
const out = [];
|
|
39070
|
+
for (const [deviceId, ref] of refs) {
|
|
39071
|
+
const entry = resolved[ref];
|
|
39072
|
+
if (entry === void 0 || entry.kind === "unavailable") out.push({
|
|
39073
|
+
deviceId,
|
|
39074
|
+
ref,
|
|
39075
|
+
kind: "unavailable",
|
|
39076
|
+
reason: entry?.kind === "unavailable" ? entry.reason : null
|
|
39077
|
+
});
|
|
39078
|
+
else if (entry.kind === "missing") out.push({
|
|
39079
|
+
deviceId,
|
|
39080
|
+
ref,
|
|
39081
|
+
kind: "missing",
|
|
39082
|
+
reason: null
|
|
39083
|
+
});
|
|
39084
|
+
else if (entry.kind === "forbidden") out.push({
|
|
39085
|
+
deviceId,
|
|
39086
|
+
ref,
|
|
39087
|
+
kind: "unavailable",
|
|
39088
|
+
reason: "forbidden"
|
|
39089
|
+
});
|
|
39090
|
+
}
|
|
39091
|
+
return out;
|
|
39092
|
+
}
|
|
39093
|
+
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.";
|
|
39094
|
+
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.`;
|
|
39095
|
+
var TokenRecovery = class {
|
|
39096
|
+
deps;
|
|
39097
|
+
/** Cameras alerted as unavailable: the alert is cleared when the value resolves. */
|
|
39098
|
+
waiting = /* @__PURE__ */ new Set();
|
|
39099
|
+
/** Consecutive `missing` reads per camera (D49: two before the value is given up). */
|
|
39100
|
+
missingReads = /* @__PURE__ */ new Map();
|
|
39101
|
+
timer = null;
|
|
39102
|
+
running = false;
|
|
39103
|
+
constructor(deps) {
|
|
39104
|
+
this.deps = deps;
|
|
39105
|
+
}
|
|
39106
|
+
/** Cameras currently blocked. */
|
|
39107
|
+
blocked() {
|
|
39108
|
+
return [...this.deps.mirror.heldDevices().keys()].toSorted((a, b) => a - b);
|
|
39109
|
+
}
|
|
39110
|
+
/** Check now, then on a timer while any camera is still held. */
|
|
39111
|
+
start() {
|
|
39112
|
+
if (this.timer !== null) return;
|
|
39113
|
+
this.tick();
|
|
39114
|
+
this.timer = setInterval(() => void this.tick(), this.deps.recheckMs ?? 3e4);
|
|
39115
|
+
this.timer.unref?.();
|
|
39116
|
+
}
|
|
39117
|
+
stop() {
|
|
39118
|
+
if (this.timer !== null) clearInterval(this.timer);
|
|
39119
|
+
this.timer = null;
|
|
39120
|
+
}
|
|
39121
|
+
/** One scheduled / change-triggered check; never overlaps itself, never throws. */
|
|
39122
|
+
async tick() {
|
|
39123
|
+
if (this.running) return;
|
|
39124
|
+
this.running = true;
|
|
39125
|
+
try {
|
|
39126
|
+
await this.check();
|
|
39127
|
+
} catch (err) {
|
|
39128
|
+
this.deps.logger.warn("restream-token re-check failed", { meta: { error: err instanceof Error ? err.message : String(err) } });
|
|
39129
|
+
} finally {
|
|
39130
|
+
this.running = false;
|
|
39131
|
+
}
|
|
39132
|
+
if (this.blocked().length === 0 && this.waiting.size === 0) this.stop();
|
|
39133
|
+
}
|
|
39134
|
+
/** See the module doc. Idempotent. */
|
|
39135
|
+
async check() {
|
|
39136
|
+
const before = new Set(this.deps.mirror.heldDevices().keys());
|
|
39137
|
+
if (before.size === 0 && this.waiting.size === 0) return;
|
|
39138
|
+
const tokens = await this.deps.mirror.load();
|
|
39139
|
+
const now = new Set(this.deps.mirror.heldDevices().keys());
|
|
39140
|
+
const classified = await classifyHeld(this.deps.mirror.heldDevices(), this.deps.secrets);
|
|
39141
|
+
const released = [];
|
|
39142
|
+
for (const camera of classified) {
|
|
39143
|
+
if (camera.kind === "missing") {
|
|
39144
|
+
const reads = (this.missingReads.get(camera.deviceId) ?? 0) + 1;
|
|
39145
|
+
this.missingReads.set(camera.deviceId, reads);
|
|
39146
|
+
if (reads < 2) continue;
|
|
39147
|
+
this.deps.mirror.release(camera.deviceId);
|
|
39148
|
+
this.missingReads.delete(camera.deviceId);
|
|
39149
|
+
if (this.waiting.delete(camera.deviceId)) await this.deps.alerts.clear(camera.deviceId);
|
|
39150
|
+
released.push(camera.deviceId);
|
|
39151
|
+
continue;
|
|
39152
|
+
}
|
|
39153
|
+
this.missingReads.delete(camera.deviceId);
|
|
39154
|
+
if (this.waiting.has(camera.deviceId)) continue;
|
|
39155
|
+
this.waiting.add(camera.deviceId);
|
|
39156
|
+
this.deps.logger.error("restream tokens held but not readable", {
|
|
39157
|
+
tags: { deviceId: camera.deviceId },
|
|
39158
|
+
meta: {
|
|
39159
|
+
reason: camera.reason,
|
|
39160
|
+
ref: camera.ref
|
|
39161
|
+
}
|
|
39162
|
+
});
|
|
39163
|
+
await this.deps.alerts.raise({
|
|
39164
|
+
deviceId: camera.deviceId,
|
|
39165
|
+
kind: "unavailable",
|
|
39166
|
+
message: UNAVAILABLE_TEXT
|
|
39167
|
+
});
|
|
39168
|
+
}
|
|
39169
|
+
const recovered = [...before].filter((id) => !now.has(id) && !released.includes(id));
|
|
39170
|
+
for (const deviceId of recovered) if (this.waiting.delete(deviceId)) await this.deps.alerts.clear(deviceId);
|
|
39171
|
+
if (recovered.length > 0) this.deps.logger.info("restream tokens readable again", { meta: { cameras: recovered.length } });
|
|
39172
|
+
const cameras = [...recovered, ...released];
|
|
39173
|
+
if (cameras.length > 0) await this.deps.onRegister(tokens, cameras);
|
|
39174
|
+
for (const deviceId of released) {
|
|
39175
|
+
this.deps.logger.error("restream tokens lost - new URLs issued", { tags: { deviceId } });
|
|
39176
|
+
await this.deps.alerts.raise({
|
|
39177
|
+
deviceId,
|
|
39178
|
+
kind: "missing",
|
|
39179
|
+
message: issuedText(deviceId)
|
|
39180
|
+
});
|
|
39181
|
+
}
|
|
39182
|
+
}
|
|
39183
|
+
};
|
|
39184
|
+
//#endregion
|
|
38307
39185
|
//#region src/stream-broker/addon.ts
|
|
38308
39186
|
/**
|
|
38309
|
-
* Hydrate the persisted RTSP restream state. Tokens come from
|
|
38310
|
-
*
|
|
38311
|
-
*
|
|
38312
|
-
*
|
|
38313
|
-
*
|
|
39187
|
+
* Hydrate the persisted RTSP restream state. Tokens come from the cameras' own
|
|
39188
|
+
* settings; `rtspEnabled` is still a key of the settings row. An unreadable
|
|
39189
|
+
* token set THROWS (the caller refuses to write tokens afterwards - a
|
|
39190
|
+
* replacement minted over a token that was merely unreadable would turn every
|
|
39191
|
+
* external NVR URL into a 404), and an empty set keeps first-boot silence.
|
|
38314
39192
|
*/
|
|
38315
39193
|
async function hydratePersistedRtspState(deps) {
|
|
38316
|
-
const
|
|
38317
|
-
const tokens = /* @__PURE__ */ new Map();
|
|
38318
|
-
for (const [brokerId, row] of rows) tokens.set(brokerId, row.token);
|
|
39194
|
+
const tokens = flattenByDevice(await deps.tokensMirror.load());
|
|
38319
39195
|
if (tokens.size > 0) {
|
|
38320
39196
|
deps.loadTokens(tokens);
|
|
38321
39197
|
deps.logger.info("Loaded persisted RTSP tokens", { meta: { tokenCount: tokens.size } });
|
|
@@ -38384,6 +39260,8 @@ var PROBE_SNAPSHOTS_PERSIST_INTERVAL_MS = 6e4;
|
|
|
38384
39260
|
* the sample as absent (the RTCP Receiver-Report path drives the ladder anyway). */
|
|
38385
39261
|
var NETWORK_QUALITY_STATS_TIMEOUT_MS = 2e3;
|
|
38386
39262
|
var StreamBrokerAddon = class extends BaseAddon {
|
|
39263
|
+
/** The restream tokens of a camera are a per-device setting held in the keychain (D870). */
|
|
39264
|
+
secretStatePaths = ["rtspTokens"];
|
|
38387
39265
|
brokerManager = null;
|
|
38388
39266
|
/** The node's pinned ffmpeg, mirrored here so the recorded-playback demuxers
|
|
38389
39267
|
* (segment-demux/segment-audio) spawn the same binary as the transcode
|
|
@@ -38440,6 +39318,7 @@ var StreamBrokerAddon = class extends BaseAddon {
|
|
|
38440
39318
|
* restarts). Loaded on activate, flushed throttled from the metrics loop and
|
|
38441
39319
|
* once on shutdown. */
|
|
38442
39320
|
probeSnapshots = null;
|
|
39321
|
+
removalHook = null;
|
|
38443
39322
|
lastProbePersistAt = 0;
|
|
38444
39323
|
constructor() {
|
|
38445
39324
|
super({
|
|
@@ -38502,24 +39381,26 @@ var StreamBrokerAddon = class extends BaseAddon {
|
|
|
38502
39381
|
logger: this.ctx.logger
|
|
38503
39382
|
});
|
|
38504
39383
|
await this.ffmpegWiring.load();
|
|
38505
|
-
|
|
38506
|
-
await this.purgeFlattenedSettingsKeys();
|
|
39384
|
+
const deviceSettings = this.deviceSettings();
|
|
38507
39385
|
const rtspProvider = this.brokerManager.getRtspRestreamProvider();
|
|
38508
39386
|
const brokerManager = this.brokerManager;
|
|
38509
|
-
const rtspTokens =
|
|
39387
|
+
const rtspTokens = rtspTokensMirror(deviceSettings, this.ctx.logger);
|
|
38510
39388
|
const rtspEnabledState = this.state("rtspEnabled", RtspEnabledSchema, {});
|
|
38511
39389
|
try {
|
|
38512
39390
|
await hydratePersistedRtspState({
|
|
38513
|
-
|
|
39391
|
+
tokensMirror: rtspTokens,
|
|
38514
39392
|
enabledState: rtspEnabledState,
|
|
38515
39393
|
loadTokens: (tokens) => brokerManager.loadPersistedTokens(tokens),
|
|
38516
39394
|
loadEnabled: (states) => rtspProvider.loadPersistedEnabled(states),
|
|
38517
39395
|
logger: this.ctx.logger
|
|
38518
39396
|
});
|
|
38519
|
-
} catch {
|
|
39397
|
+
} catch (err) {
|
|
39398
|
+
this.ctx.logger.warn("Failed to load persisted RTSP state - tokens will not be written", { meta: { error: errMsg(err) } });
|
|
39399
|
+
}
|
|
39400
|
+
this.startTokenRecovery(rtspTokens, brokerManager);
|
|
38520
39401
|
rtspProvider.setTokenPersister(async (tokens) => {
|
|
38521
39402
|
try {
|
|
38522
|
-
|
|
39403
|
+
await rtspTokens.merge(mergeRtspTokens(rtspTokens, tokens));
|
|
38523
39404
|
this.ctx.logger.info("Persisted RTSP tokens", { meta: { current: tokens.size } });
|
|
38524
39405
|
} catch (err) {
|
|
38525
39406
|
this.ctx.logger.warn("Failed to persist RTSP tokens", { meta: { error: errMsg(err) } });
|
|
@@ -38552,24 +39433,22 @@ var StreamBrokerAddon = class extends BaseAddon {
|
|
|
38552
39433
|
this.ctx.logger.warn("Failed to persist device overrides", { meta: { error: errMsg(err) } });
|
|
38553
39434
|
});
|
|
38554
39435
|
});
|
|
38555
|
-
const profileMap =
|
|
39436
|
+
const profileMap = profileMapMirror(deviceSettings, this.ctx.logger);
|
|
38556
39437
|
const profileMapPersister = (assignments) => {
|
|
38557
39438
|
const next = /* @__PURE__ */ new Map();
|
|
38558
|
-
for (const [deviceId, assignment] of assignments) next.set(
|
|
39439
|
+
for (const [deviceId, assignment] of assignments) next.set(deviceId, {
|
|
38559
39440
|
map: { ...assignment.map },
|
|
38560
39441
|
auto: assignment.auto
|
|
38561
39442
|
});
|
|
38562
|
-
profileMap.
|
|
39443
|
+
profileMap.replace(next).catch((err) => {
|
|
38563
39444
|
this.ctx.logger.warn("Failed to persist profile map", { meta: { error: errMsg(err) } });
|
|
38564
39445
|
});
|
|
38565
39446
|
};
|
|
38566
39447
|
this.brokerManager.setProfileMapPersister(profileMapPersister);
|
|
38567
39448
|
try {
|
|
38568
|
-
const rows = await profileMap.
|
|
39449
|
+
const rows = await profileMap.load();
|
|
38569
39450
|
const loaded = /* @__PURE__ */ new Map();
|
|
38570
|
-
for (const [
|
|
38571
|
-
const deviceId = Number(rawKey);
|
|
38572
|
-
if (!Number.isInteger(deviceId)) continue;
|
|
39451
|
+
for (const [deviceId, val] of rows) {
|
|
38573
39452
|
const map = {};
|
|
38574
39453
|
for (const profile of PROFILE_KEYS) {
|
|
38575
39454
|
const v = val.map[profile];
|
|
@@ -38585,18 +39464,18 @@ var StreamBrokerAddon = class extends BaseAddon {
|
|
|
38585
39464
|
} catch (err) {
|
|
38586
39465
|
this.ctx.logger.warn("Failed to load profile map", { meta: { error: errMsg(err) } });
|
|
38587
39466
|
}
|
|
38588
|
-
const probeSnapshots =
|
|
39467
|
+
const probeSnapshots = probeSnapshotsMirror(deviceSettings, this.ctx.logger);
|
|
38589
39468
|
this.probeSnapshots = probeSnapshots;
|
|
38590
39469
|
try {
|
|
38591
|
-
const rows = await probeSnapshots.
|
|
38592
|
-
const blob = Object.fromEntries(rows);
|
|
39470
|
+
const rows = await probeSnapshots.load();
|
|
39471
|
+
const blob = Object.fromEntries(flattenByDevice(rows));
|
|
38593
39472
|
this.brokerManager.loadProbeSnapshots(blob);
|
|
38594
|
-
this.ctx.logger.info("Loaded persisted probe snapshots", { meta: { streamCount:
|
|
39473
|
+
this.ctx.logger.info("Loaded persisted probe snapshots", { meta: { streamCount: Object.keys(blob).length } });
|
|
38595
39474
|
} catch (err) {
|
|
38596
39475
|
this.ctx.logger.warn("Failed to load probe snapshots", { meta: { error: errMsg(err) } });
|
|
38597
39476
|
}
|
|
38598
39477
|
this.brokerManager.setDeviceHardwareForgetHook(async (deviceId) => {
|
|
38599
|
-
const rtspTokenRowsDeleted = await rtspTokens.
|
|
39478
|
+
const rtspTokenRowsDeleted = await rtspTokens.remove(deviceId) ? 1 : 0;
|
|
38600
39479
|
this.flushProbeSnapshots(Date.now(), true);
|
|
38601
39480
|
return { rtspTokenRowsDeleted };
|
|
38602
39481
|
});
|
|
@@ -38854,6 +39733,14 @@ var StreamBrokerAddon = class extends BaseAddon {
|
|
|
38854
39733
|
} catch (err) {
|
|
38855
39734
|
this.ctx.logger.warn("stream-broker: client-log ingest failed to serve", { meta: { error: errMsg(err) } });
|
|
38856
39735
|
}
|
|
39736
|
+
this.removalHook = createBrokerRemovalHook({
|
|
39737
|
+
logger: this.ctx.logger,
|
|
39738
|
+
retractDevice: async (deviceId) => {
|
|
39739
|
+
await retractRemovedDevice(deviceId, this.deviceRemovalDeps());
|
|
39740
|
+
},
|
|
39741
|
+
dropDeviceOverride: (deviceId) => brokerManager.dropDeviceOverride(deviceId),
|
|
39742
|
+
overrideDeviceIds: () => brokerManager.listDeviceOverrideIds()
|
|
39743
|
+
});
|
|
38857
39744
|
return [
|
|
38858
39745
|
{
|
|
38859
39746
|
capability: streamBrokerCapability,
|
|
@@ -38872,9 +39759,90 @@ var StreamBrokerAddon = class extends BaseAddon {
|
|
|
38872
39759
|
provider: widgetsProvider
|
|
38873
39760
|
},
|
|
38874
39761
|
logChannelsRegistration,
|
|
38875
|
-
loadContributionRegistration
|
|
39762
|
+
loadContributionRegistration,
|
|
39763
|
+
{
|
|
39764
|
+
capability: deviceRemovalHookCapability,
|
|
39765
|
+
provider: this.removalHook
|
|
39766
|
+
}
|
|
38876
39767
|
];
|
|
38877
39768
|
}
|
|
39769
|
+
/**
|
|
39770
|
+
* Watch the cameras whose restream tokens are held but unreadable (D870): no
|
|
39771
|
+
* restream URL is registered for them (the manager's gate), an alert says so,
|
|
39772
|
+
* and when a value resolves ONLY that camera's restreamers are registered with
|
|
39773
|
+
* the stored tokens - no teardown. A lost secret is released after two
|
|
39774
|
+
* consistent reads and gets new, persisted tokens.
|
|
39775
|
+
*/
|
|
39776
|
+
startTokenRecovery(mirror, brokerManager) {
|
|
39777
|
+
const secrets = this.ctx.secrets;
|
|
39778
|
+
if (secrets === void 0) {
|
|
39779
|
+
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 } });
|
|
39780
|
+
return;
|
|
39781
|
+
}
|
|
39782
|
+
brokerManager.setRestreamGate((deviceId) => mirror.isBlocked(deviceId));
|
|
39783
|
+
if (mirror.heldDevices().size === 0) return;
|
|
39784
|
+
const alertId = (deviceId) => `stream-broker-tokens:${deviceId}`;
|
|
39785
|
+
const warn = (what) => (err) => this.ctx.logger.warn(what, { meta: { error: errMsg(err) } });
|
|
39786
|
+
const recovery = new TokenRecovery({
|
|
39787
|
+
mirror,
|
|
39788
|
+
secrets,
|
|
39789
|
+
logger: this.ctx.logger,
|
|
39790
|
+
alerts: {
|
|
39791
|
+
raise: async ({ deviceId, kind, message }) => {
|
|
39792
|
+
const now = Date.now();
|
|
39793
|
+
await this.ctx.api.alerts.emit.mutate({
|
|
39794
|
+
id: alertId(deviceId),
|
|
39795
|
+
category: "streams",
|
|
39796
|
+
severity: "warning",
|
|
39797
|
+
title: kind === "missing" ? "New RTSP restream URLs issued" : "RTSP restream URLs unavailable",
|
|
39798
|
+
message,
|
|
39799
|
+
status: "active",
|
|
39800
|
+
read: false,
|
|
39801
|
+
createdAt: now,
|
|
39802
|
+
updatedAt: now,
|
|
39803
|
+
source: {
|
|
39804
|
+
type: "device",
|
|
39805
|
+
id: String(deviceId)
|
|
39806
|
+
},
|
|
39807
|
+
metadata: {
|
|
39808
|
+
deviceId,
|
|
39809
|
+
kind
|
|
39810
|
+
}
|
|
39811
|
+
}).catch(warn("could not raise the restream-token alert"));
|
|
39812
|
+
},
|
|
39813
|
+
clear: async (deviceId) => {
|
|
39814
|
+
await this.ctx.api.alerts.update.mutate({
|
|
39815
|
+
alertId: alertId(deviceId),
|
|
39816
|
+
patch: {
|
|
39817
|
+
status: "completed",
|
|
39818
|
+
updatedAt: Date.now()
|
|
39819
|
+
}
|
|
39820
|
+
}).catch(warn("could not clear the restream-token alert"));
|
|
39821
|
+
}
|
|
39822
|
+
},
|
|
39823
|
+
onRegister: async (tokens, cameras) => {
|
|
39824
|
+
brokerManager.loadPersistedTokens(flattenByDevice(tokens));
|
|
39825
|
+
for (const deviceId of cameras) brokerManager.registerRestreamersForDevice(deviceId);
|
|
39826
|
+
}
|
|
39827
|
+
});
|
|
39828
|
+
recovery.start();
|
|
39829
|
+
const unsubscribe = secrets.onChange((ref) => {
|
|
39830
|
+
if (ref.startsWith("secret://stream-broker/device/")) recovery.tick();
|
|
39831
|
+
});
|
|
39832
|
+
this.ctx.addDisposer(() => {
|
|
39833
|
+
recovery.stop();
|
|
39834
|
+
unsubscribe();
|
|
39835
|
+
});
|
|
39836
|
+
}
|
|
39837
|
+
async onHubReachable() {
|
|
39838
|
+
const removalHook = this.removalHook;
|
|
39839
|
+
if (removalHook === null) return;
|
|
39840
|
+
this.ctx.addDisposer(armRemovalReconcile({
|
|
39841
|
+
api: this.ctx.api,
|
|
39842
|
+
logger: this.ctx.logger,
|
|
39843
|
+
remove: (input) => removalHook.onDeviceRemoved(input)
|
|
39844
|
+
}));
|
|
39845
|
+
}
|
|
38878
39846
|
async onShutdown() {
|
|
38879
39847
|
this.logChannels?.stop();
|
|
38880
39848
|
this.logChannels = null;
|
|
@@ -38975,41 +39943,14 @@ var StreamBrokerAddon = class extends BaseAddon {
|
|
|
38975
39943
|
this.flushProbeSnapshots(timestamp, false);
|
|
38976
39944
|
}
|
|
38977
39945
|
/**
|
|
38978
|
-
*
|
|
38979
|
-
*
|
|
38980
|
-
*
|
|
38981
|
-
*
|
|
38982
|
-
* Not swallowed: a broker whose `stream-broker:rtsp-tokens` is undeclared
|
|
38983
|
-
* mints a new token for every stream and every external NVR URL in the
|
|
38984
|
-
* installation goes dead. Crashing loudly is bounded by `CrashSupervisor`
|
|
38985
|
-
* (D6); silently reissuing tokens is not.
|
|
38986
|
-
*/
|
|
38987
|
-
async declareRowCollections() {
|
|
38988
|
-
const store = this.ctx.api.settingsStore;
|
|
38989
|
-
const logger = this.ctx.logger;
|
|
38990
|
-
await probeSnapshotsStore(store, logger).declare();
|
|
38991
|
-
await rtspTokensStore(store, logger).declare();
|
|
38992
|
-
await profileMapStore(store, logger).declare();
|
|
38993
|
-
}
|
|
38994
|
-
/**
|
|
38995
|
-
* Drop the flattened keys from `stream-broker:addon-settings` / `root`, each
|
|
38996
|
-
* gated on its own successor collection being non-empty. `rtspEnabled` and
|
|
38997
|
-
* `deviceOverrides` stay — see `broker-row-stores.ts` for why.
|
|
38998
|
-
*
|
|
38999
|
-
* Best-effort: a purge that throws must not take the broker down. The keys
|
|
39000
|
-
* are dead weight, not a fault.
|
|
39946
|
+
* The per-device settings, as the broker persists through them. Resolved
|
|
39947
|
+
* reads/writes (keychain markers never reach this code), and a context with
|
|
39948
|
+
* no settings is a broker that cannot start.
|
|
39001
39949
|
*/
|
|
39002
|
-
|
|
39003
|
-
|
|
39004
|
-
|
|
39005
|
-
|
|
39006
|
-
addonId: STREAM_BROKER_ADDON_ID,
|
|
39007
|
-
logger: this.ctx.logger,
|
|
39008
|
-
specs: STREAM_BROKER_RETIRED_ROOT_KEYS
|
|
39009
|
-
});
|
|
39010
|
-
} catch (err) {
|
|
39011
|
-
this.ctx.logger.warn("stream-broker: retiring the flattened settings keys failed", { meta: { error: errMsg(err) } });
|
|
39012
|
-
}
|
|
39950
|
+
deviceSettings() {
|
|
39951
|
+
const settings = this.ctx.settings;
|
|
39952
|
+
if (settings === void 0) throw new Error("stream-broker: device settings are not available");
|
|
39953
|
+
return settings;
|
|
39013
39954
|
}
|
|
39014
39955
|
/**
|
|
39015
39956
|
* Persist the manager's probe map to the durable handle. Throttled to
|
|
@@ -39022,7 +39963,7 @@ var StreamBrokerAddon = class extends BaseAddon {
|
|
|
39022
39963
|
if (!store || !manager) return;
|
|
39023
39964
|
if (!force && now - this.lastProbePersistAt < PROBE_SNAPSHOTS_PERSIST_INTERVAL_MS) return;
|
|
39024
39965
|
this.lastProbePersistAt = now;
|
|
39025
|
-
store.
|
|
39966
|
+
store.replace(groupByDevice(manager.getProbeSnapshots())).catch((err) => {
|
|
39026
39967
|
this.ctx.logger.warn("Failed to persist probe snapshots", { meta: { error: errMsg(err) } });
|
|
39027
39968
|
});
|
|
39028
39969
|
}
|