@camstack/addon-provider-rtsp 0.1.13 → 0.1.14

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/index.js CHANGED
@@ -20,303 +20,569 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
20
20
  // src/index.ts
21
21
  var index_exports = {};
22
22
  __export(index_exports, {
23
- RtspDevice: () => RtspDevice,
24
- RtspProvider: () => RtspProvider,
25
- RtspProviderAddon: () => RtspProviderAddon
23
+ RtspCamera: () => RtspCamera,
24
+ RtspProviderAddon: () => RtspProviderAddon,
25
+ rtspCameraSchema: () => rtspCameraSchema
26
26
  });
27
27
  module.exports = __toCommonJS(index_exports);
28
28
 
29
- // src/rtsp-device.ts
29
+ // src/addon.ts
30
+ var import_types2 = require("@camstack/types");
31
+
32
+ // src/rtsp-camera.ts
33
+ var import_zod = require("zod");
30
34
  var import_types = require("@camstack/types");
31
- var RtspDevice = class {
32
- id;
33
- name;
34
- providerId;
35
+ var streamEntrySchema = import_zod.z.object({
36
+ id: import_zod.z.string().regex(/^stream-\d+$/).describe("Stable slot id (stream-1/2/3)"),
37
+ url: import_zod.z.string().describe("RTSP stream URL"),
38
+ profileHint: import_zod.z.enum(["high", "mid", "low"]).optional().describe("Provider-suggested profile \u2014 advisory only"),
39
+ resolution: import_zod.z.object({ width: import_zod.z.number().int().positive(), height: import_zod.z.number().int().positive() }).optional().describe("Probed resolution, if known")
40
+ });
41
+ var streamsField = import_zod.z.preprocess(
42
+ (raw) => {
43
+ if (!Array.isArray(raw)) return raw;
44
+ return raw.map((entry, i) => {
45
+ if (!entry || typeof entry !== "object") return entry;
46
+ const o = entry;
47
+ if (typeof o["id"] === "string") return entry;
48
+ if (typeof o["label"] === "string" && typeof o["url"] === "string") {
49
+ return {
50
+ id: `stream-${i + 1}`,
51
+ url: o["url"],
52
+ profileHint: o["label"]
53
+ };
54
+ }
55
+ return entry;
56
+ });
57
+ },
58
+ import_zod.z.array(streamEntrySchema).min(1)
59
+ );
60
+ var rtspCameraSchema = import_zod.z.object({
61
+ // `name` is now base meta (DB column on `device-meta`, not in this
62
+ // hardware-config schema). Read via `this.name` (resolved by
63
+ // `BaseDevice` from `ctx.deviceMeta.name`); mutated via
64
+ // `kernel.devices.setName(id, name)`.
65
+ streams: streamsField.describe("Published RTSP streams"),
66
+ snapshotUrl: import_zod.z.string().default("").describe("Snapshot URL (optional)"),
67
+ username: import_zod.z.string().default("").describe("Username"),
68
+ password: import_zod.z.string().default("").describe("Password")
69
+ });
70
+ var legacyStreamSchema = import_zod.z.object({
71
+ label: import_zod.z.enum(["high", "mid", "low"]),
72
+ url: import_zod.z.string()
73
+ });
74
+ var RtspCamera = class extends import_types.BaseDevice {
35
75
  type = import_types.DeviceType.Camera;
36
- capabilities;
37
- ctx;
38
- stableId;
39
- settings;
40
- capabilityMap = /* @__PURE__ */ new Map();
41
- constructor(device, settings, ctx) {
42
- this.id = device.id;
43
- this.stableId = device.stableId;
44
- this.name = device.name;
45
- this.providerId = device.integrationId;
46
- this.settings = settings;
47
- this.ctx = ctx;
48
- this.capabilities = ["camera"];
49
- this.capabilityMap.set("camera", this.createCamera());
76
+ features = [];
77
+ constructor(ctx) {
78
+ super(
79
+ ctx,
80
+ rtspCameraSchema,
81
+ { type: import_types.DeviceType.Camera }
82
+ );
83
+ this.migrateLegacyStreamsIfNeeded(ctx.persistedConfig ?? {});
84
+ this.registerSnapshotProvider();
50
85
  }
51
- getCapability(cap) {
52
- return this.capabilityMap.get(cap) ?? null;
53
- }
54
- hasCapability(cap) {
55
- return this.capabilityMap.has(cap);
56
- }
57
- getState() {
58
- return { online: true };
86
+ /**
87
+ * Detect legacy `{label, url}` stream shapes and reshape to
88
+ * `{id, url, profileHint}`. Persists via `config.setAll` once, so
89
+ * subsequent boots find the new shape and this branch is inert.
90
+ *
91
+ * Runs fire-and-forget — the constructor can't await. The reshape is
92
+ * additive (ids are newly minted) so a failed persist is recoverable
93
+ * on next boot.
94
+ */
95
+ migrateLegacyStreamsIfNeeded(initialData) {
96
+ const raw = initialData["streams"];
97
+ if (!Array.isArray(raw)) return;
98
+ if (raw.every((s) => s && typeof s === "object" && typeof s["id"] === "string")) {
99
+ return;
100
+ }
101
+ const migrated = [];
102
+ let nextIdx = 1;
103
+ for (const entry of raw) {
104
+ const parsed = legacyStreamSchema.safeParse(entry);
105
+ if (!parsed.success) continue;
106
+ migrated.push({
107
+ id: `stream-${nextIdx++}`,
108
+ url: parsed.data.url,
109
+ profileHint: parsed.data.label
110
+ });
111
+ }
112
+ if (migrated.length === 0) return;
113
+ const snapshotUrl = this.config.get("snapshotUrl") ?? "";
114
+ const username = this.config.get("username") ?? "";
115
+ const password = this.config.get("password") ?? "";
116
+ this.ctx.logger.info("Migrating legacy {label,url} streams to {id,url,profileHint}", {
117
+ tags: { stableId: this.stableId },
118
+ meta: { streamCount: migrated.length }
119
+ });
120
+ void this.config.setAll({ streams: migrated, snapshotUrl, username, password }).catch((err) => {
121
+ this.ctx.logger.warn("Legacy stream migration failed", {
122
+ tags: { stableId: this.stableId },
123
+ meta: { error: err instanceof Error ? err.message : String(err) }
124
+ });
125
+ });
59
126
  }
60
- getMetadata() {
61
- return { manufacturer: "Generic RTSP" };
127
+ /**
128
+ * Publish every configured stream to the system stream-broker as a
129
+ * `pull-rtsp` cam stream. Idempotent — the broker's publishCameraStream
130
+ * entry is keyed by `(deviceId, camStreamId)` so callers may invoke
131
+ * this at will (e.g. on stream-broker restart). Fire-and-forget safe.
132
+ */
133
+ /**
134
+ * Lifecycle hook fired by the kernel after registration. Publishes
135
+ * the camera's streams to the broker — the kernel doesn't know
136
+ * about the broker, but the camera does. Best-effort: if the
137
+ * broker isn't ready, the provider's `system.ready-state`
138
+ * subscription re-publishes on the next ready fire.
139
+ */
140
+ async onActivate() {
141
+ await this.publishToBroker().catch((err) => {
142
+ this.ctx.logger.warn("publishToBroker on activate failed \u2014 will retry on broker ready", {
143
+ meta: { error: err instanceof Error ? err.message : String(err) }
144
+ });
145
+ });
62
146
  }
63
- createCamera() {
64
- const mainUrl = String(this.settings["main_stream_url"] ?? "");
65
- const subUrl = String(this.settings["sub_stream_url"] ?? "");
66
- const snapshotUrl = String(this.settings["snapshot_url"] ?? "");
67
- const deviceId = this.id;
68
- return {
69
- kind: "camera",
70
- async getSnapshot() {
71
- if (snapshotUrl) {
72
- const res = await fetch(snapshotUrl);
73
- return Buffer.from(await res.arrayBuffer());
74
- }
75
- return Buffer.alloc(0);
76
- },
77
- async getStreamOptions() {
78
- const options = [
79
- {
80
- id: `${deviceId}_main`,
81
- label: "Main",
82
- protocol: "rtsp",
83
- quality: "main",
84
- url: mainUrl
85
- }
86
- ];
87
- if (subUrl) {
88
- options.push({
89
- id: `${deviceId}_sub`,
90
- label: "Sub",
91
- protocol: "rtsp",
92
- quality: "sub",
93
- url: subUrl
94
- });
95
- }
96
- return options;
97
- },
98
- async getStreamUrl(option) {
99
- return option.url ?? mainUrl;
100
- },
101
- getConnectionMode() {
102
- return "always-on";
103
- },
104
- async setConnectionMode() {
147
+ async publishToBroker() {
148
+ const streams = this.config.get("streams");
149
+ for (const [i, s] of streams.entries()) {
150
+ try {
151
+ await this.ctx.api.streamBroker.publishCameraStream.mutate({
152
+ deviceId: this.id,
153
+ camStreamId: s.id,
154
+ kind: "pull-rtsp",
155
+ url: s.url,
156
+ ...s.resolution ? { resolution: s.resolution } : {},
157
+ label: `Stream ${i + 1}`
158
+ });
159
+ } catch (err) {
160
+ this.ctx.logger.warn("publishCameraStream failed", {
161
+ tags: { deviceId: this.id, camStreamId: s.id },
162
+ meta: { error: err instanceof Error ? err.message : String(err) }
163
+ });
105
164
  }
106
- };
107
- }
108
- };
109
-
110
- // src/rtsp-provider.ts
111
- function sanitizeStableId(name) {
112
- return name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "") || `rtsp-${Date.now()}`;
113
- }
114
- var RtspProvider = class {
115
- id;
116
- type = "rtsp";
117
- name;
118
- discoveryMode = "manual";
119
- ctx;
120
- integration;
121
- registry;
122
- devices = [];
123
- constructor(integration, registry, ctx) {
124
- this.id = integration.id;
125
- this.name = integration.name;
126
- this.integration = integration;
127
- this.registry = registry;
128
- this.ctx = ctx;
129
- }
130
- async start() {
131
- const registeredDevices = this.registry.listDevices(this.integration.id);
132
- for (const device of registeredDevices) {
133
- if (!device.enabled) continue;
134
- const settings = this.registry.getDeviceSettings(device.id);
135
- this.devices.push(this.buildDevice(device, settings));
136
165
  }
137
- this.ctx.logger.info(`RTSP provider started with ${this.devices.length} cameras`);
138
- }
139
- async stop() {
140
- this.devices.length = 0;
141
- this.ctx.logger.info("RTSP provider stopped");
142
- }
143
- getStatus() {
144
- return { connected: true, deviceCount: this.devices.length };
145
166
  }
146
- async discoverDevices() {
147
- return [];
167
+ /**
168
+ * Thin compat shim: reflects the stored `{id, url, profileHint?}`
169
+ * streams into the legacy `StreamSourceEntry` shape so
170
+ * `ICameraDevice`-aware consumers (device-manager legacy paths,
171
+ * device-cap-proxy fallback) still find data. The broker itself does
172
+ * NOT read this anymore — it consumes `publishCameraStream`. This
173
+ * shim goes away when every consumer migrates (C5/C7).
174
+ */
175
+ async getStreamSources() {
176
+ const streams = this.config.get("streams");
177
+ return streams.map((s) => ({
178
+ id: s.id,
179
+ label: s.profileHint ?? s.id,
180
+ protocol: "rtsp",
181
+ url: s.url,
182
+ ...s.profileHint ? { profileHint: s.profileHint } : {},
183
+ ...s.resolution ? { resolution: s.resolution } : {}
184
+ }));
148
185
  }
149
- getDevices() {
150
- return [...this.devices];
186
+ async removeDevice() {
187
+ this.ctx.logger.info("Removing RTSP camera", { meta: { stableId: this.stableId } });
188
+ const streams = this.config.get("streams");
189
+ await Promise.all(
190
+ streams.map(
191
+ (s) => this.ctx.api.streamBroker.retractCameraStream.mutate({ deviceId: this.id, camStreamId: s.id }).catch((err) => {
192
+ this.ctx.logger.warn("retractCameraStream failed", {
193
+ tags: { deviceId: this.id, camStreamId: s.id },
194
+ meta: { error: err instanceof Error ? err.message : String(err) }
195
+ });
196
+ })
197
+ )
198
+ );
151
199
  }
152
- getDeviceConfigSchema() {
153
- return {
200
+ // ── Driver-authored settings UI ────────────────────────────────────────
201
+ //
202
+ // Streams are stored as `Array<{id, url, profileHint?, resolution?}>`
203
+ // but the settings UI exposes them as a `multiple` probe field of raw
204
+ // URLs — the form only cares about the ordered list of URLs, the ids
205
+ // and classification live in storage. Values are projected from
206
+ // storage into the flat form keys referenced by the schema.
207
+ getSettingsUISchema() {
208
+ const schema = {
154
209
  sections: [
210
+ // The `name` field is now base meta — rendered by the
211
+ // device-manager's own settings contribution (with the
212
+ // location field), NOT by the driver's hardware-config
213
+ // schema. Driver UIs only carry technical knobs.
155
214
  {
156
- id: "device-info",
157
- title: "Device Configuration",
158
- description: "Configure the RTSP camera connection",
215
+ id: "streams",
216
+ title: "RTSP Streams",
217
+ description: "Provide 1\u20133 RTSP URLs for this camera. After save, each URL is probed and the stream-broker assigns the (high / mid / low) profiles by resolution (highest pixel count \u2192 high). Input slot order in this form does not affect the broker profile mapping.",
159
218
  columns: 1,
160
219
  fields: [
161
220
  {
162
- type: "text",
163
- key: "name",
164
- label: "Device Name",
165
- placeholder: "e.g. Front Door Camera",
166
- required: true
167
- },
168
- {
169
- type: "text",
170
- key: "main_stream_url",
171
- label: "Main Stream URL",
172
- placeholder: "rtsp://192.168.1.100:554/stream1",
221
+ type: "probe",
222
+ key: "streams",
223
+ label: "RTSP stream URL",
224
+ required: true,
225
+ placeholder: "rtsp://user:pass@host:554/Streaming/Channels/101",
173
226
  inputType: "url",
174
- required: true
175
- },
176
- {
177
- type: "text",
178
- key: "sub_stream_url",
179
- label: "Sub Stream URL",
180
- placeholder: "rtsp://192.168.1.100:554/stream2",
181
- inputType: "url"
182
- },
183
- {
184
- type: "text",
185
- key: "snapshot_url",
186
- label: "Snapshot URL",
187
- placeholder: "http://192.168.1.100/snapshot.jpg",
188
- inputType: "url"
227
+ multiple: {
228
+ min: 1,
229
+ max: 3,
230
+ addLabel: "Add stream",
231
+ itemLabel: "Stream ${n}",
232
+ itemDefault: ""
233
+ }
189
234
  }
190
235
  ]
236
+ },
237
+ {
238
+ id: "snapshot",
239
+ title: "Snapshot",
240
+ columns: 1,
241
+ fields: [
242
+ { type: "probe", key: "snapshotUrl", label: "Snapshot URL", description: "Optional HTTP(S) endpoint that returns a JPEG still.", placeholder: "http://host/ISAPI/Streaming/channels/101/picture", inputType: "url" }
243
+ ]
244
+ },
245
+ {
246
+ id: "credentials",
247
+ title: "Credentials",
248
+ description: "Optional. If the RTSP URL already contains user:pass@ you can leave these empty.",
249
+ columns: 2,
250
+ fields: [
251
+ { type: "text", key: "username", label: "Username" },
252
+ { type: "password", key: "password", label: "Password", showToggle: true }
253
+ ]
191
254
  }
192
255
  ]
193
256
  };
257
+ return (0, import_types.hydrateSchema)(schema, this.collectFormValues());
194
258
  }
195
- async createDevice(config) {
196
- const name = String(config["name"] ?? "").trim();
197
- if (!name) throw new Error("Device name is required");
198
- const mainStreamUrl = String(config["main_stream_url"] ?? "").trim();
199
- if (!mainStreamUrl) throw new Error("Main stream URL is required");
200
- const stableId = sanitizeStableId(name);
201
- const existing = this.registry.getDeviceByStableId(stableId);
202
- if (existing) throw new Error(`A device with name "${name}" already exists (stableId: ${stableId})`);
203
- const device = this.registry.createDevice({
204
- integrationId: this.integration.id,
205
- stableId,
206
- type: "camera",
207
- name,
208
- enabled: true,
209
- info: {},
210
- settings: {
211
- main_stream_url: mainStreamUrl,
212
- sub_stream_url: config["sub_stream_url"] ? String(config["sub_stream_url"]) : "",
213
- snapshot_url: config["snapshot_url"] ? String(config["snapshot_url"]) : ""
214
- }
259
+ /**
260
+ * Project stored config into the flat UI keys referenced by the schema.
261
+ * Form only sees URLs the internal id/profileHint/resolution stay
262
+ * invisible to the operator (the broker decides profiles from probed
263
+ * metadata at save time).
264
+ */
265
+ collectFormValues() {
266
+ const stored = this.config.get("streams");
267
+ const sorted = [...stored].sort((a, b) => a.id.localeCompare(b.id, "en", { numeric: true }));
268
+ const urls = sorted.map((s) => s.url).filter((u) => u.length > 0);
269
+ return {
270
+ streams: urls,
271
+ snapshotUrl: this.config.get("snapshotUrl"),
272
+ username: this.config.get("username"),
273
+ password: this.config.get("password")
274
+ };
275
+ }
276
+ async applySettingsPatch(patch) {
277
+ const current = this.collectFormValues();
278
+ const merged = { ...current, ...patch };
279
+ const pickStr = (k) => typeof merged[k] === "string" ? merged[k].trim() : "";
280
+ const next = {};
281
+ const rawStreams = merged["streams"];
282
+ const incomingUrls = Array.isArray(rawStreams) ? rawStreams.filter((s) => typeof s === "string").map((s) => s.trim()).filter((s) => s.length > 0) : [];
283
+ if (incomingUrls.length === 0) {
284
+ next.streams = this.config.get("streams");
285
+ } else {
286
+ const probed = await this.probeAndClassify(incomingUrls.slice(0, 3));
287
+ next.streams = probed;
288
+ }
289
+ next.snapshotUrl = pickStr("snapshotUrl");
290
+ next.username = pickStr("username");
291
+ next.password = typeof merged["password"] === "string" ? merged["password"] : this.config.get("password");
292
+ await this.config.setAll(next);
293
+ await this.publishToBroker();
294
+ }
295
+ /**
296
+ * Probe a list of RTSP URLs through the kernel stream-probe and return
297
+ * the new `{id, url, profileHint?, resolution?}` entries. Ids are
298
+ * assigned `stream-1/2/3` in input order so the UI's slot ordering is
299
+ * preserved across edits. The broker is in charge of the actual
300
+ * (high/mid/low) profile assignment via `computeInitialAssignment`.
301
+ */
302
+ async probeAndClassify(urls) {
303
+ const streamProbe = this.ctx.streamProbe;
304
+ if (!streamProbe) {
305
+ this.ctx.logger.warn(
306
+ "[rtsp-edit] ctx.streamProbe unavailable \u2014 falling back to input-order profile assignment",
307
+ { meta: { streamCount: urls.length } }
308
+ );
309
+ const sequence = urls.length === 1 ? ["high"] : urls.length === 2 ? ["high", "low"] : ["high", "mid", "low"];
310
+ return urls.map((url, i) => ({
311
+ id: `stream-${i + 1}`,
312
+ url,
313
+ profileHint: sequence[i]
314
+ }));
315
+ }
316
+ const probed = await Promise.all(
317
+ urls.map(async (url) => {
318
+ try {
319
+ const metadata = await streamProbe.probe(url, { force: false });
320
+ return { url, metadata };
321
+ } catch (err) {
322
+ this.ctx.logger.warn(
323
+ "[rtsp-edit] probe failed \u2014 landing in the lowest tier via classifyStreams",
324
+ { meta: { url: (0, import_types.maskUrlCredentials)(url), error: err instanceof Error ? err.message : String(err) } }
325
+ );
326
+ const emptyMetadata = {};
327
+ return { url, metadata: emptyMetadata };
328
+ }
329
+ })
330
+ );
331
+ const classified = (0, import_types.classifyStreams)(probed);
332
+ const metadataByUrl = new Map(probed.map((p) => [p.url, p.metadata]));
333
+ const classifiedByUrl = new Map(classified.map((c) => [c.url, c]));
334
+ return urls.map((url, i) => {
335
+ const tag = classifiedByUrl.get(url);
336
+ const meta = metadataByUrl.get(url);
337
+ const entry = { id: `stream-${i + 1}`, url };
338
+ if (tag) entry.profileHint = tag.quality;
339
+ if (meta?.width && meta?.height) entry.resolution = { width: meta.width, height: meta.height };
340
+ return entry;
215
341
  });
216
- const settings = this.registry.getDeviceSettings(device.id);
217
- const rtspDevice = this.buildDevice(device, settings);
218
- this.devices.push(rtspDevice);
219
- this.ctx.logger.info(`Created RTSP device: ${device.id} / ${stableId} (${name})`);
220
- return rtspDevice;
221
342
  }
222
- subscribeLiveEvents(_callback) {
223
- return () => {
343
+ // ── Native capabilities ────────────────────────────────────────────────
344
+ registerSnapshotProvider() {
345
+ const snapshotUrl = (this.config.get("snapshotUrl") ?? "").trim();
346
+ if (!snapshotUrl) return;
347
+ const provider = {
348
+ getSnapshot: async ({ deviceId }) => {
349
+ if (deviceId !== this.id) {
350
+ throw new Error(`RtspCamera: deviceId mismatch, expected ${this.id}, got ${deviceId}`);
351
+ }
352
+ const buf = await this.fetchHttpSnapshot(snapshotUrl);
353
+ if (buf.length === 0) return null;
354
+ return { base64: buf.toString("base64"), contentType: "image/jpeg" };
355
+ },
356
+ invalidateCache: async () => {
357
+ }
224
358
  };
359
+ this.ctx.registerNativeCap(import_types.snapshotCapability, provider);
225
360
  }
226
- buildDevice(device, settings) {
227
- const deviceCtx = {
228
- id: `device:${device.id}`,
229
- logger: this.ctx.logger.child(device.name),
230
- eventBus: this.ctx.eventBus,
231
- storage: this.ctx.storage,
232
- config: this.ctx.config
233
- };
234
- return new RtspDevice(device, settings, deviceCtx);
361
+ async fetchHttpSnapshot(url) {
362
+ const username = (this.config.get("username") ?? "").trim();
363
+ const password = (this.config.get("password") ?? "").trim();
364
+ const headers = {};
365
+ if (username || password) {
366
+ const creds = Buffer.from(`${username}:${password}`).toString("base64");
367
+ headers["authorization"] = `Basic ${creds}`;
368
+ }
369
+ const res = await fetch(url, { headers });
370
+ if (!res.ok) throw new Error(`HTTP ${res.status} ${res.statusText}`);
371
+ const ab = await res.arrayBuffer();
372
+ return Buffer.from(ab);
235
373
  }
236
374
  };
237
375
 
238
376
  // src/addon.ts
239
- var RtspProviderAddon = class {
240
- manifest = {
241
- id: "provider-rtsp",
242
- name: "RTSP Camera Provider",
243
- version: "0.1.0",
244
- description: "Direct connection to cameras via RTSP URL",
245
- capabilities: ["device-provider"]
377
+ function getString(obj, key) {
378
+ const v = obj[key];
379
+ return typeof v === "string" ? v : "";
380
+ }
381
+ function getStringArray(obj, key) {
382
+ const v = obj[key];
383
+ if (!Array.isArray(v)) return [];
384
+ return v.filter((s) => typeof s === "string").map((s) => s.trim()).filter((s) => s.length > 0);
385
+ }
386
+ function buildCreationFormSchema() {
387
+ return {
388
+ sections: [
389
+ {
390
+ id: "identity",
391
+ title: "Camera",
392
+ columns: 1,
393
+ fields: [
394
+ { type: "text", key: "name", label: "Name", required: true, placeholder: "Living room" }
395
+ ]
396
+ },
397
+ {
398
+ id: "streams",
399
+ title: "RTSP Streams",
400
+ description: "Provide 1\u20133 RTSP URLs for this camera. Each URL is probed and published to the stream-broker; the broker assigns the (high / mid / low) profiles by resolution \u2014 you do not need to label the streams manually.",
401
+ columns: 1,
402
+ fields: [
403
+ {
404
+ type: "probe",
405
+ key: "streams",
406
+ label: "RTSP stream URL",
407
+ required: true,
408
+ placeholder: "rtsp://user:pass@host:554/Streaming/Channels/101",
409
+ inputType: "url",
410
+ multiple: {
411
+ min: 1,
412
+ max: 3,
413
+ addLabel: "Add stream",
414
+ itemLabel: "Stream ${n}",
415
+ itemDefault: ""
416
+ }
417
+ }
418
+ ]
419
+ },
420
+ {
421
+ id: "snapshot",
422
+ title: "Snapshot",
423
+ columns: 1,
424
+ fields: [
425
+ { type: "probe", key: "snapshotUrl", label: "Snapshot URL", description: "Optional HTTP(S) endpoint that returns a JPEG still.", placeholder: "http://host/ISAPI/Streaming/channels/101/picture", inputType: "url" }
426
+ ]
427
+ },
428
+ {
429
+ id: "credentials",
430
+ title: "Credentials",
431
+ description: "Optional. If the RTSP URL already contains user:pass@ you can leave these empty.",
432
+ columns: 2,
433
+ fields: [
434
+ { type: "text", key: "username", label: "Username" },
435
+ { type: "password", key: "password", label: "Password", showToggle: true }
436
+ ]
437
+ }
438
+ ]
246
439
  };
247
- provider = null;
248
- async initialize(context) {
249
- const registry = context.integrationRegistry;
250
- if (!registry) {
251
- context.logger.warn("IntegrationRegistry not available \u2014 RTSP provider cannot start");
252
- return;
253
- }
254
- let integration = registry.getIntegrationByAddonId("provider-rtsp");
255
- if (!integration) {
256
- integration = registry.createIntegration({
257
- addonId: "provider-rtsp",
258
- name: "RTSP Cameras",
259
- enabled: true,
260
- info: { discoveryMode: "manual", icon: "assets/icon.svg", color: "#78716c" }
261
- });
262
- context.logger.info(`Created RTSP integration: ${integration.id}`);
263
- }
264
- if (!integration.enabled) {
265
- context.logger.info("RTSP integration is disabled \u2014 skipping start");
266
- return;
267
- }
268
- this.provider = new RtspProvider(integration, registry, {
269
- id: context.id,
270
- logger: context.logger,
271
- eventBus: context.eventBus,
272
- storage: context.storage,
273
- config: context.config
274
- });
275
- await this.provider.start();
276
- context.logger.info(`RTSP provider started (integration=${integration.id}, devices=${this.provider.getDevices().length})`);
440
+ }
441
+ function isStreamBrokerReady(event) {
442
+ if (event.category !== "system.ready-state") return false;
443
+ const data = event.data;
444
+ return data["capName"] === "stream-broker" && data["state"] === "ready";
445
+ }
446
+ var RtspProviderAddon = class extends import_types2.BaseDeviceProvider {
447
+ addonId = "provider-rtsp";
448
+ providerName = "RTSP";
449
+ deviceClasses = {
450
+ [import_types2.DeviceType.Camera]: RtspCamera
451
+ };
452
+ constructor() {
453
+ super({});
277
454
  }
278
- async shutdown() {
279
- await this.provider?.stop();
280
- this.provider = null;
455
+ async onInitialize() {
456
+ const regs = await super.onInitialize();
457
+ this.subscribe(
458
+ { category: import_types2.EventCategory.DeviceStateChanged },
459
+ (event) => {
460
+ const data = event.data;
461
+ if (data.capName !== "camera-streams") return;
462
+ const deviceId = data.deviceId;
463
+ if (typeof deviceId !== "number") return;
464
+ const registry = this.ctx.kernel.deviceRegistry;
465
+ if (!registry) return;
466
+ if (registry.getAddonId(deviceId) !== this.addonId) return;
467
+ const device = registry.getById(deviceId);
468
+ if (!device) return;
469
+ const online = data.slice?.online === true;
470
+ if (device.online !== online) device.online = online;
471
+ }
472
+ );
473
+ this.subscribe(
474
+ { category: import_types2.EventCategory.SystemReadyState },
475
+ (event) => {
476
+ if (!isStreamBrokerReady(event)) return;
477
+ void this.republishAll().catch((err) => {
478
+ this.ctx.logger.warn("Failed to re-publish RTSP streams after broker ready", {
479
+ meta: { error: err instanceof Error ? err.message : String(err) }
480
+ });
481
+ });
482
+ }
483
+ );
484
+ return regs;
281
485
  }
282
- getCapabilityProvider(name) {
283
- if (name === "device-provider" && this.provider) {
284
- return this.provider;
285
- }
286
- return null;
486
+ // ── Creation ─────────────────────────────────────────────────────────
487
+ async onGetCreationSchema(type) {
488
+ if (type !== import_types2.DeviceType.Camera) return null;
489
+ return buildCreationFormSchema();
287
490
  }
288
- getConfigSchema() {
289
- return {
290
- sections: [
291
- {
292
- id: "general",
293
- title: "RTSP Provider",
294
- description: "Configure generic RTSP camera connections",
295
- columns: 1,
296
- fields: [
297
- { type: "text", key: "name", label: "Provider Name", placeholder: "RTSP Cameras" },
298
- {
299
- type: "info",
300
- key: "info",
301
- label: "Camera Configuration",
302
- content: "Individual cameras are configured via the device management interface after adding this provider.",
303
- variant: "info"
304
- }
305
- ]
491
+ async onCreateDevice(type, config) {
492
+ if (type !== import_types2.DeviceType.Camera) {
493
+ throw new Error(`RTSP provider does not support device type: ${type}`);
494
+ }
495
+ const name = getString(config, "name").trim();
496
+ if (!name) throw new Error("Camera name is required");
497
+ const rawStreams = getStringArray(config, "streams");
498
+ if (rawStreams.length === 0) throw new Error("At least one RTSP stream URL is required");
499
+ if (rawStreams.length > 3) throw new Error("At most 3 RTSP stream URLs are supported");
500
+ const streamProbe = this.ctx.kernel.streamProbe;
501
+ if (!streamProbe) throw new Error("ctx.kernel.streamProbe unavailable \u2014 cannot probe streams");
502
+ const probed = await Promise.all(
503
+ rawStreams.map(async (url) => {
504
+ try {
505
+ const metadata = await streamProbe.probe(url, { force: false });
506
+ return { url, metadata, ok: true };
507
+ } catch (err) {
508
+ this.ctx.logger.warn(
509
+ "[rtsp-create] probe failed \u2014 publishing without probed metadata",
510
+ { meta: { url: (0, import_types2.maskUrlCredentials)(url), error: err instanceof Error ? err.message : String(err) } }
511
+ );
512
+ return { url, metadata: void 0, ok: false };
306
513
  }
307
- ]
514
+ })
515
+ );
516
+ const classified = (0, import_types2.classifyStreams)(
517
+ probed.map((p) => ({ url: p.url, metadata: p.metadata ?? {} }))
518
+ );
519
+ const hintByUrl = new Map(classified.map((c) => [c.url, c.quality]));
520
+ const streams = probed.map((p, i) => {
521
+ const entry = { id: `stream-${i + 1}`, url: p.url };
522
+ const hint = hintByUrl.get(p.url);
523
+ if (hint) entry.profileHint = hint;
524
+ if (p.metadata?.width && p.metadata?.height) {
525
+ entry.resolution = { width: p.metadata.width, height: p.metadata.height };
526
+ }
527
+ return entry;
528
+ });
529
+ const parsed = rtspCameraSchema.parse({
530
+ streams,
531
+ snapshotUrl: getString(config, "snapshotUrl"),
532
+ username: getString(config, "username"),
533
+ password: getString(config, "password")
534
+ });
535
+ return {
536
+ meta: { type: import_types2.DeviceType.Camera, name },
537
+ config: parsed
308
538
  };
309
539
  }
310
- getConfig() {
311
- return {};
540
+ // ── Field probing ────────────────────────────────────────────────────
541
+ async testCreationField(input) {
542
+ if (input.type !== import_types2.DeviceType.Camera) {
543
+ return { status: "error", error: `Unsupported device type: ${input.type}` };
544
+ }
545
+ const streamProbe = this.ctx.kernel.streamProbe;
546
+ if (!streamProbe) {
547
+ return { status: "error", error: "ctx.kernel.streamProbe unavailable \u2014 cannot reach kernel probe" };
548
+ }
549
+ return streamProbe.probeField(input.key, input.value);
312
550
  }
313
- async onConfigChange(_config) {
551
+ // ── Restore ──────────────────────────────────────────────────────────
552
+ //
553
+ // `BaseDeviceProvider.onRestoreDevices` default impl iterates
554
+ // `savedDevices`, looks up the right class via `deviceClasses`,
555
+ // and calls `kernel.devices.create()`. Each created device fires
556
+ // its `onCreated` lifecycle hook (see `RtspCamera.onCreated()`)
557
+ // which publishes streams to the broker — no per-provider override
558
+ // needed here.
559
+ // ── Internal — re-publish every RtspCamera to the broker ────────────
560
+ async republishAll() {
561
+ const all = await this.ctx.kernel.devices?.getAll() ?? [];
562
+ let published = 0;
563
+ for (const dev of all) {
564
+ if (!(dev instanceof RtspCamera)) continue;
565
+ try {
566
+ await dev.publishToBroker();
567
+ published++;
568
+ } catch (err) {
569
+ this.ctx.logger.debug("publishToBroker threw during republish", {
570
+ tags: { deviceId: dev.id },
571
+ meta: { error: err instanceof Error ? err.message : String(err) }
572
+ });
573
+ }
574
+ }
575
+ if (published > 0) {
576
+ this.ctx.logger.info("Re-published RTSP streams to stream-broker", {
577
+ meta: { published, total: all.length }
578
+ });
579
+ }
314
580
  }
315
581
  };
316
582
  // Annotate the CommonJS export names for ESM import in node:
317
583
  0 && (module.exports = {
318
- RtspDevice,
319
- RtspProvider,
320
- RtspProviderAddon
584
+ RtspCamera,
585
+ RtspProviderAddon,
586
+ rtspCameraSchema
321
587
  });
322
588
  //# sourceMappingURL=index.js.map