@camstack/addon-provider-rtsp 1.1.18 → 1.1.20

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/dist/addon.js +402 -232
  2. package/dist/addon.mjs +379 -232
  3. package/package.json +1 -1
package/dist/addon.js CHANGED
@@ -1,4 +1,28 @@
1
1
  Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
+ //#region \0rolldown/runtime.js
3
+ var __create = Object.create;
4
+ var __defProp = Object.defineProperty;
5
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
6
+ var __getOwnPropNames = Object.getOwnPropertyNames;
7
+ var __getProtoOf = Object.getPrototypeOf;
8
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
9
+ var __copyProps = (to, from, except, desc) => {
10
+ if (from && typeof from === "object" || typeof from === "function") for (var keys = __getOwnPropNames(from), i = 0, n = keys.length, key; i < n; i++) {
11
+ key = keys[i];
12
+ if (!__hasOwnProp.call(to, key) && key !== except) __defProp(to, key, {
13
+ get: ((k) => from[k]).bind(null, key),
14
+ enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable
15
+ });
16
+ }
17
+ return to;
18
+ };
19
+ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", {
20
+ value: mod,
21
+ enumerable: true
22
+ }) : target, mod));
23
+ //#endregion
24
+ let node_net = require("node:net");
25
+ node_net = __toESM(node_net);
2
26
  //#region ../../node_modules/zod/v4/core/core.js
3
27
  var _a$1;
4
28
  function $constructor(name, initializer, params) {
@@ -4642,7 +4666,7 @@ function preprocess(fn, schema) {
4642
4666
  });
4643
4667
  }
4644
4668
  //#endregion
