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