@camstack/addon-provider-rtsp 1.1.19 → 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 +393 -231
  2. package/dist/addon.mjs +370 -231
  3. package/package.json +1 -1
package/dist/addon.mjs CHANGED
@@ -1,3 +1,4 @@
1
+ import net from "node:net";
1
2
  //#region ../../node_modules/zod/v4/core/core.js
2
3
  var _a$1;
3
4
  function $constructor(name, initializer, params) {
@@ -4641,7 +4642,7 @@ function preprocess(fn, schema) {
4641
4642
  });
4642
4643
  }
4643
4644
  //#endregion
4644
- //#region ../types/dist/sleep-CZDdRBua.mjs
4645
+ //#region ../types/dist/sleep-Cc14_yxc.mjs
4645
4646
  var EventCategory = /* @__PURE__ */ function(EventCategory) {
4646
4647
  EventCategory["SystemBoot"] = "system.boot";
4647
4648
  EventCategory["SystemAddonsReady"] = "system.addons-ready";
@@ -4827,6 +4828,18 @@ var EventCategory = /* @__PURE__ */ function(EventCategory) {
4827
4828
  */
4828
4829
  EventCategory["PipelineCameraUpdated"] = "pipeline.camera-updated";
4829
4830
  /**
4831
+ * The cluster camera-source OWNER changed (`clusterRoles.ingestNode`).
4832
+ * Emitted by addon-pipeline-orchestrator whenever it (re)derives node
4833
+ * capabilities — at boot, on agent online/offline, and on an ingest-node
4834
+ * flip. Carries the resolved `ownerNodeId`. The stream-broker consumes it to
4835
+ * keep its ingest-owner-gate decision current WITHOUT a per-`ensureBroker`
4836
+ * cross-process `getIngestOwner` query (push the authority's decision instead
4837
+ * of polling it on the hot path). Idempotent state — re-emitted on every
4838
+ * topology change, so a dropped event self-heals on the next one (plus the
4839
+ * broker's long backstop reconcile query).
4840
+ */
4841
+ EventCategory["PipelineIngestOwnerChanged"] = "pipeline.ingest-owner-changed";
4842
+ /**
4830
4843
  * Periodic snapshot of per-node pipeline-runner load
4831
4844
  * (`RunnerLocalLoad`). Emitted ~1Hz by every runner so UI dashboards
4832
4845
  * subscribe instead of polling `pipelineRunner.getLocalLoad`.
@@ -6910,6 +6923,36 @@ var ModelFormatsSchema = object({
6910
6923
  tflite: ModelFormatEntrySchema.optional(),
6911
6924
  pt: ModelFormatEntrySchema.optional()
6912
6925
  });
6926
+ /**
6927
+ * Variant-selector grouping axes. Shared by the full `ModelCatalogEntry` and by
6928
+ * the reduced `PipelineModelOption` returned in `pipeline.getSchema()` so the
6929
+ * grouped Family→Tier→Variant picker renders identically in the config UI and
6930
+ * in the pipeline/device steppers. The flat `id` stays the source of truth for
6931
+ * resolution/download/persistence; this is a presentation overlay resolved back
6932
+ * to an `id`.
6933
+ */
6934
+ var ModelVariantGroupSchema = object({
6935
+ /** Top-level family, e.g. `yolo26` (later `d-fine`, `rf-detr`). */
6936
+ family: string(),
6937
+ /** Size within the family, e.g. `n` | `s` | `m` | `l`. */
6938
+ tier: string(),
6939
+ /** Quantization axis. Omit ⇒ the fp32 base build. */
6940
+ precision: _enum(["fp32", "int8"]).optional(),
6941
+ /**
6942
+ * Speed-optimization axis. Omit ⇒ the standard build. `fast` marks a
6943
+ * latency-optimized export (e.g. ReLU-activation variant) — the slot the
6944
+ * future performance variants plug into.
6945
+ */
6946
+ optimization: _enum(["standard", "fast"]).optional(),
6947
+ /**
6948
+ * Input-resolution axis (square input side, px). Omit ⇒ the family's native
6949
+ * resolution (640 for yolo26). Reduced-input builds (320 / 256) are a big,
6950
+ * cheap latency lever — especially on Apple ANE and the Intel N100 — at a
6951
+ * small-object accuracy cost. Mirrors the model's `inputSize` but lifted onto
6952
+ * the group so the selector can offer it as a variant axis.
6953
+ */
6954
+ resolution: number().int().positive().optional()
6955
+ });
6913
6956
  var ModelCatalogEntrySchema = object({
6914
6957
  id: string(),
6915
6958
  name: string(),
@@ -6939,7 +6982,43 @@ var ModelCatalogEntrySchema = object({
6939
6982
  * Auxiliary files required at runtime (labels JSON, charset dict, etc.).
6940
6983
  * Downloaded into the same modelsDir alongside the model file.
6941
6984
  */
6942
- extraFiles: array(ModelExtraFileSchema).readonly().optional()
6985
+ extraFiles: array(ModelExtraFileSchema).readonly().optional(),
6986
+ /**
6987
+ * LEGACY entry — retained in the catalog so a persisted operator selection
6988
+ * still RESOLVES (and can be re-activated), but hidden from the selectable
6989
+ * model list and excluded from the auto format-default pick. Set on the
6990
+ * superseded / consolidated models (older lineages, redundant fp16 IRs) so
6991
+ * the active lineup stays the coherent curated ladder without deleting a
6992
+ * model anyone may still be pinned to. `resolveModelForFormat` keeps honoring
6993
+ * an explicit legacy id that has a build for the node's format.
6994
+ */
6995
+ legacy: boolean().optional(),
6996
+ /**
6997
+ * Measured quality/latency metadata — populated from the benchmark addon on
6998
+ * the real node classes. Absent = not yet measured (most entries today; the
6999
+ * catalog historically carried only `sizeMB`, a poor cross-architecture
7000
+ * speed proxy). `p95LatencyMs` is keyed by node class (e.g. `n100`, `mac`).
7001
+ */
7002
+ metrics: object({
7003
+ map50: number().optional(),
7004
+ p95LatencyMs: record(string(), number()).optional()
7005
+ }).optional(),
7006
+ /**
7007
+ * SPDX-ish license id of the model weights (e.g. `AGPL-3.0` for Ultralytics
7008
+ * YOLO26, `GPL-3.0` for YOLOv9, `Apache-2.0` for D-FINE/RF-DETR). Matters for
7009
+ * the retraining addon and any future commercial distribution.
7010
+ */
7011
+ license: string().optional(),
7012
+ /**
7013
+ * Variant-selector grouping. The UI groups models by `family` + `tier` and
7014
+ * offers `precision` / `optimization` as variant axes WITHIN a tier — so all
7015
+ * of a family's sizes and quantizations collapse into one grouped picker
7016
+ * instead of a flat list of `yolo26s`, `yolo26s-int8`, … Absent ⇒ ungrouped
7017
+ * (legacy / custom models) — never shown in the grouped selector. The flat
7018
+ * `id` stays the source of truth for resolution/download/persistence; grouping
7019
+ * is a presentation overlay resolved back to an `id`.
7020
+ */
7021
+ group: ModelVariantGroupSchema.optional()
6943
7022
  });
6944
7023
  var ConvertTargetSchema = discriminatedUnion("format", [object({
6945
7024
  format: literal("openvino"),
@@ -7000,8 +7079,8 @@ var RecordingModeSchema = _enum([
7000
7079
  "onAudioThreshold"
7001
7080
  ]);
7002
7081
  /**
7003
- * First-class, authoritative per-camera storage mode — the netta choice the UI
7004
- * reads directly (never inferred from `rules`):
7082
+ * First-class, authoritative per-camera storage mode — the explicit choice the
7083
+ * UI reads directly (never inferred from `rules`):
7005
7084
  * - `off` — not recording.
7006
7085
  * - `events` — record only around triggers (motion / audio threshold),
7007
7086
  * with pre/post-buffer.
@@ -8533,6 +8612,72 @@ function shallowEqual(a, b) {
8533
8612
  for (const k of ak) if (a[k] !== b[k]) return false;
8534
8613
  return true;
8535
8614
  }
8615
+ /** Reject after `ms`; always clears its own timer. */
8616
+ async function withTimeout(promise, ms, label) {
8617
+ let timer;
8618
+ const timeout = new Promise((_resolve, reject) => {
8619
+ timer = setTimeout(() => reject(/* @__PURE__ */ new Error(`${label} timed out after ${String(ms)}ms`)), ms);
8620
+ });
8621
+ try {
8622
+ return await Promise.race([promise, timeout]);
8623
+ } finally {
8624
+ if (timer !== void 0) clearTimeout(timer);
8625
+ }
8626
+ }
8627
+ /**
8628
+ * Start the reachability poll loop. Returns a handle whose `stop()` clears the
8629
+ * timer and prevents any further ticks. Start on device activation, stop on
8630
+ * device teardown (`removeDevice`) so no timer leaks.
8631
+ */
8632
+ function startReachabilityPoll(options) {
8633
+ const intervalMs = options.intervalMs ?? 3e4;
8634
+ const failuresToOffline = options.failuresToOffline ?? 3;
8635
+ const probeTimeoutMs = options.probeTimeoutMs ?? 1e4;
8636
+ const runImmediately = options.runImmediately ?? true;
8637
+ let stopped = false;
8638
+ let running = false;
8639
+ let consecutiveFailures = 0;
8640
+ let timer;
8641
+ const tick = async () => {
8642
+ if (stopped) return;
8643
+ if (running) return;
8644
+ if (options.isEnabled && !options.isEnabled()) return;
8645
+ running = true;
8646
+ try {
8647
+ const reachable = await withTimeout(Promise.resolve().then(options.probe), probeTimeoutMs, "reachability probe");
8648
+ if (stopped) return;
8649
+ if (reachable) {
8650
+ consecutiveFailures = 0;
8651
+ options.setOnline(true);
8652
+ } else registerFailure("probe resolved unreachable");
8653
+ } catch (error) {
8654
+ if (stopped) return;
8655
+ registerFailure(error instanceof Error ? error.message : "probe threw");
8656
+ } finally {
8657
+ running = false;
8658
+ }
8659
+ };
8660
+ const registerFailure = (reason) => {
8661
+ consecutiveFailures += 1;
8662
+ options.logger?.debug("reachability probe failed", {
8663
+ reason,
8664
+ consecutiveFailures,
8665
+ failuresToOffline
8666
+ });
8667
+ if (consecutiveFailures >= failuresToOffline) options.setOnline(false);
8668
+ };
8669
+ timer = setInterval(() => {
8670
+ tick();
8671
+ }, intervalMs);
8672
+ if (typeof timer === "object" && timer !== null && "unref" in timer) timer.unref();
8673
+ if (runImmediately) tick();
8674
+ return { stop: () => {
8675
+ if (stopped) return;
8676
+ stopped = true;
8677
+ if (timer !== void 0) clearInterval(timer);
8678
+ timer = void 0;
8679
+ } };
8680
+ }
8536
8681
  /**
8537
8682
  * Generic device-level status snapshot. Auto-registered by `BaseDevice`
8538
8683
  * for every device, regardless of provider — the kernel needs a uniform
@@ -9196,26 +9341,13 @@ onBrightnessChanged: { data: object({
9196
9341
  */
9197
9342
  runtimeState: BrightnessStatusSchema
9198
9343
  };
9344
+ /** Stream delivery format. (Relocated from the retired `streaming-engine` cap.) */
9199
9345
  var StreamFormatSchema = _enum([
9200
9346
  "webrtc",
9201
9347
  "hls",
9202
9348
  "mjpeg",
9203
9349
  "rtsp"
9204
9350
  ]);
9205
- var StreamInfoSchema = object({
9206
- streamId: string(),
9207
- format: StreamFormatSchema,
9208
- url: string().nullable(),
9209
- active: boolean()
9210
- });
9211
- method(object({
9212
- streamId: string(),
9213
- sourceUrl: string(),
9214
- codec: string().optional()
9215
- }), _void(), { kind: "mutation" }), method(object({ streamId: string() }), _void(), { kind: "mutation" }), method(object({
9216
- streamId: string(),
9217
- format: StreamFormatSchema
9218
- }), string().nullable()), method(_void(), array(StreamInfoSchema));
9219
9351
  var RtspRestreamEntrySchema = object({
9220
9352
  brokerId: string(),
9221
9353
  url: string(),
@@ -10083,37 +10215,7 @@ var consumablesCapability = {
10083
10215
  scope: "device",
10084
10216
  deviceNative: true,
10085
10217
  mode: "singleton",
10086
- deviceTypes: [
10087
- DeviceType.Camera,
10088
- DeviceType.Hub,
10089
- DeviceType.Light,
10090
- DeviceType.Siren,
10091
- DeviceType.Switch,
10092
- DeviceType.Sensor,
10093
- DeviceType.Thermostat,
10094
- DeviceType.Button,
10095
- DeviceType.EventEmitter,
10096
- DeviceType.Update,
10097
- DeviceType.Generic,
10098
- DeviceType.Notifier,
10099
- DeviceType.Script,
10100
- DeviceType.Automation,
10101
- DeviceType.Lock,
10102
- DeviceType.Cover,
10103
- DeviceType.Valve,
10104
- DeviceType.Humidifier,
10105
- DeviceType.WaterHeater,
10106
- DeviceType.Fan,
10107
- DeviceType.MediaPlayer,
10108
- DeviceType.AlarmPanel,
10109
- DeviceType.Control,
10110
- DeviceType.Presence,
10111
- DeviceType.Weather,
10112
- DeviceType.Vacuum,
10113
- DeviceType.LawnMower,
10114
- DeviceType.Container,
10115
- DeviceType.Image
10116
- ],
10218
+ deviceTypes: Object.values(DeviceType),
10117
10219
  deviceConfig: { ui: {
10118
10220
  kind: "widget",
10119
10221
  widgetId: "host/consumables-panel",
@@ -11706,7 +11808,6 @@ var PipelineDefaultStepSchema = lazy(() => object({
11706
11808
  enabled: boolean(),
11707
11809
  modelId: string(),
11708
11810
  children: array(PipelineDefaultStepSchema).readonly(),
11709
- engine: PipelineEngineChoiceSchema.optional(),
11710
11811
  group: string().optional(),
11711
11812
  settings: record(string(), unknown()).optional()
11712
11813
  }));
@@ -11731,7 +11832,9 @@ var PipelineModelOptionSchema = object({
11731
11832
  formats: record(string(), object({
11732
11833
  downloaded: boolean(),
11733
11834
  sizeMB: number()
11734
- }))
11835
+ })),
11836
+ group: ModelVariantGroupSchema.optional(),
11837
+ legacy: boolean().optional()
11735
11838
  });
11736
11839
  var ConfigFieldBridge = custom();
11737
11840
  var PipelineAddonSchemaSchema = object({
@@ -11782,15 +11885,42 @@ var EngineProvisioningSchema = object({
11782
11885
  ]),
11783
11886
  progress: number().optional(),
11784
11887
  error: string().optional(),
11785
- nextRetryAt: number().optional()
11888
+ nextRetryAt: number().optional(),
11889
+ /**
11890
+ * Gate A (config-correctness gate at engine change): human-readable
11891
+ * config issues surfaced EAGERLY when the node's engine changes — model
11892
+ * substitutions ("chose X, running Y") and zero-build steps ("no model
11893
+ * has a <format> build"). Additive/optional: informational only, never
11894
+ * enforced here — `assertEngineReady` (readiness) still gates inference.
11895
+ * Absent/empty when the node-default tree resolves cleanly.
11896
+ */
11897
+ configIssues: array(string()).optional()
11786
11898
  });
11787
11899
  var PipelineStepInputSchema = lazy(() => object({
11788
11900
  addonId: string(),
11789
- modelId: string(),
11901
+ modelId: string().optional(),
11790
11902
  enabled: boolean().default(true),
11791
11903
  children: array(PipelineStepInputSchema).optional(),
11792
11904
  settings: record(string(), unknown()).optional()
11793
11905
  }));
11906
+ var ModelSubstitutionSchema = object({
11907
+ addonId: string(),
11908
+ chosen: string(),
11909
+ running: string(),
11910
+ format: string()
11911
+ });
11912
+ var PipelineValidationIssueSchema = object({
11913
+ addonId: string(),
11914
+ kind: _enum(["unknown-addon", "no-format-build"]),
11915
+ detail: string()
11916
+ });
11917
+ var PipelineValidationResultSchema = object({
11918
+ ok: boolean(),
11919
+ issues: array(PipelineValidationIssueSchema).readonly(),
11920
+ substitutions: array(ModelSubstitutionSchema).readonly(),
11921
+ /** The node's `currentEngine.format` this validation ran against. */
11922
+ format: string()
11923
+ });
11794
11924
  var ReferenceImageEntrySchema = object({
11795
11925
  filename: string(),
11796
11926
  stepIds: array(string()).readonly().optional()
@@ -11861,7 +11991,13 @@ method(_void(), array(PipelineEngineChoiceSchema)), method(_void(), PipelineEngi
11861
11991
  })) }), object({ success: literal(true) }), {
11862
11992
  kind: "mutation",
11863
11993
  auth: "admin"
11864
- }), method(_void(), PipelineSchemaSchema), method(_void(), array(PipelineDefaultStepSchema).readonly().nullable()), method(_void(), PipelineConfigBridge), method(_void(), ConfigUISchemaBridge), method(_void(), array(PipelineTemplateSchema$1).readonly()), method(object({
11994
+ }), method(object({ nodeId: string() }), object({
11995
+ success: literal(true),
11996
+ regeneratedModelId: string().nullable()
11997
+ }), {
11998
+ kind: "mutation",
11999
+ auth: "admin"
12000
+ }), 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({
11865
12001
  name: string(),
11866
12002
  steps: array(PipelineTemplateStepSchema).readonly(),
11867
12003
  engine: PipelineEngineChoiceSchema
@@ -12154,6 +12290,13 @@ var RunnerFrameSourceSchema = discriminatedUnion("kind", [object({ kind: literal
12154
12290
  kind: literal("remote-restream"),
12155
12291
  /** The camera's source-owner node (slice 1: always the hub). */
12156
12292
  ownerNodeId: string(),
12293
+ /**
12294
+ * The owner's LAN-reachable host, resolved by the orchestrator from the
12295
+ * per-node `reachableHost` override (Cluster UI). When present the runner
12296
+ * dials THIS host for the owner's restream, in preference to the
12297
+ * `CAMSTACK_HUB_URL`-derived default. Absent → auto-detect fallback.
12298
+ */
12299
+ ownerReachableHost: string().optional(),
12157
12300
  /** Operator override for the owner host the runner dials. */
12158
12301
  hubHostnameOverride: string().optional()
12159
12302
  })]).describe("Per-camera frame-source mode for the runner (P2c)");
@@ -12162,13 +12305,11 @@ var RunnerFrameSourceSchema = discriminatedUnion("kind", [object({ kind: literal
12162
12305
  * specific runner instance via `attachCamera`. Carries everything the
12163
12306
  * runner needs to subscribe to the local broker and execute inference.
12164
12307
  *
12165
- * Stateless-pipeline model: the full pipeline content (`engine`, `steps`,
12166
- * optional `audio`) travels with the attach payload. The runner keeps it
12167
- * in RAM for the lifetime of the attach — on rebalance, edit, or
12168
- * restart the orchestrator re-sends the latest snapshot.
12169
- *
12170
- * `engine`/`steps`/`audio` are optional during the additive migration
12171
- * window; once orchestrator + UI are migrated they become required.
12308
+ * Stateless-pipeline model: the pipeline content (`steps`, optional
12309
+ * `audio`) travels with the attach payload. The runner keeps it in RAM
12310
+ * for the lifetime of the attach — on rebalance, edit, or restart the
12311
+ * orchestrator re-sends the latest snapshot. Engine is NOT carried: it is
12312
+ * node-local, resolved by the executing runner at dispatch time.
12172
12313
  */
12173
12314
  var RunnerCameraConfigSchema = object({
12174
12315
  deviceId: number(),
@@ -12219,14 +12360,11 @@ var RunnerCameraConfigSchema = object({
12219
12360
  */
12220
12361
  motionSources: MotionSourcesSchema.default(["analyzer"]),
12221
12362
  pipelineEnabled: boolean().default(true),
12222
- /** Engine choice for video steps (runtime+backend+format). */
12223
- engine: PipelineEngineChoiceSchema.optional(),
12224
12363
  /** Ordered tree of video steps. Absent → runner skips video detection. */
12225
12364
  steps: array(PipelineStepInputSchema).readonly().optional(),
12226
12365
  /** Audio classification branch. `enabled:false` disables, null skips. */
12227
12366
  audio: object({
12228
- engine: PipelineEngineChoiceSchema,
12229
- modelId: string(),
12367
+ modelId: string().optional(),
12230
12368
  enabled: boolean()
12231
12369
  }).nullable().optional(),
12232
12370
  /**
@@ -18089,11 +18227,11 @@ DeviceType.Camera, method(object({ deviceId: number() }), array(TrackSchema).rea
18089
18227
  timestamp: number()
18090
18228
  });
18091
18229
  var CameraPipelineConfigSchema = object({
18092
- engine: PipelineEngineChoiceSchema,
18230
+ engine: PipelineEngineChoiceSchema.optional(),
18093
18231
  steps: array(PipelineStepInputSchema).readonly(),
18094
18232
  audio: object({
18095
- engine: PipelineEngineChoiceSchema,
18096
- modelId: string(),
18233
+ engine: PipelineEngineChoiceSchema.optional(),
18234
+ modelId: string().optional(),
18097
18235
  enabled: boolean(),
18098
18236
  settings: record(string(), unknown()).readonly().optional()
18099
18237
  }).nullable().optional()
@@ -18108,7 +18246,7 @@ var PipelineTemplateSchema = object({
18108
18246
  });
18109
18247
  var AgentAddonConfigSchema = object({
18110
18248
  enabled: boolean(),
18111
- modelId: string(),
18249
+ modelId: string().optional(),
18112
18250
  settings: record(string(), unknown()).readonly()
18113
18251
  });
18114
18252
  var AgentPipelineSettingsSchema = object({
@@ -18123,7 +18261,15 @@ var AgentPipelineSettingsSchema = object({
18123
18261
  /** Node is eligible to run audio-analyzer sessions. */
18124
18262
  audio: boolean().optional(),
18125
18263
  /** Node is eligible to be the ingest / source-owner (serve the restream). */
18126
- ingest: boolean().optional()
18264
+ ingest: boolean().optional(),
18265
+ /**
18266
+ * Operator override for the LAN host a cross-node decoder dials to reach
18267
+ * THIS node's restream (Cluster UI). Absent → auto-detect: a remote runner
18268
+ * falls back to its `CAMSTACK_HUB_URL`-derived host (the Moleculer address
18269
+ * it already uses to reach the hub). Set this only when the auto-detected
18270
+ * address is wrong (multi-homed host, NAT, custom interface).
18271
+ */
18272
+ reachableHost: string().optional()
18127
18273
  });
18128
18274
  var CameraPipelineForAgentSchema = object({
18129
18275
  steps: array(PipelineStepInputSchema).readonly(),
@@ -18229,6 +18375,15 @@ var GlobalMetricsSchema = object({
18229
18375
  * capability providers.
18230
18376
  */
18231
18377
  var CapabilityBindingsSchema = record(string(), string());
18378
+ /**
18379
+ * The cluster's single camera-source owner (`clusterRoles.ingestNode`) plus
18380
+ * its LAN-reachable host, if one is registered. See `getIngestOwner`.
18381
+ */
18382
+ var IngestOwnerSchema = object({
18383
+ ownerNodeId: string(),
18384
+ reachableHost: string().optional(),
18385
+ configIssue: string().optional()
18386
+ });
18232
18387
  /** Source block — always present; derives from the stream catalog. */
18233
18388
  var CameraSourceStatusSchema = object({ streams: array(object({
18234
18389
  camStreamId: string(),
@@ -18243,6 +18398,14 @@ var CameraAssignmentStatusSchema = object({
18243
18398
  detectionNodeId: string().nullable(),
18244
18399
  decoderNodeId: string().nullable(),
18245
18400
  audioNodeId: string().nullable(),
18401
+ /**
18402
+ * The node that OWNS this camera's physical source pull (dials the RTSP and
18403
+ * hosts the broker/restream) — the cluster ingest owner today
18404
+ * (`clusterRoles.ingestNode`), per-camera once source assignment lands. Lets
18405
+ * the UI show WHERE a camera is sourced without SSH/logs, and is the node the
18406
+ * broker block below was read from (pinned). Nullable only pre-wiring.
18407
+ */
18408
+ sourceNodeId: string().nullable(),
18246
18409
  pinned: object({
18247
18410
  detection: boolean(),
18248
18411
  decoder: boolean(),
@@ -18375,7 +18538,7 @@ method(object({
18375
18538
  }), object({ success: literal(true) }), {
18376
18539
  kind: "mutation",
18377
18540
  auth: "admin"
18378
- }), method(object({
18541
+ }), method(_void(), IngestOwnerSchema), method(object({
18379
18542
  deviceId: number(),
18380
18543
  nodeId: string()
18381
18544
  }), _void(), {
@@ -18444,6 +18607,12 @@ method(object({
18444
18607
  }), object({ success: literal(true) }), {
18445
18608
  kind: "mutation",
18446
18609
  auth: "admin"
18610
+ }), method(object({
18611
+ agentNodeId: string(),
18612
+ reachableHost: string().nullable()
18613
+ }), object({ success: literal(true) }), {
18614
+ kind: "mutation",
18615
+ auth: "admin"
18447
18616
  }), method(object({ deviceId: number() }), CameraPipelineSettingsSchema.nullable()), method(object({
18448
18617
  deviceId: number(),
18449
18618
  addonId: string(),
@@ -18488,22 +18657,6 @@ method(object({
18488
18657
  kind: "mutation",
18489
18658
  auth: "admin"
18490
18659
  });
18491
- var RegisteredStreamSchema = object({
18492
- streamId: string(),
18493
- label: string().optional(),
18494
- codec: string(),
18495
- type: _enum(["video", "audio"]),
18496
- sourceUrl: string()
18497
- });
18498
- var ExposedResourceSchema = object({
18499
- streamId: string(),
18500
- format: string(),
18501
- value: string()
18502
- });
18503
- method(object({
18504
- deviceId: number(),
18505
- streams: array(RegisteredStreamSchema).readonly()
18506
- }), _void(), { kind: "mutation" }), method(object({ deviceId: number() }), _void(), { kind: "mutation" }), method(object({ deviceId: number() }), array(ExposedResourceSchema).readonly());
18507
18660
  /**
18508
18661
  * Query filter for settings-store collections.
18509
18662
  */
@@ -18656,9 +18809,9 @@ method(SendEmailInputSchema, SendEmailResultSchema, {
18656
18809
  /**
18657
18810
  * A single device snapshot returned as base64 JPEG/PNG.
18658
18811
  *
18659
- * Shared with the `snapshot-provider` collection cap the orchestrator
18660
- * receives the same shape from each native provider and from the
18661
- * broker-based fallback.
18812
+ * The `SnapshotAddon` wrapper returns this shape whether the frame came from
18813
+ * the device-native provider (onboard capture) or from the stream-broker
18814
+ * prebuffer fallback.
18662
18815
  */
18663
18816
  var SnapshotImageSchema = object({
18664
18817
  base64: string(),
@@ -18733,10 +18886,6 @@ var snapshotCapability = {
18733
18886
  kind: "poll"
18734
18887
  }
18735
18888
  };
18736
- method(object({ deviceId: number() }), boolean()), method(object({
18737
- deviceId: number(),
18738
- streamId: string().optional()
18739
- }), SnapshotImageSchema.nullable());
18740
18889
  /**
18741
18890
  * `sso-bridge` — internal hub-only cap that lets SSO-style auth
18742
18891
  * providers (OIDC, SAML, magic-link, …) mint an HMAC-signed token
@@ -19097,9 +19246,10 @@ method(object({
19097
19246
  auth: "admin"
19098
19247
  });
19099
19248
  /**
19100
- * Optional client-side hints sent at session creation to help the
19101
- * provider pick the best native source. All fields are optional —
19102
- * a viewer that knows nothing still gets a sane default.
19249
+ * Optional client-side hints sent at session creation to help the provider
19250
+ * pick the best native source. All fields optional — a viewer that knows
19251
+ * nothing still gets a sane default. (Relocated from the retired `webrtc`
19252
+ * collection cap; this `webrtc-session` cap is the live signaling surface.)
19103
19253
  */
19104
19254
  var webrtcClientHintsSchema = object({
19105
19255
  viewportWidth: number().int().positive().optional(),
@@ -19110,22 +19260,6 @@ var webrtcClientHintsSchema = object({
19110
19260
  /** Hard tier override; takes precedence over scoring when registered. */
19111
19261
  prefersTier: string().optional()
19112
19262
  }).partial();
19113
- method(object({
19114
- streamId: string(),
19115
- sdpOffer: string()
19116
- }), string(), { kind: "mutation" }), method(object({ streamId: string() }), boolean()), method(object({
19117
- streamId: string(),
19118
- codec: string()
19119
- }), _void(), { kind: "mutation" }), method(object({ streamId: string() }), _void(), { kind: "mutation" }), method(object({
19120
- streamId: string(),
19121
- hints: webrtcClientHintsSchema.optional()
19122
- }), object({
19123
- sessionId: string(),
19124
- sdpOffer: string()
19125
- }), { kind: "mutation" }), method(object({
19126
- sessionId: string(),
19127
- sdpAnswer: string()
19128
- }), _void(), { kind: "mutation" }), method(object({ sessionId: string() }), _void(), { kind: "mutation" }), method(object({ streamId: string() }), boolean());
19129
19263
  /**
19130
19264
  * Discriminated target for a WebRTC session. The client sends this
19131
19265
  * structured object instead of building / parsing brokerId strings;
@@ -24181,6 +24315,12 @@ Object.freeze({
24181
24315
  addonId: null,
24182
24316
  access: "create"
24183
24317
  },
24318
+ "pipelineExecutor.resetToDefault": {
24319
+ capName: "pipeline-executor",
24320
+ capScope: "system",
24321
+ addonId: null,
24322
+ access: "delete"
24323
+ },
24184
24324
  "pipelineExecutor.runAudioTest": {
24185
24325
  capName: "pipeline-executor",
24186
24326
  capScope: "system",
@@ -24229,6 +24369,12 @@ Object.freeze({
24229
24369
  addonId: null,
24230
24370
  access: "create"
24231
24371
  },
24372
+ "pipelineExecutor.validatePipeline": {
24373
+ capName: "pipeline-executor",
24374
+ capScope: "system",
24375
+ addonId: null,
24376
+ access: "view"
24377
+ },
24232
24378
  "pipelineOrchestrator.assignAudio": {
24233
24379
  capName: "pipeline-orchestrator",
24234
24380
  capScope: "system",
@@ -24337,6 +24483,12 @@ Object.freeze({
24337
24483
  addonId: null,
24338
24484
  access: "view"
24339
24485
  },
24486
+ "pipelineOrchestrator.getIngestOwner": {
24487
+ capName: "pipeline-orchestrator",
24488
+ capScope: "system",
24489
+ addonId: null,
24490
+ access: "view"
24491
+ },
24340
24492
  "pipelineOrchestrator.getPipelineAssignment": {
24341
24493
  capName: "pipeline-orchestrator",
24342
24494
  capScope: "system",
@@ -24409,6 +24561,12 @@ Object.freeze({
24409
24561
  addonId: null,
24410
24562
  access: "create"
24411
24563
  },
24564
+ "pipelineOrchestrator.setAgentReachableHost": {
24565
+ capName: "pipeline-orchestrator",
24566
+ capScope: "system",
24567
+ addonId: null,
24568
+ access: "create"
24569
+ },
24412
24570
  "pipelineOrchestrator.setCameraPipelineForAgent": {
24413
24571
  capName: "pipeline-orchestrator",
24414
24572
  capScope: "system",
@@ -24739,24 +24897,6 @@ Object.freeze({
24739
24897
  addonId: null,
24740
24898
  access: "create"
24741
24899
  },
24742
- "restreamer.getExposedResources": {
24743
- capName: "restreamer",
24744
- capScope: "system",
24745
- addonId: null,
24746
- access: "view"
24747
- },
24748
- "restreamer.registerDevice": {
24749
- capName: "restreamer",
24750
- capScope: "system",
24751
- addonId: null,
24752
- access: "create"
24753
- },
24754
- "restreamer.unregisterDevice": {
24755
- capName: "restreamer",
24756
- capScope: "system",
24757
- addonId: null,
24758
- access: "delete"
24759
- },
24760
24900
  "scriptRunner.run": {
24761
24901
  capName: "script-runner",
24762
24902
  capScope: "device",
@@ -24859,18 +24999,6 @@ Object.freeze({
24859
24999
  addonId: null,
24860
25000
  access: "create"
24861
25001
  },
24862
- "snapshotProvider.getSnapshot": {
24863
- capName: "snapshot-provider",
24864
- capScope: "system",
24865
- addonId: null,
24866
- access: "view"
24867
- },
24868
- "snapshotProvider.supportsDevice": {
24869
- capName: "snapshot-provider",
24870
- capScope: "system",
24871
- addonId: null,
24872
- access: "view"
24873
- },
24874
25002
  "ssoBridge.signBridgeToken": {
24875
25003
  capName: "sso-bridge",
24876
25004
  capScope: "system",
@@ -25297,30 +25425,6 @@ Object.freeze({
25297
25425
  addonId: null,
25298
25426
  access: "view"
25299
25427
  },
25300
- "streamingEngine.getStreamUrl": {
25301
- capName: "streaming-engine",
25302
- capScope: "system",
25303
- addonId: null,
25304
- access: "view"
25305
- },
25306
- "streamingEngine.listStreams": {
25307
- capName: "streaming-engine",
25308
- capScope: "system",
25309
- addonId: null,
25310
- access: "view"
25311
- },
25312
- "streamingEngine.registerStream": {
25313
- capName: "streaming-engine",
25314
- capScope: "system",
25315
- addonId: null,
25316
- access: "create"
25317
- },
25318
- "streamingEngine.unregisterStream": {
25319
- capName: "streaming-engine",
25320
- capScope: "system",
25321
- addonId: null,
25322
- access: "delete"
25323
- },
25324
25428
  "streamParams.getConfigSchema": {
25325
25429
  capName: "stream-params",
25326
25430
  capScope: "device",
@@ -25687,54 +25791,6 @@ Object.freeze({
25687
25791
  addonId: null,
25688
25792
  access: "create"
25689
25793
  },
25690
- "webrtc.closeSession": {
25691
- capName: "webrtc",
25692
- capScope: "system",
25693
- addonId: null,
25694
- access: "create"
25695
- },
25696
- "webrtc.createSession": {
25697
- capName: "webrtc",
25698
- capScope: "system",
25699
- addonId: null,
25700
- access: "create"
25701
- },
25702
- "webrtc.handleAnswer": {
25703
- capName: "webrtc",
25704
- capScope: "system",
25705
- addonId: null,
25706
- access: "create"
25707
- },
25708
- "webrtc.handleOffer": {
25709
- capName: "webrtc",
25710
- capScope: "system",
25711
- addonId: null,
25712
- access: "create"
25713
- },
25714
- "webrtc.hasAdaptiveBitrate": {
25715
- capName: "webrtc",
25716
- capScope: "system",
25717
- addonId: null,
25718
- access: "view"
25719
- },
25720
- "webrtc.registerStream": {
25721
- capName: "webrtc",
25722
- capScope: "system",
25723
- addonId: null,
25724
- access: "create"
25725
- },
25726
- "webrtc.supportsStream": {
25727
- capName: "webrtc",
25728
- capScope: "system",
25729
- addonId: null,
25730
- access: "view"
25731
- },
25732
- "webrtc.unregisterStream": {
25733
- capName: "webrtc",
25734
- capScope: "system",
25735
- addonId: null,
25736
- access: "delete"
25737
- },
25738
25794
  "webrtcSession.addIceCandidate": {
25739
25795
  capName: "webrtc-session",
25740
25796
  capScope: "device",
@@ -25948,6 +26004,72 @@ function maskUrlCredentials(rawUrl) {
25948
26004
  return rawUrl;
25949
26005
  }
25950
26006
  }
26007
+ //#endregion
26008
+ //#region src/rtsp-reachability.ts
26009
+ /**
26010
+ * Control-plane reachability probe for a generic RTSP camera.
26011
+ *
26012
+ * Unlike Hikvision/Amcrest/ONVIF, an RTSP source exposes NO management API —
26013
+ * we only have an `rtsp://` URL. Reachability is therefore probed at the RTSP
26014
+ * control channel itself:
26015
+ *
26016
+ * 1. open a TCP connection to the RTSP `host:port` (default 554);
26017
+ * 2. best-effort send an RTSP `OPTIONS` request;
26018
+ * 3. treat an `RTSP/…` response OR a successful TCP handshake (even without
26019
+ * a response before the deadline) as REACHABLE.
26020
+ *
26021
+ * A successful TCP handshake already proves the camera's RTSP port is live —
26022
+ * the `OPTIONS` round-trip is a stronger confirmation when the server answers
26023
+ * promptly, but we deliberately fall back to the bare connect so a camera that
26024
+ * gates `OPTIONS` behind auth (or is simply slow to answer) is not wrongly
26025
+ * marked offline.
26026
+ *
26027
+ * The socket is always destroyed; the probe never throws and resolves within
26028
+ * `timeoutMs`.
26029
+ */
26030
+ var DEFAULT_RTSP_PORT = 554;
26031
+ /** Parse `host` + `port` out of an `rtsp://` URL. Credentials/path ignored. */
26032
+ function parseRtspTarget(url) {
26033
+ try {
26034
+ const parsed = new URL(url);
26035
+ if (!parsed.hostname) return null;
26036
+ const port = parsed.port ? Number.parseInt(parsed.port, 10) : DEFAULT_RTSP_PORT;
26037
+ if (!Number.isFinite(port) || port <= 0 || port > 65535) return null;
26038
+ return {
26039
+ host: parsed.hostname,
26040
+ port
26041
+ };
26042
+ } catch {
26043
+ return null;
26044
+ }
26045
+ }
26046
+ async function probeRtspReachable(url, timeoutMs) {
26047
+ const target = parseRtspTarget(url);
26048
+ if (!target) return false;
26049
+ return new Promise((resolve) => {
26050
+ let settled = false;
26051
+ let connected = false;
26052
+ const socket = new net.Socket();
26053
+ const finish = (reachable) => {
26054
+ if (settled) return;
26055
+ settled = true;
26056
+ socket.destroy();
26057
+ resolve(reachable);
26058
+ };
26059
+ socket.setTimeout(timeoutMs);
26060
+ socket.once("timeout", () => finish(connected));
26061
+ socket.once("error", () => finish(false));
26062
+ socket.once("connect", () => {
26063
+ connected = true;
26064
+ const request = `OPTIONS ${url} RTSP/1.0\r\nCSeq: 1\r\nUser-Agent: camstack-reachability\r\n\r\n`;
26065
+ socket.write(request, () => {});
26066
+ });
26067
+ socket.once("data", (buf) => {
26068
+ finish(buf.toString("latin1").startsWith("RTSP/") || connected);
26069
+ });
26070
+ socket.connect(target.port, target.host);
26071
+ });
26072
+ }
25951
26073
  var rtspCameraSchema = object({
25952
26074
  streams: preprocess((raw) => {
25953
26075
  if (!Array.isArray(raw)) return raw;
@@ -25985,15 +26107,44 @@ var legacyStreamSchema = object({
25985
26107
  label: CamProfileSchema,
25986
26108
  url: string()
25987
26109
  });
25988
- var RtspCamera = class extends BaseDevice {
26110
+ var RtspCamera = class RtspCamera extends BaseDevice {
25989
26111
  type = DeviceType.Camera;
25990
26112
  features = [];
26113
+ /** Control-plane reachability poll — an RTSP `OPTIONS`/TCP-connect probe to
26114
+ * the camera's RTSP port every 30s drives `device.online`, decoupled from
26115
+ * stream-broker video health. Started in `onActivate`, stopped in
26116
+ * `removeDevice`. */
26117
+ reachabilityPoll = null;
25991
26118
  constructor(ctx) {
25992
26119
  super(ctx, rtspCameraSchema, { type: DeviceType.Camera });
25993
26120
  this.migrateLegacyStreamsIfNeeded(ctx.persistedConfig ?? {});
25994
26121
  this.registerSnapshotProvider();
25995
26122
  this.registerStreamCatalogProvider();
25996
26123
  }
26124
+ /** Phase 5 — device is live: begin the control-plane reachability poll. */
26125
+ async onActivate() {
26126
+ this.startReachabilityPolling();
26127
+ }
26128
+ /** Start the RTSP reachability poll: a lightweight `OPTIONS`/TCP-connect
26129
+ * probe to the first configured stream's `host:port` every 30s drives
26130
+ * `device.online`, with hysteresis. Idempotent. */
26131
+ startReachabilityPolling() {
26132
+ if (this.reachabilityPoll) return;
26133
+ this.reachabilityPoll = startReachabilityPoll({
26134
+ probe: async () => {
26135
+ const url = this.config.get("streams")[0]?.url;
26136
+ if (!url) return false;
26137
+ return probeRtspReachable(url, RtspCamera.RTSP_PROBE_TIMEOUT_MS);
26138
+ },
26139
+ setOnline: (online) => {
26140
+ this.markOnline(online);
26141
+ },
26142
+ isEnabled: () => !this.disabled,
26143
+ logger: this.ctx.logger
26144
+ });
26145
+ }
26146
+ /** Per-probe TCP/RTSP round-trip budget — a dead host resolves as a miss. */
26147
+ static RTSP_PROBE_TIMEOUT_MS = 5e3;
25997
26148
  /**
25998
26149
  * Build this camera's pull-able stream catalog — the descriptors the
25999
26150
  * stream-broker needs to dial. Single source of truth feeding the
@@ -26084,6 +26235,8 @@ var RtspCamera = class extends BaseDevice {
26084
26235
  }
26085
26236
  async removeDevice() {
26086
26237
  this.ctx.logger.info("Removing RTSP camera", { meta: { stableId: this.stableId } });
26238
+ this.reachabilityPoll?.stop();
26239
+ this.reachabilityPoll = null;
26087
26240
  const streams = this.config.get("streams");
26088
26241
  await Promise.all(streams.map((s) => this.ctx.api.streamBroker.retractCameraStream.mutate({
26089
26242
  deviceId: this.id,
@@ -26361,21 +26514,7 @@ var RtspProviderAddon = class extends BaseDeviceProvider {
26361
26514
  super({});
26362
26515
  }
26363
26516
  async onInitialize() {
26364
- const regs = await super.onInitialize();
26365
- this.subscribe({ category: EventCategory.DeviceStateChanged }, (event) => {
26366
- const data = event.data;
26367
- if (data.capName !== "camera-streams") return;
26368
- const deviceId = data.deviceId;
26369
- if (typeof deviceId !== "number") return;
26370
- const registry = this.ctx.kernel.deviceRegistry;
26371
- if (!registry) return;
26372
- if (registry.getAddonId(deviceId) !== this.addonId) return;
26373
- const device = registry.getById(deviceId);
26374
- if (!device) return;
26375
- const online = data.slice?.online === true;
26376
- if (device.online !== online) device.online = online;
26377
- });
26378
- return regs;
26517
+ return await super.onInitialize();
26379
26518
  }
26380
26519
  async onGetCreationSchema(type) {
26381
26520
  if (type !== DeviceType.Camera) return null;