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.
- package/.github/workflows/release.yml +76 -0
- package/README.md +55 -0
- package/package.json +42 -0
- package/src/CiscoCamera.d.ts +13 -0
- package/src/CiscoCamera.d.ts.map +1 -0
- package/src/CiscoCamera.ts +415 -0
- package/src/main.d.ts +12 -0
- package/src/main.d.ts.map +1 -0
- package/src/main.ts +98 -0
- package/tsconfig.json +13 -0
- package/webpack.nodejs.config.js +8 -0
|
@@ -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
|
+
}
|