4645
- //#region ../types/dist/sleep-CZDdRBua.mjs
4669
+ //#region ../types/dist/sleep-Cc14_yxc.mjs
4646
4670
  var EventCategory = /* @__PURE__ */ function(EventCategory) {
4647
4671
  EventCategory["SystemBoot"] = "system.boot";
4648
4672
  EventCategory["SystemAddonsReady"] = "system.addons-ready";
@@ -4828,6 +4852,18 @@ var EventCategory = /* @__PURE__ */ function(EventCategory) {
4828
4852
  */
4829
4853
  EventCategory["PipelineCameraUpdated"] = "pipeline.camera-updated";
4830
4854
  /**
4855
+ * The cluster camera-source OWNER changed (`clusterRoles.ingestNode`).
4856
+ * Emitted by addon-pipeline-orchestrator whenever it (re)derives node
4857
+ * capabilities — at boot, on agent online/offline, and on an ingest-node
4858
+ * flip. Carries the resolved `ownerNodeId`. The stream-broker consumes it to
4859
+ * keep its ingest-owner-gate decision current WITHOUT a per-`ensureBroker`
4860
+ * cross-process `getIngestOwner` query (push the authority's decision instead
4861
+ * of polling it on the hot path). Idempotent state — re-emitted on every
4862
+ * topology change, so a dropped event self-heals on the next one (plus the
4863
+ * broker's long backstop reconcile query).
4864
+ */
4865
+ EventCategory["PipelineIngestOwnerChanged"] = "pipeline.ingest-owner-changed";
4866
+ /**
4831
4867
  * Periodic snapshot of per-node pipeline-runner load
4832
4868
  * (`RunnerLocalLoad`). Emitted ~1Hz by every runner so UI dashboards
4833
4869
  * subscribe instead of polling `pipelineRunner.getLocalLoad`.
@@ -6911,6 +6947,36 @@ var ModelFormatsSchema = object({
6911
6947
  tflite: ModelFormatEntrySchema.optional(),
6912
6948
  pt: ModelFormatEntrySchema.optional()
6913
6949
  });
6950
+ /**
6951
+ * Variant-selector grouping axes. Shared by the full `ModelCatalogEntry` and by
6952
+ * the reduced `PipelineModelOption` returned in `pipeline.getSchema()` so the
6953
+ * grouped Family→Tier→Variant picker renders identically in the config UI and
6954
+ * in the pipeline/device steppers. The flat `id` stays the source of truth for
6955
+ * resolution/download/persistence; this is a presentation overlay resolved back
6956
+ * to an `id`.
6957
+ */
6958
+ var ModelVariantGroupSchema = object({
6959
+ /** Top-level family, e.g. `yolo26` (later `d-fine`, `rf-detr`). */
6960
+ family: string(),
6961
+ /** Size within the family, e.g. `n` | `s` | `m` | `l`. */
6962
+ tier: string(),
6963
+ /** Quantization axis. Omit ⇒ the fp32 base build. */
6964
+ precision: _enum(["fp32", "int8"]).optional(),
6965
+ /**
6966
+ * Speed-optimization axis. Omit ⇒ the standard build. `fast` marks a
6967
+ * latency-optimized export (e.g. ReLU-activation variant) — the slot the
6968
+ * future performance variants plug into.
6969
+ */
6970
+ optimization: _enum(["standard", "fast"]).optional(),
6971
+ /**
6972
+ * Input-resolution axis (square input side, px). Omit ⇒ the family's native
6973
+ * resolution (640 for yolo26). Reduced-input builds (320 / 256) are a big,
6974
+ * cheap latency lever — especially on Apple ANE and the Intel N100 — at a
6975
+ * small-object accuracy cost. Mirrors the model's `inputSize` but lifted onto
6976
+ * the group so the selector can offer it as a variant axis.
6977
+ */
6978
+ resolution: number().int().positive().optional()
6979
+ });
6914
6980
  var ModelCatalogEntrySchema = object({
6915
6981
  id: string(),
6916
6982
  name: string(),
@@ -6940,7 +7006,43 @@ var ModelCatalogEntrySchema = object({
6940
7006
  * Auxiliary files required at runtime (labels JSON, charset dict, etc.).
6941
7007
  * Downloaded into the same modelsDir alongside the model file.
6942
7008
  */
6943
- extraFiles: array(ModelExtraFileSchema).readonly().optional()
7009
+ extraFiles: array(ModelExtraFileSchema).readonly().optional(),
7010
+ /**
7011
+ * LEGACY entry — retained in the catalog so a persisted operator selection
7012
+ * still RESOLVES (and can be re-activated), but hidden from the selectable
7013
+ * model list and excluded from the auto format-default pick. Set on the
7014
+ * superseded / consolidated models (older lineages, redundant fp16 IRs) so
7015
+ * the active lineup stays the coherent curated ladder without deleting a
7016
+ * model anyone may still be pinned to. `resolveModelForFormat` keeps honoring
7017
+ * an explicit legacy id that has a build for the node's format.
7018
+ */
7019
+ legacy: boolean().optional(),
7020
+ /**
7021
+ * Measured quality/latency metadata — populated from the benchmark addon on
7022
+ * the real node classes. Absent = not yet measured (most entries today; the
7023
+ * catalog historically carried only `sizeMB`, a poor cross-architecture
7024
+ * speed proxy). `p95LatencyMs` is keyed by node class (e.g. `n100`, `mac`).
7025
+ */
7026
+ metrics: object({
7027
+ map50: number().optional(),
7028
+ p95LatencyMs: record(string(), number()).optional()
7029
+ }).optional(),
7030
+ /**
7031
+ * SPDX-ish license id of the model weights (e.g. `AGPL-3.0` for Ultralytics
7032
+ * YOLO26, `GPL-3.0` for YOLOv9, `Apache-2.0` for D-FINE/RF-DETR). Matters for
7033
+ * the retraining addon and any future commercial distribution.
7034
+ */
7035
+ license: string().optional(),
7036
+ /**
7037
+ * Variant-selector grouping. The UI groups models by `family` + `tier` and
7038
+ * offers `precision` / `optimization` as variant axes WITHIN a tier — so all
7039
+ * of a family's sizes and quantizations collapse into one grouped picker
7040
+ * instead of a flat list of `yolo26s`, `yolo26s-int8`, … Absent ⇒ ungrouped
7041
+ * (legacy / custom models) — never shown in the grouped selector. The flat
7042
+ * `id` stays the source of truth for resolution/download/persistence; grouping
7043
+ * is a presentation overlay resolved back to an `id`.
7044
+ */
7045
+ group: ModelVariantGroupSchema.optional()
6944
7046
  });
6945
7047
  var ConvertTargetSchema = discriminatedUnion("format", [object({
6946
7048
  format: literal("openvino"),
@@ -7001,8 +7103,8 @@ var RecordingModeSchema = _enum([
7001
7103
  "onAudioThreshold"
7002
7104
  ]);
7003
7105
  /**
7004
- * First-class, authoritative per-camera storage mode — the netta choice the UI
7005
- * reads directly (never inferred from `rules`):
7106
+ * First-class, authoritative per-camera storage mode — the explicit choice the
7107
+ * UI reads directly (never inferred from `rules`):
7006
7108
  * - `off` — not recording.
7007
7109
  * - `events` — record only around triggers (motion / audio threshold),
7008
7110
  * with pre/post-buffer.
@@ -8534,6 +8636,72 @@ function shallowEqual(a, b) {
8534
8636
  for (const k of ak) if (a[k] !== b[k]) return false;
8535
8637
  return true;
8536
8638
  }
8639
+ /** Reject after `ms`; always clears its own timer. */
8640
+ async function withTimeout(promise, ms, label) {
8641
+ let timer;
8642
+ const timeout = new Promise((_resolve, reject) => {
8643
+ timer = setTimeout(() => reject(/* @__PURE__ */ new Error(`${label} timed out after ${String(ms)}ms`)), ms);
8644
+ });
8645
+ try {
8646
+ return await Promise.race([promise, timeout]);
8647
+ } finally {
8648
+ if (timer !== void 0) clearTimeout(timer);
8649
+ }
8650
+ }
8651
+ /**
8652
+ * Start the reachability poll loop. Returns a handle whose `stop()` clears the
8653
+ * timer and prevents any further ticks. Start on device activation, stop on
8654
+ * device teardown (`removeDevice`) so no timer leaks.
8655
+ */
8656
+ function startReachabilityPoll(options) {
8657
+ const intervalMs = options.intervalMs ?? 3e4;
8658
+ const failuresToOffline = options.failuresToOffline ?? 3;
8659
+ const probeTimeoutMs = options.probeTimeoutMs ?? 1e4;
8660
+ const runImmediately = options.runImmediately ?? true;
8661
+ let stopped = false;
8662
+ let running = false;
8663
+ let consecutiveFailures = 0;
8664
+ let timer;
8665
+ const tick = async () => {
8666
+ if (stopped) return;
8667
+ if (running) return;
8668
+ if (options.isEnabled && !options.isEnabled()) return;
8669
+ running = true;
8670
+ try {
8671
+ const reachable = await withTimeout(Promise.resolve().then(options.probe), probeTimeoutMs, "reachability probe");
8672
+ if (stopped) return;
8673
+ if (reachable) {
8674
+ consecutiveFailures = 0;
8675
+ options.setOnline(true);
8676
+ } else registerFailure("probe resolved unreachable");
8677
+ } catch (error) {
8678
+ if (stopped) return;
8679
+ registerFailure(error instanceof Error ? error.message : "probe threw");
8680
+ } finally {
8681
+ running = false;
8682
+ }
8683
+ };
8684
+ const registerFailure = (reason) => {
8685
+ consecutiveFailures += 1;
8686
+ options.logger?.debug("reachability probe failed", {
8687
+ reason,
8688
+ consecutiveFailures,
8689
+ failuresToOffline
8690
+ });
8691
+ if (consecutiveFailures >= failuresToOffline) options.setOnline(false);
8692
+ };
8693
+ timer = setInterval(() => {
8694
+ tick();
8695
+ }, intervalMs);
8696
+ if (typeof timer === "object" && timer !== null && "unref" in timer) timer.unref();
8697
+ if (runImmediately) tick();
8698
+ return { stop: () => {
8699
+ if (stopped) return;
8700
+ stopped = true;
8701
+ if (timer !== void 0) clearInterval(timer);
8702
+ timer = void 0;
8703
+ } };
8704
+ }
8537
8705
  /**
8538
8706
  * Generic device-level status snapshot. Auto-registered by `BaseDevice`
8539
8707
  * for every device, regardless of provider — the kernel needs a uniform
@@ -9197,26 +9365,13 @@ onBrightnessChanged: { data: object({
9197
9365
  */
9198
9366
  runtimeState: BrightnessStatusSchema
9199
9367
  };
9368
+ /** Stream delivery format. (Relocated from the retired `streaming-engine` cap.) */
9200
9369
  var StreamFormatSchema = _enum([
9201
9370
  "webrtc",
9202
9371
  "hls",
9203
9372
  "mjpeg",
9204
9373
  "rtsp"
9205
9374
  ]);
9206
- var StreamInfoSchema = object({
9207
- streamId: string(),
9208
- format: StreamFormatSchema,
9209
- url: string().nullable(),
9210
- active: boolean()
9211
- });
9212
- method(object({
9213
- streamId: string(),
9214
- sourceUrl: string(),
9215
- codec: string().optional()
9216
- }), _void(), { kind: "mutation" }), method(object({ streamId: string() }), _void(), { kind: "mutation" }), method(object({
9217
- streamId: string(),
9218
- format: StreamFormatSchema
9219
- }), string().nullable()), method(_void(), array(StreamInfoSchema));
9220
9375
  var RtspRestreamEntrySchema = object({
9221
9376
  brokerId: string(),
9222
9377
  url: string(),
@@ -10084,37 +10239,7 @@ var consumablesCapability = {
10084
10239
  scope: "device",
10085
10240
  deviceNative: true,
10086
10241
  mode: "singleton",
10087
- deviceTypes: [
10088
- DeviceType.Camera,
10089
- DeviceType.Hub,
10090
- DeviceType.Light,
10091
- DeviceType.Siren,
10092
- DeviceType.Switch,
10093
- DeviceType.Sensor,
10094
- DeviceType.Thermostat,
10095
- DeviceType.Button,
10096
- DeviceType.EventEmitter,
10097
- DeviceType.Update,
10098
- DeviceType.Generic,
10099
- DeviceType.Notifier,
10100
- DeviceType.Script,
10101
- DeviceType.Automation,
10102
- DeviceType.Lock,
10103
- DeviceType.Cover,
10104
- DeviceType.Valve,
10105
- DeviceType.Humidifier,
10106
- DeviceType.WaterHeater,
10107
- DeviceType.Fan,
10108
- DeviceType.MediaPlayer,
10109
- DeviceType.AlarmPanel,
10110
- DeviceType.Control,
10111
- DeviceType.Presence,
10112
- DeviceType.Weather,
10113
- DeviceType.Vacuum,
10114
- DeviceType.LawnMower,
10115
- DeviceType.Container,
10116
- DeviceType.Image
10117
- ],
10242
+ deviceTypes: Object.values(DeviceType),
10118
10243
  deviceConfig: { ui: {
10119
10244
  kind: "widget",
10120
10245
  widgetId: "host/consumables-panel",
@@ -11707,7 +11832,6 @@ var PipelineDefaultStepSchema = lazy(() => object({
11707
11832
  enabled: boolean(),
11708
11833
  modelId: string(),
11709
11834
  children: array(PipelineDefaultStepSchema).readonly(),
11710
- engine: PipelineEngineChoiceSchema.optional(),
11711
11835
  group: string().optional(),
11712
11836
  settings: record(string(), unknown()).optional()
11713
11837
  }));
@@ -11732,7 +11856,9 @@ var PipelineModelOptionSchema = object({
11732
11856
  formats: record(string(), object({
11733
11857
  downloaded: boolean(),
11734
11858
  sizeMB: number()
11735
- }))
11859
+ })),
11860
+ group: ModelVariantGroupSchema.optional(),
11861
+ legacy: boolean().optional()
11736
11862
  });
11737
11863
  var ConfigFieldBridge = custom();
11738
11864
  var PipelineAddonSchemaSchema = object({
@@ -11783,15 +11909,42 @@ var EngineProvisioningSchema = object({
11783
11909
  ]),
11784
11910
  progress: number().optional(),
11785
11911
  error: string().optional(),
11786
- nextRetryAt: number().optional()
11912
+ nextRetryAt: number().optional(),
11913
+ /**
11914
+ * Gate A (config-correctness gate at engine change): human-readable
11915
+ * config issues surfaced EAGERLY when the node's engine changes — model
11916
+ * substitutions ("chose X, running Y") and zero-build steps ("no model
11917
+ * has a <format> build"). Additive/optional: informational only, never
11918
+ * enforced here — `assertEngineReady` (readiness) still gates inference.
11919
+ * Absent/empty when the node-default tree resolves cleanly.
11920
+ */
11921
+ configIssues: array(string()).optional()
11787
11922
  });
11788
11923
  var PipelineStepInputSchema = lazy(() => object({
11789
11924
  addonId: string(),
11790
- modelId: string(),
11925
+ modelId: string().optional(),
11791
11926
  enabled: boolean().default(true),
11792
11927
  children: array(PipelineStepInputSchema).optional(),
11793
11928
  settings: record(string(), unknown()).optional()
11794
11929
  }));
11930
+ var ModelSubstitutionSchema = object({
11931
+ addonId: string(),
11932
+ chosen: string(),
11933
+ running: string(),
11934
+ format: string()
11935
+ });
11936
+ var PipelineValidationIssueSchema = object({
11937
+ addonId: string(),
11938
+ kind: _enum(["unknown-addon", "no-format-build"]),
11939
+ detail: string()
11940
+ });
11941
+ var PipelineValidationResultSchema = object({
11942
+ ok: boolean(),
11943
+ issues: array(PipelineValidationIssueSchema).readonly(),
11944
+ substitutions: array(ModelSubstitutionSchema).readonly(),
11945
+ /** The node's `currentEngine.format` this validation ran against. */
11946
+ format: string()
11947
+ });
11795
11948
  var ReferenceImageEntrySchema = object({
11796
11949
  filename: string(),
11797
11950
  stepIds: array(string()).readonly().optional()
@@ -11862,7 +12015,13 @@ method(_void(), array(PipelineEngineChoiceSchema)), method(_void(), PipelineEngi
11862
12015
  })) }), object({ success: literal(true) }), {
11863
12016
  kind: "mutation",
11864
12017
  auth: "admin"
11865
- }), method(_void(), PipelineSchemaSchema), method(_void(), array(PipelineDefaultStepSchema).readonly().nullable()), method(_void(), PipelineConfigBridge), method(_void(), ConfigUISchemaBridge), method(_void(), array(PipelineTemplateSchema$1).readonly()), method(object({
12018
+ }), method(object({ nodeId: string() }), object({
12019
+ success: literal(true),
12020
+ regeneratedModelId: string().nullable()
12021
+ }), {
12022
+ kind: "mutation",
12023
+ auth: "admin"
12024
+ }), method(_void(), PipelineSchemaSchema), method(_void(), array(PipelineDefaultStepSchema).readonly().nullable()), method(_void(), PipelineConfigBridge), method(_void(), ConfigUISchemaBridge), method(object({ steps: array(PipelineStepInputSchema) }), PipelineValidationResultSchema), method(_void(), array(PipelineTemplateSchema$1).readonly()), method(object({
11866
12025
  name: string(),
11867
12026
  steps: array(PipelineTemplateStepSchema).readonly(),
11868
12027
  engine: PipelineEngineChoiceSchema
@@ -12155,6 +12314,13 @@ var RunnerFrameSourceSchema = discriminatedUnion("kind", [object({ kind: literal
12155
12314
  kind: literal("remote-restream"),
12156
12315
  /** The camera's source-owner node (slice 1: always the hub). */
12157
12316
  ownerNodeId: string(),
12317
+ /**
12318
+ * The owner's LAN-reachable host, resolved by the orchestrator from the
12319
+ * per-node `reachableHost` override (Cluster UI). When present the runner
12320
+ * dials THIS host for the owner's restream, in preference to the
12321
+ * `CAMSTACK_HUB_URL`-derived default. Absent → auto-detect fallback.
12322
+ */
12323
+ ownerReachableHost: string().optional(),
12158
12324
  /** Operator override for the owner host the runner dials. */
12159
12325
  hubHostnameOverride: string().optional()
12160
12326
  })]).describe("Per-camera frame-source mode for the runner (P2c)");
@@ -12163,13 +12329,11 @@ var RunnerFrameSourceSchema = discriminatedUnion("kind", [object({ kind: literal
12163
12329
  * specific runner instance via `attachCamera`. Carries everything the
12164
12330
  * runner needs to subscribe to the local broker and execute inference.
12165
12331
  *
12166
- * Stateless-pipeline model: the full pipeline content (`engine`, `steps`,
12167
- * optional `audio`) travels with the attach payload. The runner keeps it
12168
- * in RAM for the lifetime of the attach — on rebalance, edit, or
12169
- * restart the orchestrator re-sends the latest snapshot.
12170
- *
12171
- * `engine`/`steps`/`audio` are optional during the additive migration
12172
- * window; once orchestrator + UI are migrated they become required.
12332
+ * Stateless-pipeline model: the pipeline content (`steps`, optional
12333
+ * `audio`) travels with the attach payload. The runner keeps it in RAM
12334
+ * for the lifetime of the attach — on rebalance, edit, or restart the
12335
+ * orchestrator re-sends the latest snapshot. Engine is NOT carried: it is
12336
+ * node-local, resolved by the executing runner at dispatch time.
12173
12337
  */
12174
12338
  var RunnerCameraConfigSchema = object({
12175
12339
  deviceId: number(),
@@ -12220,14 +12384,11 @@ var RunnerCameraConfigSchema = object({
12220
12384
  */
12221
12385
  motionSources: MotionSourcesSchema.default(["analyzer"]),
12222
12386
  pipelineEnabled: boolean().default(true),
12223
- /** Engine choice for video steps (runtime+backend+format). */
12224
- engine: PipelineEngineChoiceSchema.optional(),
12225
12387
  /** Ordered tree of video steps. Absent → runner skips video detection. */
12226
12388
  steps: array(PipelineStepInputSchema).readonly().optional(),
12227
12389
  /** Audio classification branch. `enabled:false` disables, null skips. */
12228
12390
  audio: object({
12229
- engine: PipelineEngineChoiceSchema,
12230
- modelId: string(),
12391
+ modelId: string().optional(),
12231
12392
  enabled: boolean()
12232
12393
  }).nullable().optional(),
12233
12394
  /**
@@ -18090,11 +18251,11 @@ DeviceType.Camera, method(object({ deviceId: number() }), array(TrackSchema).rea
18090
18251
  timestamp: number()
18091
18252
  });
18092
18253
  var CameraPipelineConfigSchema = object({
18093
- engine: PipelineEngineChoiceSchema,
18254
+ engine: PipelineEngineChoiceSchema.optional(),
18094
18255
  steps: array(PipelineStepInputSchema).readonly(),
18095
18256
  audio: object({
18096
- engine: PipelineEngineChoiceSchema,
18097
- modelId: string(),
18257
+ engine: PipelineEngineChoiceSchema.optional(),
18258
+ modelId: string().optional(),
18098
18259
  enabled: boolean(),
18099
18260
  settings: record(string(), unknown()).readonly().optional()
18100
18261
  }).nullable().optional()
@@ -18109,7 +18270,7 @@ var PipelineTemplateSchema = object({
18109
18270
  });
18110
18271
  var AgentAddonConfigSchema = object({
18111
18272
  enabled: boolean(),
18112
- modelId: string(),
18273
+ modelId: string().optional(),
18113
18274
  settings: record(string(), unknown()).readonly()
18114
18275
  });
18115
18276
  var AgentPipelineSettingsSchema = object({
@@ -18124,7 +18285,15 @@ var AgentPipelineSettingsSchema = object({
18124
18285
  /** Node is eligible to run audio-analyzer sessions. */
18125
18286
  audio: boolean().optional(),
18126
18287
  /** Node is eligible to be the ingest / source-owner (serve the restream). */
18127
- ingest: boolean().optional()
18288
+ ingest: boolean().optional(),
18289
+ /**
18290
+ * Operator override for the LAN host a cross-node decoder dials to reach
18291
+ * THIS node's restream (Cluster UI). Absent → auto-detect: a remote runner
18292
+ * falls back to its `CAMSTACK_HUB_URL`-derived host (the Moleculer address
18293
+ * it already uses to reach the hub). Set this only when the auto-detected
18294
+ * address is wrong (multi-homed host, NAT, custom interface).
18295
+ */
18296
+ reachableHost: string().optional()
18128
18297
  });
18129
18298
  var CameraPipelineForAgentSchema = object({
18130
18299
  steps: array(PipelineStepInputSchema).readonly(),
@@ -18230,6 +18399,15 @@ var GlobalMetricsSchema = object({
18230
18399
  * capability providers.
18231
18400
  */
18232
18401
  var CapabilityBindingsSchema = record(string(), string());
18402
+ /**
18403
+ * The cluster's single camera-source owner (`clusterRoles.ingestNode`) plus
18404
+ * its LAN-reachable host, if one is registered. See `getIngestOwner`.
18405
+ */
18406
+ var IngestOwnerSchema = object({
18407
+ ownerNodeId: string(),
18408
+ reachableHost: string().optional(),
18409
+ configIssue: string().optional()
18410
+ });
18233
18411
  /** Source block — always present; derives from the stream catalog. */
18234
18412
  var CameraSourceStatusSchema = object({ streams: array(object({
18235
18413
  camStreamId: string(),
@@ -18244,6 +18422,14 @@ var CameraAssignmentStatusSchema = object({
18244
18422
  detectionNodeId: string().nullable(),
18245
18423
  decoderNodeId: string().nullable(),
18246
18424
  audioNodeId: string().nullable(),
18425
+ /**
18426
+ * The node that OWNS this camera's physical source pull (dials the RTSP and
18427
+ * hosts the broker/restream) — the cluster ingest owner today
18428
+ * (`clusterRoles.ingestNode`), per-camera once source assignment lands. Lets
18429
+ * the UI show WHERE a camera is sourced without SSH/logs, and is the node the
18430
+ * broker block below was read from (pinned). Nullable only pre-wiring.
18431
+ */
18432
+ sourceNodeId: string().nullable(),
18247
18433
  pinned: object({
18248
18434
  detection: boolean(),
18249
18435
  decoder: boolean(),
@@ -18376,7 +18562,7 @@ method(object({
18376
18562
  }), object({ success: literal(true) }), {
18377
18563
  kind: "mutation",
18378
18564
  auth: "admin"
18379
- }), method(object({
18565
+ }), method(_void(), IngestOwnerSchema), method(object({
18380
18566
  deviceId: number(),
18381
18567
  nodeId: string()
18382
18568
  }), _void(), {
@@ -18445,6 +18631,12 @@ method(object({
18445
18631
  }), object({ success: literal(true) }), {
18446
18632
  kind: "mutation",
18447
18633
  auth: "admin"
18634
+ }), method(object({
18635
+ agentNodeId: string(),
18636
+ reachableHost: string().nullable()
18637
+ }), object({ success: literal(true) }), {
18638
+ kind: "mutation",
18639
+ auth: "admin"
18448
18640
  }), method(object({ deviceId: number() }), CameraPipelineSettingsSchema.nullable()), method(object({
18449
18641
  deviceId: number(),
18450
18642
  addonId: string(),
@@ -18489,22 +18681,6 @@ method(object({
18489
18681
  kind: "mutation",
18490
18682
  auth: "admin"
18491
18683
  });
18492
- var RegisteredStreamSchema = object({
18493
- streamId: string(),
18494
- label: string().optional(),
18495
- codec: string(),
18496
- type: _enum(["video", "audio"]),
18497
- sourceUrl: string()
18498
- });
18499
- var ExposedResourceSchema = object({
18500
- streamId: string(),
18501
- format: string(),
18502
- value: string()
18503
- });
18504
- method(object({
18505
- deviceId: number(),
18506
- streams: array(RegisteredStreamSchema).readonly()
18507
- }), _void(), { kind: "mutation" }), method(object({ deviceId: number() }), _void(), { kind: "mutation" }), method(object({ deviceId: number() }), array(ExposedResourceSchema).readonly());
18508
18684
  /**
18509
18685
  * Query filter for settings-store collections.
18510
18686
  */
@@ -18657,9 +18833,9 @@ method(SendEmailInputSchema, SendEmailResultSchema, {
18657
18833
  /**
18658
18834
  * A single device snapshot returned as base64 JPEG/PNG.
18659
18835
  *
18660
- * Shared with the `snapshot-provider` collection cap the orchestrator
18661
- * receives the same shape from each native provider and from the
18662
- * broker-based fallback.
18836
+ * The `SnapshotAddon` wrapper returns this shape whether the frame came from
18837
+ * the device-native provider (onboard capture) or from the stream-broker
18838
+ * prebuffer fallback.
18663
18839
  */
18664
18840
  var SnapshotImageSchema = object({
18665
18841
  base64: string(),
@@ -18734,10 +18910,6 @@ var snapshotCapability = {
18734
18910
  kind: "poll"
18735
18911
  }
18736
18912
  };
18737
- method(object({ deviceId: number() }), boolean()), method(object({
18738
- deviceId: number(),
18739
- streamId: string().optional()
18740
- }), SnapshotImageSchema.nullable());
18741
18913
  /**
18742
18914
  * `sso-bridge` — internal hub-only cap that lets SSO-style auth
18743
18915
  * providers (OIDC, SAML, magic-link, …) mint an HMAC-signed token
@@ -19098,9 +19270,10 @@ method(object({
19098
19270
  auth: "admin"
19099
19271
  });
19100
19272
  /**
19101
- * Optional client-side hints sent at session creation to help the
19102
- * provider pick the best native source. All fields are optional —
19103
- * a viewer that knows nothing still gets a sane default.
19273
+ * Optional client-side hints sent at session creation to help the provider
19274
+ * pick the best native source. All fields optional — a viewer that knows
19275
+ * nothing still gets a sane default. (Relocated from the retired `webrtc`
19276
+ * collection cap; this `webrtc-session` cap is the live signaling surface.)
19104
19277
  */
19105
19278
  var webrtcClientHintsSchema = object({
19106
19279
  viewportWidth: number().int().positive().optional(),
@@ -19111,22 +19284,6 @@ var webrtcClientHintsSchema = object({
19111
19284
  /** Hard tier override; takes precedence over scoring when registered. */
19112
19285
  prefersTier: string().optional()
19113
19286
  }).partial();
19114
- method(object({
19115
- streamId: string(),
19116
- sdpOffer: string()
19117
- }), string(), { kind: "mutation" }), method(object({ streamId: string() }), boolean()), method(object({
19118
- streamId: string(),
19119
- codec: string()
19120
- }), _void(), { kind: "mutation" }), method(object({ streamId: string() }), _void(), { kind: "mutation" }), method(object({
19121
- streamId: string(),
19122
- hints: webrtcClientHintsSchema.optional()
19123
- }), object({
19124
- sessionId: string(),
19125
- sdpOffer: string()
19126
- }), { kind: "mutation" }), method(object({
19127
- sessionId: string(),
19128
- sdpAnswer: string()
19129
- }), _void(), { kind: "mutation" }), method(object({ sessionId: string() }), _void(), { kind: "mutation" }), method(object({ streamId: string() }), boolean());
19130
19287
  /**
19131
19288
  * Discriminated target for a WebRTC session. The client sends this
19132
19289
  * structured object instead of building / parsing brokerId strings;
@@ -19613,7 +19770,15 @@ var FrameworkPackageStatusSchema = object({
19613
19770
  latestVersion: string().nullable(),
19614
19771
  hasUpdate: boolean(),
19615
19772
  /** Optional manifest description for the row tooltip. */
19616
- description: string().optional()
19773
+ description: string().optional(),
19774
+ /**
19775
+ * Content build-id (md5 of the resolved `dist/` tree) of the code the hub
19776
+ * ACTUALLY loaded. Framework packages ship code changes without always
19777
+ * bumping `currentVersion`, so semver alone hides "same version, new code".
19778
+ * `null` when the dist can't be hashed (not installed / empty). The admin-UI
19779
+ * surfaces this so a stale-code hub is visible even at an unchanged version.
19780
+ */
19781
+ buildId: string().nullable()
19617
19782
  });
19618
19783
  var LogStreamEntrySchema = object({
19619
19784
  timestamp: string(),
@@ -24174,6 +24339,12 @@ Object.freeze({
24174
24339
  addonId: null,
24175
24340
  access: "create"
24176
24341
  },
24342
+ "pipelineExecutor.resetToDefault": {
24343
+ capName: "pipeline-executor",
24344
+ capScope: "system",
24345
+ addonId: null,
24346
+ access: "delete"
24347
+ },
24177
24348
  "pipelineExecutor.runAudioTest": {
24178
24349
  capName: "pipeline-executor",
24179
24350
  capScope: "system",
@@ -24222,6 +24393,12 @@ Object.freeze({
24222
24393
  addonId: null,
24223
24394
  access: "create"
24224
24395
  },
24396
+ "pipelineExecutor.validatePipeline": {
24397
+ capName: "pipeline-executor",
24398
+ capScope: "system",
24399
+ addonId: null,
24400
+ access: "view"
24401
+ },
24225
24402
  "pipelineOrchestrator.assignAudio": {
24226
24403
  capName: "pipeline-orchestrator",
24227
24404
  capScope: "system",
@@ -24330,6 +24507,12 @@ Object.freeze({
24330
24507
  addonId: null,
24331
24508
  access: "view"
24332
24509
  },
24510
+ "pipelineOrchestrator.getIngestOwner": {
24511
+ capName: "pipeline-orchestrator",
24512
+ capScope: "system",
24513
+ addonId: null,
24514
+ access: "view"
24515
+ },
24333
24516
  "pipelineOrchestrator.getPipelineAssignment": {
24334
24517
  capName: "pipeline-orchestrator",
24335
24518
  capScope: "system",
@@ -24402,6 +24585,12 @@ Object.freeze({
24402
24585
  addonId: null,
24403
24586
  access: "create"
24404
24587
  },
24588
+ "pipelineOrchestrator.setAgentReachableHost": {
24589
+ capName: "pipeline-orchestrator",
24590
+ capScope: "system",
24591
+ addonId: null,
24592
+ access: "create"
24593
+ },
24405
24594
  "pipelineOrchestrator.setCameraPipelineForAgent": {
24406
24595
  capName: "pipeline-orchestrator",
24407
24596
  capScope: "system",
@@ -24732,24 +24921,6 @@ Object.freeze({
24732
24921
  addonId: null,
24733
24922
  access: "create"
24734
24923
  },
24735
- "restreamer.getExposedResources": {
24736
- capName: "restreamer",
24737
- capScope: "system",
24738
- addonId: null,
24739
- access: "view"
24740
- },
24741
- "restreamer.registerDevice": {
24742
- capName: "restreamer",
24743
- capScope: "system",
24744
- addonId: null,
24745
- access: "create"
24746
- },
24747
- "restreamer.unregisterDevice": {
24748
- capName: "restreamer",
24749
- capScope: "system",
24750
- addonId: null,
24751
- access: "delete"
24752
- },
24753
24924
  "scriptRunner.run": {
24754
24925
  capName: "script-runner",
24755
24926
  capScope: "device",
@@ -24852,18 +25023,6 @@ Object.freeze({
24852
25023
  addonId: null,
24853
25024
  access: "create"
24854
25025
  },
24855
- "snapshotProvider.getSnapshot": {
24856
- capName: "snapshot-provider",
24857
- capScope: "system",
24858
- addonId: null,
24859
- access: "view"
24860
- },
24861
- "snapshotProvider.supportsDevice": {
24862
- capName: "snapshot-provider",
24863
- capScope: "system",
24864
- addonId: null,
24865
- access: "view"
24866
- },
24867
25026
  "ssoBridge.signBridgeToken": {
24868
25027
  capName: "sso-bridge",
24869
25028
  capScope: "system",
@@ -25290,30 +25449,6 @@ Object.freeze({
25290
25449
  addonId: null,
25291
25450
  access: "view"
25292
25451
  },
25293
- "streamingEngine.getStreamUrl": {
25294
- capName: "streaming-engine",
25295
- capScope: "system",
25296
- addonId: null,
25297
- access: "view"
25298
- },
25299
- "streamingEngine.listStreams": {
25300
- capName: "streaming-engine",
25301
- capScope: "system",
25302
- addonId: null,
25303
- access: "view"
25304
- },
25305
- "streamingEngine.registerStream": {
25306
- capName: "streaming-engine",
25307
- capScope: "system",
25308
- addonId: null,
25309
- access: "create"
25310
- },
25311
- "streamingEngine.unregisterStream": {
25312
- capName: "streaming-engine",
25313
- capScope: "system",
25314
- addonId: null,
25315
- access: "delete"
25316
- },
25317
25452
  "streamParams.getConfigSchema": {
25318
25453
  capName: "stream-params",
25319
25454
  capScope: "device",
@@ -25680,54 +25815,6 @@ Object.freeze({
25680
25815
  addonId: null,
25681
25816
  access: "create"
25682
25817
  },
25683
- "webrtc.closeSession": {
25684
- capName: "webrtc",
25685
- capScope: "system",
25686
- addonId: null,
25687
- access: "create"
25688
- },
25689
- "webrtc.createSession": {
25690
- capName: "webrtc",
25691
- capScope: "system",
25692
- addonId: null,
25693
- access: "create"
25694
- },
25695
- "webrtc.handleAnswer": {
25696
- capName: "webrtc",
25697
- capScope: "system",
25698
- addonId: null,
25699
- access: "create"
25700
- },
25701
- "webrtc.handleOffer": {
25702
- capName: "webrtc",
25703
- capScope: "system",
25704
- addonId: null,
25705
- access: "create"
25706
- },
25707
- "webrtc.hasAdaptiveBitrate": {
25708
- capName: "webrtc",
25709
- capScope: "system",
25710
- addonId: null,
25711
- access: "view"
25712
- },
25713
- "webrtc.registerStream": {
25714
- capName: "webrtc",
25715
- capScope: "system",
25716
- addonId: null,
25717
- access: "create"
25718
- },
25719
- "webrtc.supportsStream": {
25720
- capName: "webrtc",
25721
- capScope: "system",
25722
- addonId: null,
25723
- access: "view"
25724
- },
25725
- "webrtc.unregisterStream": {
25726
- capName: "webrtc",
25727
- capScope: "system",
25728
- addonId: null,
25729
- access: "delete"
25730
- },
25731
25818
  "webrtcSession.addIceCandidate": {
25732
25819
  capName: "webrtc-session",
25733
25820
  capScope: "device",
@@ -25941,6 +26028,72 @@ function maskUrlCredentials(rawUrl) {
25941
26028
  return rawUrl;
25942
26029
  }
25943
26030
  }
26031
+ //#endregion
26032
+ //#region src/rtsp-reachability.ts
26033
+ /**
26034
+ * Control-plane reachability probe for a generic RTSP camera.
26035
+ *
26036
+ * Unlike Hikvision/Amcrest/ONVIF, an RTSP source exposes NO management API —
26037
+ * we only have an `rtsp://` URL. Reachability is therefore probed at the RTSP
26038
+ * control channel itself:
26039
+ *
26040
+ * 1. open a TCP connection to the RTSP `host:port` (default 554);
26041
+ * 2. best-effort send an RTSP `OPTIONS` request;
26042
+ * 3. treat an `RTSP/…` response OR a successful TCP handshake (even without
26043
+ * a response before the deadline) as REACHABLE.
26044
+ *
26045
+ * A successful TCP handshake already proves the camera's RTSP port is live —
26046
+ * the `OPTIONS` round-trip is a stronger confirmation when the server answers
26047
+ * promptly, but we deliberately fall back to the bare connect so a camera that
26048
+ * gates `OPTIONS` behind auth (or is simply slow to answer) is not wrongly
26049
+ * marked offline.
26050
+ *
26051
+ * The socket is always destroyed; the probe never throws and resolves within
26052
+ * `timeoutMs`.
26053
+ */
26054
+ var DEFAULT_RTSP_PORT = 554;
26055
+ /** Parse `host` + `port` out of an `rtsp://` URL. Credentials/path ignored. */
26056
+ function parseRtspTarget(url) {
26057
+ try {
26058
+ const parsed = new URL(url);
26059
+ if (!parsed.hostname) return null;
26060
+ const port = parsed.port ? Number.parseInt(parsed.port, 10) : DEFAULT_RTSP_PORT;
26061
+ if (!Number.isFinite(port) || port <= 0 || port > 65535) return null;
26062
+ return {
26063
+ host: parsed.hostname,
26064
+ port
26065
+ };
26066
+ } catch {
26067
+ return null;
26068
+ }
26069
+ }
26070
+ async function probeRtspReachable(url, timeoutMs) {
26071
+ const target = parseRtspTarget(url);
26072
+ if (!target) return false;
26073
+ return new Promise((resolve) => {
26074
+ let settled = false;
26075
+ let connected = false;
26076
+ const socket = new node_net.default.Socket();
26077
+ const finish = (reachable) => {
26078
+ if (settled) return;
26079
+ settled = true;
26080
+ socket.destroy();
26081
+ resolve(reachable);
26082
+ };
26083
+ socket.setTimeout(timeoutMs);
26084
+ socket.once("timeout", () => finish(connected));
26085
+ socket.once("error", () => finish(false));
26086
+ socket.once("connect", () => {
26087
+ connected = true;
26088
+ const request = `OPTIONS ${url} RTSP/1.0\r\nCSeq: 1\r\nUser-Agent: camstack-reachability\r\n\r\n`;
26089
+ socket.write(request, () => {});
26090
+ });
26091
+ socket.once("data", (buf) => {
26092
+ finish(buf.toString("latin1").startsWith("RTSP/") || connected);
26093
+ });
26094
+ socket.connect(target.port, target.host);
26095
+ });
26096
+ }
25944
26097
  var rtspCameraSchema = object({
25945
26098
  streams: preprocess((raw) => {
25946
26099
  if (!Array.isArray(raw)) return raw;
@@ -25978,15 +26131,44 @@ var legacyStreamSchema = object({
25978
26131
  label: CamProfileSchema,
25979
26132
  url: string()
25980
26133
  });
25981
- var RtspCamera = class extends BaseDevice {
26134
+ var RtspCamera = class RtspCamera extends BaseDevice {
25982
26135
  type = DeviceType.Camera;
25983
26136
  features = [];
26137
+ /** Control-plane reachability poll — an RTSP `OPTIONS`/TCP-connect probe to
26138
+ * the camera's RTSP port every 30s drives `device.online`, decoupled from
26139
+ * stream-broker video health. Started in `onActivate`, stopped in
26140
+ * `removeDevice`. */
26141
+ reachabilityPoll = null;
25984
26142
  constructor(ctx) {
25985
26143
  super(ctx, rtspCameraSchema, { type: DeviceType.Camera });
25986
26144
  this.migrateLegacyStreamsIfNeeded(ctx.persistedConfig ?? {});
25987
26145
  this.registerSnapshotProvider();
25988
26146
  this.registerStreamCatalogProvider();
25989
26147
  }
26148
+ /** Phase 5 — device is live: begin the control-plane reachability poll. */
26149
+ async onActivate() {
26150
+ this.startReachabilityPolling();
26151
+ }
26152
+ /** Start the RTSP reachability poll: a lightweight `OPTIONS`/TCP-connect
26153
+ * probe to the first configured stream's `host:port` every 30s drives
26154
+ * `device.online`, with hysteresis. Idempotent. */
26155
+ startReachabilityPolling() {
26156
+ if (this.reachabilityPoll) return;
26157
+ this.reachabilityPoll = startReachabilityPoll({
26158
+ probe: async () => {
26159
+ const url = this.config.get("streams")[0]?.url;
26160
+ if (!url) return false;
26161
+ return probeRtspReachable(url, RtspCamera.RTSP_PROBE_TIMEOUT_MS);
26162
+ },
26163
+ setOnline: (online) => {
26164
+ this.markOnline(online);
26165
+ },
26166
+ isEnabled: () => !this.disabled,
26167
+ logger: this.ctx.logger
26168
+ });
26169
+ }
26170
+ /** Per-probe TCP/RTSP round-trip budget — a dead host resolves as a miss. */
26171
+ static RTSP_PROBE_TIMEOUT_MS = 5e3;
25990
26172
  /**
25991
26173
  * Build this camera's pull-able stream catalog — the descriptors the
25992
26174
  * stream-broker needs to dial. Single source of truth feeding the
@@ -26077,6 +26259,8 @@ var RtspCamera = class extends BaseDevice {
26077
26259
  }
26078
26260
  async removeDevice() {
26079
26261
  this.ctx.logger.info("Removing RTSP camera", { meta: { stableId: this.stableId } });
26262
+ this.reachabilityPoll?.stop();
26263
+ this.reachabilityPoll = null;
26080
26264
  const streams = this.config.get("streams");
26081
26265
  await Promise.all(streams.map((s) => this.ctx.api.streamBroker.retractCameraStream.mutate({
26082
26266
  deviceId: this.id,
@@ -26354,21 +26538,7 @@ var RtspProviderAddon = class extends BaseDeviceProvider {
26354
26538
  super({});
26355
26539
  }
26356
26540
  async onInitialize() {
26357
- const regs = await super.onInitialize();
26358
- this.subscribe({ category: EventCategory.DeviceStateChanged }, (event) => {
26359
- const data = event.data;
26360
- if (data.capName !== "camera-streams") return;
26361
- const deviceId = data.deviceId;
26362
- if (typeof deviceId !== "number") return;
26363
- const registry = this.ctx.kernel.deviceRegistry;
26364
- if (!registry) return;
26365
- if (registry.getAddonId(deviceId) !== this.addonId) return;
26366
- const device = registry.getById(deviceId);
26367
- if (!device) return;
26368
- const online = data.slice?.online === true;
26369
- if (device.online !== online) device.online = online;
26370
- });
26371
- return regs;
26541
+ return await super.onInitialize();
26372
26542
  }
26373
26543
  async onGetCreationSchema(type) {
26374
26544
  if (type !== DeviceType.Camera) return null;