scrypted-cisco-camera 1.1.0

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.
@@ -0,0 +1,76 @@
1
+ name: Build, Release & Publish
2
+
3
+ on:
4
+ workflow_dispatch:
5
+ inputs:
6
+ bump_type:
7
+ description: 'Version bump type (patch, minor, major)'
8
+ required: true
9
+ default: 'patch'
10
+ type: choice
11
+ options:
12
+ - patch
13
+ - minor
14
+ - major
15
+
16
+ permissions:
17
+ contents: write
18
+ pull-requests: write
19
+ id-token: write
20
+
21
+ jobs:
22
+ release:
23
+ name: Build, Release, & Publish Plugin
24
+ runs-on: ubuntu-latest
25
+
26
+ steps:
27
+ - name: Checkout repository
28
+ uses: actions/checkout@v4
29
+ with:
30
+ fetch-depth: 0 # Required for pushing tags back
31
+
32
+ - name: Setup NodeJS
33
+ uses: actions/setup-node@v4
34
+ with:
35
+ node-version: '24'
36
+ registry-url: 'https://registry.npmjs.org'
37
+
38
+ - name: Setup Git
39
+ run: |
40
+ git config --global user.email "github-actions[bot]@users.noreply.github.com"
41
+ git config --global user.name "github-actions[bot]"
42
+
43
+ - name: Install Dependencies
44
+ run: npm ci
45
+
46
+ - name: Build Plugin
47
+ run: npm run build
48
+
49
+ - name: Bump Version
50
+ id: bump
51
+ run: |
52
+ # Bumps the version in package.json and creates a git tag automatically
53
+ NEW_VERSION=$(npm version ${{ github.event.inputs.bump_type }})
54
+ echo "new_version=$NEW_VERSION" >> $GITHUB_OUTPUT
55
+
56
+ - name: Pack Plugin
57
+ id: pack
58
+ run: |
59
+ # Generate the .tgz file
60
+ TARBALL=$(npm pack)
61
+ echo "tarball=$TARBALL" >> $GITHUB_OUTPUT
62
+
63
+ - name: Push Version Bump & Tag
64
+ run: git push --follow-tags
65
+
66
+ - name: Create GitHub Release
67
+ uses: softprops/action-gh-release@v2
68
+ with:
69
+ tag_name: ${{ steps.bump.outputs.new_version }}
70
+ files: ${{ steps.pack.outputs.tarball }}
71
+ generate_release_notes: true
72
+ env:
73
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
74
+
75
+ - name: Publish to NPM
76
+ run: npm publish --provenance --access public
package/README.md ADDED
@@ -0,0 +1,55 @@
1
+ # Scrypted Cisco 4500E Camera Plugin
2
+
3
+ This is a custom plugin for [Scrypted](https://github.com/koush/scrypted) designed specifically to handle legacy Cisco IP Cameras, such as the Cisco CIVS-IPC-4500E.
4
+
5
+ These older Cisco cameras rely on outdated browser technologies (like ActiveX and legacy Java applets) for their web interfaces, and have unstable built-in RTSP servers that often drop frames or produce visual artifacts when streamed directly. This plugin solves both of those problems by bundling a customized backend.
6
+
7
+ ## Features
8
+
9
+ - **Built-in Modern Web Proxy**: The plugin hosts a modernized version of the camera's original Web UI on a custom port, stripping out the broken ActiveX controls and replacing them with a native HTML5 video player. You can configure camera settings (like motion detection and resolution) right from any modern browser.
10
+ - **Embedded `go2rtc` Service**: To solve the visual artifacting and connection instability, the plugin automatically downloads and runs `go2rtc` locally. It securely intercepts the video feed over a stable TCP connection and re-broadcasts it as an ultra-stable, artifact-free RTSP feed into Scrypted.
11
+ - **Auto-Session Management**: The plugin dynamically scrapes and maintains the required authentication `sessionID` from the camera, ensuring the video stream never times out or drops due to expired credentials.
12
+ - **Zero Configuration Files**: Everything is configured natively through the Scrypted UI.
13
+
14
+ ## Installation & Deployment
15
+
16
+ Since this is a custom plugin, you'll need to deploy it directly to your Scrypted instance using the `@scrypted/cli`.
17
+
18
+ ### Prerequisites
19
+ - Node.js (v18+)
20
+ - A running instance of Scrypted
21
+
22
+ ### Setup Steps
23
+ 1. Clone this repository:
24
+ ```bash
25
+ git clone https://github.com/mkelley88/scrypted-cisco-4500e.git
26
+ cd scrypted-cisco-4500e
27
+ ```
28
+ 2. Install the necessary dependencies:
29
+ ```bash
30
+ npm install
31
+ ```
32
+ 3. Build the plugin:
33
+ ```bash
34
+ npm run build
35
+ ```
36
+ 4. Deploy it directly to your Scrypted server (replace `<IP_ADDRESS>` with the IP of your Scrypted server):
37
+ ```bash
38
+ npx scrypted-deploy <IP_ADDRESS>
39
+ ```
40
+
41
+ ## Configuration
42
+
43
+ Once deployed, add a new instance of the **Cisco Camera** plugin in your Scrypted dashboard.
44
+
45
+ In the plugin's "Stream Settings" page, you will find several required fields:
46
+ - **Camera IP**: The IP address of your Cisco camera.
47
+ - **Username**: The administrator username of the Cisco camera.
48
+ - **Password**: The administrator password of the Cisco camera.
49
+ - **Web Proxy Port**: The local port where the modernized Web UI should be served (Default: `3000`).
50
+ - **Web Proxy Username**: Choose a username to secure the new Web UI.
51
+ - **Web Proxy Password**: Choose a password to secure the new Web UI.
52
+
53
+ After saving your settings, simply navigate to `http://<SCRYPTED_IP>:<PROXY_PORT>` in your browser to access the modernized camera interface!
54
+
55
+
package/package.json ADDED
@@ -0,0 +1,42 @@
1
+ {
2
+ "name": "scrypted-cisco-camera",
3
+ "version": "1.1.0",
4
+ "description": "Cisco IP Camera Plugin",
5
+ "main": "src/main.ts",
6
+ "scripts": {
7
+ "test": "echo \"Error: no test specified\" && exit 1",
8
+ "build": "scrypted-webpack"
9
+ },
10
+ "keywords": [],
11
+ "author": "",
12
+ "license": "ISC",
13
+ "type": "commonjs",
14
+ "dependencies": {
15
+ "@scrypted/sdk": "^0.5.59",
16
+ "axios": "^1.18.0",
17
+ "basic-auth": "^2.0.1",
18
+ "express": "^5.2.1",
19
+ "http-proxy-middleware": "^4.1.1",
20
+ "ssh2": "^1.17.0",
21
+ "tcp-port-used": "^1.0.3"
22
+ },
23
+ "scrypted": {
24
+ "name": "Cisco Camera",
25
+ "type": "DeviceProvider",
26
+ "interfaces": [
27
+ "DeviceProvider",
28
+ "DeviceCreator",
29
+ "VideoCamera",
30
+ "Settings"
31
+ ]
32
+ },
33
+ "devDependencies": {
34
+ "@types/basic-auth": "^1.1.8",
35
+ "@types/express": "^5.0.6",
36
+ "@types/node": "^26.0.0",
37
+ "@types/ssh2": "^1.15.5",
38
+ "@types/tcp-port-used": "^1.0.4",
39
+ "ts-node": "^10.9.2",
40
+ "typescript": "^6.0.3"
41
+ }
42
+ }
@@ -0,0 +1,13 @@
1
+ import { Camera, VideoCamera, Settings, Setting, ScryptedDeviceBase, MediaObject, MediaStreamOptions } from "@scrypted/sdk";
2
+ export declare class CiscoCamera extends ScryptedDeviceBase implements Camera, VideoCamera, Settings {
3
+ private sessionID;
4
+ constructor(nativeId: string);
5
+ getSettings(): Promise<Setting[]>;
6
+ putSetting(key: string, value: string | number | boolean): Promise<void>;
7
+ private getSessionID;
8
+ getVideoStream(options?: MediaStreamOptions): Promise<MediaObject>;
9
+ getVideoStreamOptions(): Promise<MediaStreamOptions[]>;
10
+ getPictureOptions(): Promise<void>;
11
+ takePicture(): Promise<MediaObject>;
12
+ }
13
+ //# sourceMappingURL=CiscoCamera.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"CiscoCamera.d.ts","sourceRoot":"","sources":["CiscoCamera.ts"],"names":[],"mappings":"AAAA,OAAO,EACH,MAAM,EACN,WAAW,EACX,QAAQ,EACR,OAAO,EAEP,kBAAkB,EAClB,WAAW,EACX,kBAAkB,EAErB,MAAM,eAAe,CAAC;AAMvB,qBAAa,WAAY,SAAQ,kBAAmB,YAAW,MAAM,EAAE,WAAW,EAAE,QAAQ;IACxF,OAAO,CAAC,SAAS,CAAuB;gBAE5B,QAAQ,EAAE,MAAM;IAItB,WAAW,IAAI,OAAO,CAAC,OAAO,EAAE,CAAC;IAwBjC,UAAU,CAAC,GAAG,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,MAAM,GAAG,OAAO,GAAG,OAAO,CAAC,IAAI,CAAC;YAQhE,YAAY;IA6CpB,cAAc,CAAC,OAAO,CAAC,EAAE,kBAAkB,GAAG,OAAO,CAAC,WAAW,CAAC;IAuBlE,qBAAqB,IAAI,OAAO,CAAC,kBAAkB,EAAE,CAAC;IAatD,iBAAiB,IAAI,OAAO,CAAC,IAAI,CAAC;IAKlC,WAAW,IAAI,OAAO,CAAC,WAAW,CAAC;CAK5C"}
@@ -0,0 +1,415 @@
1
+ import {
2
+ VideoCamera,
3
+ Settings,
4
+ Setting,
5
+ ScryptedDeviceBase,
6
+ MediaObject,
7
+ MediaStreamOptions,
8
+ ResponseMediaStreamOptions,
9
+ ScryptedInterface,
10
+ } from "@scrypted/sdk";
11
+ import sdk from "@scrypted/sdk";
12
+ import { Client } from "ssh2";
13
+ import axios from "axios";
14
+ import * as express from "express";
15
+ import { createProxyMiddleware } from "http-proxy-middleware";
16
+ import * as basicAuth from "basic-auth";
17
+ import { spawn, ChildProcess } from "child_process";
18
+ import * as fs from "fs";
19
+ import * as path from "path";
20
+ import * as https from "https";
21
+ import * as crypto from "crypto";
22
+ import * as http from "http";
23
+
24
+ const { mediaManager, deviceManager } = sdk;
25
+
26
+ const SAFE_NVRAM_VALUE = /^[a-zA-Z0-9._\-:\/@ ]*$/;
27
+
28
+ function sanitizeNvramValue(value: string): string {
29
+ if (!SAFE_NVRAM_VALUE.test(value)) {
30
+ throw new Error(`Refusing to set nvram value containing unsafe characters: "${value}"`);
31
+ }
32
+ return value;
33
+ }
34
+
35
+ export class CiscoCamera extends ScryptedDeviceBase implements VideoCamera, Settings {
36
+ private go2rtcProc?: ChildProcess;
37
+ private expressServer?: http.Server;
38
+ private cachedSessionId?: string;
39
+ private cachedSessionTime: number = 0;
40
+
41
+ constructor(nativeId: string) {
42
+ super(nativeId);
43
+
44
+ // Start background services asynchronously
45
+ this.startServices().catch(e => {
46
+ this.console.error("Failed to start services:", e);
47
+ });
48
+ }
49
+
50
+ release() {
51
+ if (this.go2rtcProc) {
52
+ this.console.log("Killing go2rtc process...");
53
+ this.go2rtcProc.kill();
54
+ }
55
+ if (this.expressServer) {
56
+ this.console.log("Closing Express server...");
57
+ this.expressServer.close();
58
+ }
59
+ }
60
+
61
+ private async startServices() {
62
+ await this.startGo2Rtc();
63
+ await this.startExpressProxy();
64
+ }
65
+
66
+ private async startGo2Rtc() {
67
+ // Scrypted plugin storage directory is safe to store binaries
68
+ const binPath = path.join(process.env.SCRYPTED_PLUGIN_VOLUME || __dirname, 'go2rtc');
69
+
70
+ if (!fs.existsSync(binPath)) {
71
+ this.console.log("Downloading go2rtc...");
72
+ const res = await axios.get("https://github.com/AlexxIT/go2rtc/releases/latest/download/go2rtc_linux_amd64", {
73
+ responseType: "arraybuffer"
74
+ });
75
+ fs.writeFileSync(binPath, res.data);
76
+ fs.chmodSync(binPath, 0o755);
77
+ this.console.log("go2rtc downloaded successfully.");
78
+ }
79
+
80
+ // Create a custom go2rtc.yaml in the same directory to avoid port conflicts with Scrypted
81
+ const configPath = path.join(process.env.SCRYPTED_PLUGIN_VOLUME || __dirname, 'go2rtc.yaml');
82
+ const configContent = `
83
+ api:
84
+ listen: ":1984"
85
+ rtsp:
86
+ listen: ":8554"
87
+ webrtc:
88
+ listen: ":8555"
89
+ `;
90
+ fs.writeFileSync(configPath, configContent);
91
+
92
+ this.console.log("Starting go2rtc process...");
93
+ this.go2rtcProc = spawn(binPath, ["-config", configPath], {
94
+ cwd: path.dirname(binPath),
95
+ stdio: 'pipe'
96
+ });
97
+
98
+ this.go2rtcProc.stdout?.on('data', (data) => this.console.log(`[go2rtc] ${data.toString().trim()}`));
99
+ this.go2rtcProc.stderr?.on('data', (data) => this.console.error(`[go2rtc] ${data.toString().trim()}`));
100
+
101
+ this.go2rtcProc.on('exit', (code) => {
102
+ this.console.warn(`go2rtc process exited with code ${code}`);
103
+ this.go2rtcProc = undefined;
104
+ });
105
+ }
106
+
107
+ private async startExpressProxy() {
108
+ const app = express.default();
109
+ const port = parseInt(this.storage.getItem("proxyPort") || "3000");
110
+ const proxyUsername = this.storage.getItem("proxyUsername") || "admin";
111
+ const proxyPassword = this.storage.getItem("proxyPassword");
112
+
113
+ const ipAddress = this.storage.getItem("ipAddress");
114
+ if (!ipAddress) {
115
+ this.console.warn("Camera IP not configured. Express proxy will not start until IP is set.");
116
+ return;
117
+ }
118
+
119
+ const CAMERA_URL = `https://${ipAddress}`;
120
+
121
+ const legacyAgent = new https.Agent({
122
+ rejectUnauthorized: false,
123
+ secureOptions: crypto.constants.SSL_OP_LEGACY_SERVER_CONNECT,
124
+ ciphers: 'DEFAULT@SECLEVEL=0',
125
+ minVersion: 'TLSv1'
126
+ });
127
+
128
+ // Basic Auth Middleware
129
+ app.use((req, res, next) => {
130
+ if (!proxyPassword) return next(); // Skip auth if no password configured
131
+ const user = basicAuth.default(req);
132
+ if (!user || user.name !== proxyUsername || user.pass !== proxyPassword) {
133
+ res.set('WWW-Authenticate', 'Basic realm="Cisco Camera Web Proxy"');
134
+ return res.status(401).send('Authentication required.');
135
+ }
136
+ next();
137
+ });
138
+
139
+ const go2rtcProxy = createProxyMiddleware({
140
+ target: 'http://127.0.0.1:1984',
141
+ pathRewrite: { '^/go2rtc': '' },
142
+ ws: true
143
+ });
144
+ app.use('/go2rtc', go2rtcProxy);
145
+
146
+ app.get(/\.(cs|html)$/, async (req, res) => {
147
+ try {
148
+ const headers = { ...req.headers };
149
+ delete headers.host;
150
+ if (headers.referer) {
151
+ headers.referer = headers.referer.replace(`http://${req.headers.host}`, CAMERA_URL);
152
+ }
153
+
154
+ const response = await axios.get(CAMERA_URL + req.originalUrl, {
155
+ responseType: 'arraybuffer',
156
+ headers: headers,
157
+ maxRedirects: 0,
158
+ validateStatus: null,
159
+ httpsAgent: legacyAgent
160
+ });
161
+
162
+ const contentType = (response.headers['content-type'] as string) || '';
163
+ if (!contentType.includes('text/html') || response.status !== 200) {
164
+ Object.entries(response.headers).forEach(([key, value]) => {
165
+ res.setHeader(key, value as string);
166
+ });
167
+ return res.status(response.status).send(response.data);
168
+ }
169
+
170
+ let html = response.data.toString('utf8');
171
+ const sessionID = req.query.sessionID || '';
172
+
173
+ if (req.path === '/viewvideo.cs') {
174
+ const objectTagRegex = /<object id="DxPlay"[\s\S]*?<\/object>/i;
175
+ const webrtcHtml = `
176
+ <div id="DxPlay" style="margin-top:-1px; margin-left:10px; margin-right:10px; width:640px; height:480px; background:#000;">
177
+ <iframe src="/go2rtc/webrtc.html?src=cisco_camera" width="100%" height="100%" frameborder="0" allowfullscreen></iframe>
178
+ </div>`;
179
+ html = html.replace(objectTagRegex, webrtcHtml);
180
+
181
+ // Ensure stream is registered in go2rtc
182
+ if (sessionID) {
183
+ const rtspUrl = `rtsp://${ipAddress}:554/StreamingSetting?version=1.0&action=getRTSPStream&ChannelID=1&ChannelName=Main&sessionID=${sessionID}#transport=tcp`;
184
+ try {
185
+ await axios.put(`http://127.0.0.1:1984/api/streams?name=cisco_camera&src=${encodeURIComponent(rtspUrl)}`);
186
+ } catch(e: any) {
187
+ this.console.error("[go2rtc] Failed to register stream:", e.message);
188
+ }
189
+ }
190
+ }
191
+
192
+ const polyfill = `
193
+ <script>
194
+ window.addEventListener('mousemove', (e) => { window.event = e; }, true);
195
+ window.addEventListener('mousedown', (e) => { window.event = e; }, true);
196
+ window.addEventListener('mouseup', (e) => { window.event = e; }, true);
197
+ const originalGetElementById = document.getElementById;
198
+ document.getElementById = function(id) {
199
+ let el = originalGetElementById.call(document, id);
200
+ if (!el) {
201
+ let elements = document.getElementsByName(id);
202
+ if (elements && elements.length > 0) return elements[0];
203
+ }
204
+ return el;
205
+ };
206
+ window.addEventListener('error', function(e) { console.warn("Camera JS Error:", e.message); });
207
+ const originalAlert = window.alert;
208
+ window.alert = function(msg) {
209
+ if (typeof msg === 'string' && msg.includes('.NET SDK/Runtime')) return;
210
+ if (originalAlert) originalAlert(msg);
211
+ };
212
+ </script>
213
+ `;
214
+ html = html.replace('</head>', polyfill + '</head>');
215
+
216
+ html = html.replace(/<body([^>]*)onLoad="([^"]+)"/i, (match: string, before: string, onloadStr: string) => {
217
+ const safeLoad = onloadStr.split(';')
218
+ .filter((s: string) => s.trim().length > 0)
219
+ .map((s: string) => `try{${s}}catch(e){console.warn('onLoad error', e)}`)
220
+ .join(';');
221
+ return `<body${before}onLoad="${safeLoad}"`;
222
+ });
223
+
224
+ html = html.replace(/<(input|select)([^>]*)name="([^"]+)"([^>]*)>/ig, (match: string, tag: string, p1: string, name: string, p3: string) => {
225
+ if (!match.toLowerCase().includes('id=')) {
226
+ return `<${tag}${p1}name="${name}" id="${name}"${p3}>`;
227
+ }
228
+ return match;
229
+ });
230
+
231
+ res.setHeader('Content-Type', 'text/html');
232
+ res.send(html);
233
+ } catch (e: any) {
234
+ this.console.error("Error proxying HTML/CS:", e.message);
235
+ res.status(500).send("Error proxying HTML/CS");
236
+ }
237
+ });
238
+
239
+ const cameraProxy = createProxyMiddleware({
240
+ target: CAMERA_URL,
241
+ changeOrigin: true,
242
+ secure: false,
243
+ agent: legacyAgent
244
+ });
245
+ app.use('/', cameraProxy);
246
+
247
+ this.expressServer = app.listen(port, '0.0.0.0', () => {
248
+ this.console.log(`Web Proxy running on http://localhost:${port}`);
249
+ });
250
+ this.expressServer.on('upgrade', go2rtcProxy.upgrade);
251
+ }
252
+
253
+ async getSettings(): Promise<Setting[]> {
254
+ return [
255
+ {
256
+ title: "Camera IP Address",
257
+ group: "Device Login",
258
+ key: "ipAddress",
259
+ description: "The IP address of the Cisco Camera",
260
+ value: this.storage.getItem("ipAddress"),
261
+ },
262
+ {
263
+ title: "Username",
264
+ group: "Device Login",
265
+ key: "username",
266
+ description: "The login username",
267
+ value: this.storage.getItem("username") || "admin",
268
+ },
269
+ {
270
+ title: "Password",
271
+ group: "Device Login",
272
+ key: "password",
273
+ description: "The login password",
274
+ value: this.storage.getItem("password"),
275
+ type: "password",
276
+ },
277
+ {
278
+ title: "Web Proxy Port",
279
+ group: "Web Proxy Settings",
280
+ key: "proxyPort",
281
+ type: "number",
282
+ description: "The port to run the modernized Web UI Proxy on (default 3000)",
283
+ value: this.storage.getItem("proxyPort") || "3000",
284
+ },
285
+ {
286
+ title: "Web Proxy Username",
287
+ group: "Web Proxy Settings",
288
+ key: "proxyUsername",
289
+ description: "Username for accessing the modernized Web UI",
290
+ value: this.storage.getItem("proxyUsername") || "admin",
291
+ },
292
+ {
293
+ title: "Web Proxy Password",
294
+ group: "Web Proxy Settings",
295
+ key: "proxyPassword",
296
+ type: "password",
297
+ description: "Password for accessing the modernized Web UI. Leave blank to disable authentication.",
298
+ value: this.storage.getItem("proxyPassword"),
299
+ }
300
+ ];
301
+ }
302
+
303
+ async putSetting(key: string, value: string | number | boolean): Promise<void> {
304
+ this.storage.setItem(key, value.toString());
305
+ // Restart proxy if proxy settings changed
306
+ if (key === "proxyPort" || key === "proxyUsername" || key === "proxyPassword" || key === "ipAddress") {
307
+ if (this.expressServer) {
308
+ this.console.log("Proxy settings changed, restarting Express server...");
309
+ this.expressServer.close(() => {
310
+ this.startExpressProxy().catch(e => this.console.error("Failed to restart Express server:", e));
311
+ });
312
+ } else {
313
+ this.startExpressProxy().catch(e => this.console.error("Failed to restart Express server:", e));
314
+ }
315
+ }
316
+ }
317
+
318
+ private async getSessionID(): Promise<string> {
319
+ const now = Date.now();
320
+ if (this.cachedSessionId && now - this.cachedSessionTime < 4 * 60 * 1000) {
321
+ return this.cachedSessionId;
322
+ }
323
+
324
+ const ipAddress = this.storage.getItem("ipAddress");
325
+ const username = this.storage.getItem("username") || "admin";
326
+ const password = this.storage.getItem("password");
327
+
328
+ if (!ipAddress || !password) {
329
+ throw new Error("Missing camera IP or password. Please update plugin settings.");
330
+ }
331
+
332
+ const loginUrl = `http://${ipAddress}/login.cs`;
333
+ const data = new URLSearchParams({
334
+ action: 'login',
335
+ version: '1.0',
336
+ userName: username,
337
+ password: password
338
+ });
339
+
340
+ try {
341
+ const response = await axios.post(loginUrl, data.toString(), {
342
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
343
+ timeout: 5000
344
+ });
345
+
346
+ if (response.request?.res?.responseUrl) {
347
+ const urlMatch = response.request.res.responseUrl.match(/sessionID=(\d+)/);
348
+ if (urlMatch && urlMatch[1]) {
349
+ this.cachedSessionId = urlMatch[1];
350
+ this.cachedSessionTime = now;
351
+ return urlMatch[1];
352
+ }
353
+ }
354
+
355
+ const bodyMatch = response.data.match(/sessionID=(\d+)/);
356
+ if (bodyMatch && bodyMatch[1]) {
357
+ this.cachedSessionId = bodyMatch[1];
358
+ this.cachedSessionTime = now;
359
+ return bodyMatch[1];
360
+ }
361
+
362
+ throw new Error("Could not find sessionID in login response.");
363
+ } catch (e: any) {
364
+ this.console.error("Login to camera failed:", e);
365
+ throw e;
366
+ }
367
+ }
368
+
369
+ async getVideoStream(options?: MediaStreamOptions): Promise<MediaObject> {
370
+ const ipAddress = this.storage.getItem("ipAddress");
371
+ if (!ipAddress) {
372
+ throw new Error("Camera IP is not set.");
373
+ }
374
+
375
+ const sessionID = await this.getSessionID();
376
+ const rtspUrl = `rtsp://${ipAddress}:554/StreamingSetting?version=1.0&action=getRTSPStream&ChannelID=1&ChannelName=Main&sessionID=${sessionID}#transport=tcp`;
377
+
378
+ // Ensure go2rtc is tracking the stream
379
+ try {
380
+ await axios.put(`http://127.0.0.1:1984/api/streams?name=scrypted_stream&src=${encodeURIComponent(rtspUrl)}`);
381
+ } catch(e: any) {
382
+ this.console.error("[go2rtc] Failed to register stream in getVideoStream:", e.message);
383
+ }
384
+
385
+ // Return the local go2rtc RTSP stream which is artifact-free!
386
+ const cleanRtspUrl = `rtsp://127.0.0.1:8554/scrypted_stream`;
387
+ this.console.log(`Providing clean RTSP URL from go2rtc: ${cleanRtspUrl}`);
388
+
389
+ try {
390
+ return await mediaManager.createFFmpegMediaObject({
391
+ url: cleanRtspUrl,
392
+ inputArguments: [
393
+ "-rtsp_transport", "tcp",
394
+ "-i", cleanRtspUrl
395
+ ]
396
+ });
397
+ } catch (e) {
398
+ this.console.error("Failed to create ffmpeg media object:", e);
399
+ throw e;
400
+ }
401
+ }
402
+
403
+ async getVideoStreamOptions(): Promise<ResponseMediaStreamOptions[]> {
404
+ return [
405
+ {
406
+ id: 'default',
407
+ name: 'Default Stream',
408
+ tool: 'ffmpeg',
409
+ video: {
410
+ codec: 'h264',
411
+ }
412
+ } as any
413
+ ];
414
+ }
415
+ }
package/src/main.d.ts ADDED
@@ -0,0 +1,12 @@
1
+ import { DeviceProvider, DeviceCreator, DeviceCreatorSettings, Setting } from "@scrypted/sdk";
2
+ import sdk from "@scrypted/sdk";
3
+ import { CiscoCamera } from "./CiscoCamera";
4
+ declare class CiscoCameraProvider extends sdk.ScryptedDeviceBase implements DeviceProvider, DeviceCreator {
5
+ constructor();
6
+ getDevice(nativeId: string): Promise<CiscoCamera>;
7
+ getCreateDeviceSettings(): Promise<Setting[]>;
8
+ createDevice(settings: DeviceCreatorSettings): Promise<string>;
9
+ }
10
+ declare const _default: CiscoCameraProvider;
11
+ export default _default;
12
+ //# sourceMappingURL=main.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"main.d.ts","sourceRoot":"","sources":["main.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,cAAc,EAA0D,aAAa,EAAE,qBAAqB,EAAE,OAAO,EAAE,MAAM,eAAe,CAAC;AACtJ,OAAO,GAAG,MAAM,eAAe,CAAC;AAChC,OAAO,EAAE,WAAW,EAAE,MAAM,eAAe,CAAC;AAI5C,cAAM,mBAAoB,SAAQ,GAAG,CAAC,kBAAmB,YAAW,cAAc,EAAE,aAAa;;IAKvF,SAAS,CAAC,QAAQ,EAAE,MAAM;IAI1B,uBAAuB,IAAI,OAAO,CAAC,OAAO,EAAE,CAAC;IA4B7C,YAAY,CAAC,QAAQ,EAAE,qBAAqB,GAAG,OAAO,CAAC,MAAM,CAAC;CAkCvE;;AAED,wBAAyC"}
package/src/main.ts ADDED
@@ -0,0 +1,98 @@
1
+ import { DeviceProvider, ScryptedDeviceType, ScryptedInterface, DeviceDiscovery, DeviceCreator, DeviceCreatorSettings, Setting, ScryptedDeviceBase } from "@scrypted/sdk";
2
+ import sdk from "@scrypted/sdk";
3
+ import { CiscoCamera } from "./CiscoCamera";
4
+
5
+ const { deviceManager } = sdk;
6
+
7
+ class CiscoCameraProvider extends ScryptedDeviceBase implements DeviceProvider, DeviceCreator {
8
+ constructor() {
9
+ super();
10
+ }
11
+
12
+ async getDevice(nativeId: string) {
13
+ const device = new CiscoCamera(nativeId);
14
+ // Force update of existing devices to ensure they have the latest interfaces
15
+ await deviceManager.onDeviceDiscovered({
16
+ name: device.name || "Cisco Camera",
17
+ type: ScryptedDeviceType.Camera,
18
+ nativeId,
19
+ interfaces: [
20
+ ScryptedInterface.VideoCamera,
21
+ ScryptedInterface.Settings
22
+ ],
23
+ info: {
24
+ model: "CIVS-IPC-4500E",
25
+ manufacturer: "Cisco",
26
+ }
27
+ });
28
+ return device;
29
+ }
30
+
31
+ async releaseDevice(id: string, nativeId: string): Promise<void> {
32
+ // Nothing special to do on release.
33
+ }
34
+
35
+ async getCreateDeviceSettings(): Promise<Setting[]> {
36
+ return [
37
+ {
38
+ title: "Camera Name",
39
+ key: "name",
40
+ description: "The name of the camera (e.g., Driveway)",
41
+ value: "Cisco Camera",
42
+ },
43
+ {
44
+ title: "Camera IP Address",
45
+ key: "ipAddress",
46
+ description: "The IP address of the Cisco Camera",
47
+ },
48
+ {
49
+ title: "Username",
50
+ key: "username",
51
+ description: "The login username",
52
+ value: "admin",
53
+ },
54
+ {
55
+ title: "Password",
56
+ key: "password",
57
+ description: "The login password",
58
+ type: "password",
59
+ }
60
+ ];
61
+ }
62
+
63
+ async createDevice(settings: DeviceCreatorSettings): Promise<string> {
64
+ const name = settings.name?.toString() || "Cisco Camera";
65
+ const ipAddress = settings.ipAddress?.toString();
66
+ const username = settings.username?.toString() || "admin";
67
+ const password = settings.password?.toString();
68
+
69
+ if (!ipAddress || !password) {
70
+ throw new Error("IP Address and Password are required");
71
+ }
72
+
73
+ const nativeId = `cisco-${ipAddress.replace(/\./g, '-')}`;
74
+
75
+ await deviceManager.onDeviceDiscovered({
76
+ name,
77
+ type: ScryptedDeviceType.Camera,
78
+ nativeId,
79
+ interfaces: [
80
+ ScryptedInterface.VideoCamera,
81
+ ScryptedInterface.Settings
82
+ ],
83
+ info: {
84
+ model: "CIVS-IPC-4500E",
85
+ manufacturer: "Cisco",
86
+ }
87
+ });
88
+
89
+ const device = new CiscoCamera(nativeId);
90
+ await device.storage.setItem("ipAddress", ipAddress);
91
+ await device.storage.setItem("username", username);
92
+ await device.storage.setItem("password", password);
93
+
94
+ return nativeId;
95
+ }
96
+ }
97
+
98
+ export default new CiscoCameraProvider();
package/tsconfig.json ADDED
@@ -0,0 +1,13 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "es2022",
4
+ "module": "commonjs",
5
+ "esModuleInterop": true,
6
+ "forceConsistentCasingInFileNames": true,
7
+ "strict": true,
8
+ "skipLibCheck": true,
9
+ "outDir": "./dist",
10
+ "rootDir": "./src"
11
+ },
12
+ "include": ["src/**/*"]
13
+ }
@@ -0,0 +1,8 @@
1
+ const baseConfig = require('./node_modules/@scrypted/sdk/webpack.nodejs.config.js');
2
+
3
+ if (!baseConfig.externals) {
4
+ baseConfig.externals = {};
5
+ }
6
+ baseConfig.externals['cpu-features'] = 'commonjs cpu-features';
7
+
8
+ module.exports = baseConfig;