From df82dce25d350051697f744b20bc7dcc8420edf6 Mon Sep 17 00:00:00 2001 From: coolham123 Date: Sat, 11 Jul 2026 23:24:30 -0300 Subject: [PATCH 1/3] Add ONVIF PTZ passthrough Co-Authored-By: Claude Fable 5 --- main.js | 8 +++ src/onvif-server.js | 151 ++++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 155 insertions(+), 4 deletions(-) diff --git a/main.js b/main.js index dd86cfc..0f3263c 100644 --- a/main.js +++ b/main.js @@ -98,6 +98,14 @@ if (args) { logger.info(' Started!'); logger.info(''); + if (onvifConfig.ptz) { + server.startPtz().then((token) => { + logger.info(`PTZ passthrough enabled for ${onvifConfig.name} (camera profile: ${token})`); + }).catch((err) => { + logger.error(`PTZ setup failed for ${onvifConfig.name}: ${err.message || err}`); + }); + } + if (!proxies[onvifConfig.target.hostname]) proxies[onvifConfig.target.hostname] = {} diff --git a/src/onvif-server.js b/src/onvif-server.js index 8a4e77b..8977049 100644 --- a/src/onvif-server.js +++ b/src/onvif-server.js @@ -34,6 +34,9 @@ class OnvifServer { if (!this.config.hostname) this.config.hostname = getIpAddressFromMac(this.config.mac); + this.realPtzProfileToken = null; + this.ptzTarget = null; + this.videoSource = { attributes: { token: 'video_src_token' @@ -224,6 +227,11 @@ class OnvifServer { Extension: {} }; } + if (this.config.ptz && (args.Category === undefined || args.Category == 'All' || args.Category == 'PTZ')) { + response.Capabilities['PTZ'] = { + XAddr: `http://${this.config.hostname}:${this.config.ports.server}/onvif/ptz_service` + }; + } if (args.Category === undefined || args.Category == 'All' || args.Category == 'Media') { response.Capabilities['Media'] = { XAddr: `http://${this.config.hostname}:${this.config.ports.server}/onvif/media_service`, @@ -245,8 +253,7 @@ class OnvifServer { }, GetServices: (args) => { - return { - Service : [ + let services = [ { Namespace : 'http://www.onvif.org/ver10/device/wsdl', XAddr : `http://${this.config.hostname}:${this.config.ports.server}/onvif/device_service`, @@ -263,7 +270,21 @@ class OnvifServer { Minor : 5, } } - ] + ]; + + if (this.config.ptz) { + services.push({ + Namespace : 'http://www.onvif.org/ver20/ptz/wsdl', + XAddr : `http://${this.config.hostname}:${this.config.ports.server}/onvif/ptz_service`, + Version : { + Major : 2, + Minor : 5, + } + }); + } + + return { + Service : services }; }, @@ -338,6 +359,8 @@ class OnvifServer { let image = fs.readFileSync('./resources/snapshot.png'); response.writeHead(200, {'Content-Type': 'image/png' }); response.end(image, 'binary'); + } else if (this.config.ptz && action == '/onvif/ptz_service' && request.method == 'POST') { + this.relayPtz(request, response); } else { response.writeHead(404, {'Content-Type': 'text/plain'}); response.write('404 Not Found\n'); @@ -345,8 +368,128 @@ class OnvifServer { } } + relayPtz(request, response) { + let chunks = []; + request.on('data', (chunk) => chunks.push(chunk)); + request.on('end', () => { + if (!this.ptzTarget) { + response.writeHead(502, {'Content-Type': 'text/plain'}); + response.end('PTZ passthrough not initialized\n'); + return; + } + + let body = Buffer.concat(chunks).toString('utf8'); + + // Rewrite our virtual profile tokens to the camera's real PTZ profile token. + // All other tokens (configuration, node, preset) originate from the camera + // via relayed responses and are passed through untouched. + if (this.realPtzProfileToken) { + body = body.replace( + /(<[^>]*ProfileToken[^>]*>)\s*(?:main_stream|sub_stream)\s*(<\/[^>]*ProfileToken[^>]*>)/g, + `$1${this.realPtzProfileToken}$2` + ); + } + + const relayRequest = http.request({ + hostname: this.ptzTarget.hostname, + port: this.ptzTarget.port, + path: this.ptzTarget.path, + method: 'POST', + headers: { + 'Content-Type': request.headers['content-type'] || 'application/soap+xml; charset=utf-8', + 'Content-Length': Buffer.byteLength(body) + } + }, (relayResponse) => { + this.logger.debug(`PtzService: relayed request -> ${relayResponse.statusCode}`); + response.writeHead(relayResponse.statusCode, { + 'Content-Type': relayResponse.headers['content-type'] || 'application/soap+xml; charset=utf-8' + }); + relayResponse.pipe(response); + }); + + relayRequest.setTimeout(10000, () => relayRequest.destroy(new Error('PTZ relay timeout'))); + relayRequest.on('error', (err) => { + this.logger.error(`PtzService: relay error: ${err.message}`); + if (!response.headersSent) + response.writeHead(502, {'Content-Type': 'text/plain'}); + response.end(); + }); + + relayRequest.end(body); + }); + } + + async startPtz() { + const ptzConfig = this.config.ptz; + const onvifPort = ptzConfig.port || 8000; + const endpoint = `http://${this.config.target.hostname}:${onvifPort}/onvif/device_service`; + + const options = { forceSoap12Headers: true }; + const securityOptions = { hasNonce: true, passwordType: 'PasswordDigest' }; + + // Discover the camera's PTZ-capable media profile. + let mediaClient = await soap.createClientAsync('./wsdl/media_service.wsdl', options); + mediaClient.setEndpoint(endpoint); + mediaClient.setSecurity(new soap.WSSecurity(ptzConfig.username, ptzConfig.password, securityOptions)); + + let profiles = (await mediaClient.GetProfilesAsync({}))[0].Profiles; + let realProfile = null; + for (let profile of profiles) { + if (!profile.PTZConfiguration) + continue; + if (ptzConfig.profileToken) { + if (profile.attributes.token === ptzConfig.profileToken) { + realProfile = profile; + break; + } + } else { + realProfile = profile; + break; + } + } + + if (!realProfile) + throw new Error('No camera media profile with a PTZConfiguration was found' + + (ptzConfig.profileToken ? ` (matching token '${ptzConfig.profileToken}')` : '')); + + this.realPtzProfileToken = realProfile.attributes.token; + + // Embed the camera's real PTZConfiguration into our virtual profiles so + // clients detect PTZ capability; its tokens are valid on the relayed service. + for (let profile of this.profiles) + profile.PTZConfiguration = realProfile.PTZConfiguration; + + // Discover the camera's real PTZ service endpoint. + let deviceClient = await soap.createClientAsync('./wsdl/device_service.wsdl', options); + deviceClient.setEndpoint(endpoint); + deviceClient.setSecurity(new soap.WSSecurity(ptzConfig.username, ptzConfig.password, securityOptions)); + + let ptzPath = '/onvif/ptz_service'; + let ptzPort = onvifPort; + try { + let capabilities = (await deviceClient.GetCapabilitiesAsync({ Category: 'PTZ' }))[0]; + if (capabilities && capabilities.Capabilities && capabilities.Capabilities.PTZ && capabilities.Capabilities.PTZ.XAddr) { + let xaddr = url.parse(capabilities.Capabilities.PTZ.XAddr); + ptzPath = xaddr.pathname || ptzPath; + ptzPort = parseInt(xaddr.port) || (xaddr.protocol === 'https:' ? 443 : 80); + } + } catch (err) { + this.logger.warn(`PtzService: GetCapabilities(PTZ) failed (${err.message}), assuming ${ptzPath} on port ${ptzPort}`); + } + + // Always contact the camera at its configured hostname, regardless of + // what host the camera put in its XAddr. + this.ptzTarget = { + hostname: this.config.target.hostname, + port: ptzPort, + path: ptzPath + }; + + return this.realPtzProfileToken; + } + startServer() { - this.server = http.createServer(this.listen); + this.server = http.createServer((request, response) => this.listen(request, response)); this.server.listen(this.config.ports.server, this.config.hostname); From 6207b7e5a31edfa46dd190dada971ebf566620dc Mon Sep 17 00:00:00 2001 From: coolham123 Date: Sat, 11 Jul 2026 23:39:12 -0300 Subject: [PATCH 2/3] Add audio advertisement to virtual ONVIF profiles --- src/onvif-server.js | 86 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 86 insertions(+) diff --git a/src/onvif-server.js b/src/onvif-server.js index 8977049..b6f5e6b 100644 --- a/src/onvif-server.js +++ b/src/onvif-server.js @@ -37,6 +37,41 @@ class OnvifServer { this.realPtzProfileToken = null; this.ptzTarget = null; + this.audioConfig = null; + if (this.config.audio) { + let audio = typeof this.config.audio === 'object' ? this.config.audio : {}; + this.audioConfig = { + encoding: audio.encoding || 'AAC', + bitrate: audio.bitrate || 64, + samplerate: audio.samplerate || 16 + }; + this.audioSource = { + attributes: { + token: 'audio_src_token' + }, + Channels: 1 + }; + this.audioSourceConfiguration = { + attributes: { + token: 'audio_src_config_token' + }, + Name: 'AudioSource', + UseCount: 2, + SourceToken: 'audio_src_token' + }; + this.audioEncoderConfiguration = { + attributes: { + token: 'audio_encoder_config_token' + }, + Name: 'CardinalAudioConfiguration', + UseCount: 2, + Encoding: this.audioConfig.encoding, + Bitrate: this.audioConfig.bitrate, + SampleRate: this.audioConfig.samplerate, + SessionTimeout: 'PT1000S' + }; + } + this.videoSource = { attributes: { token: 'video_src_token' @@ -129,6 +164,17 @@ class OnvifServer { ); } + if (this.audioConfig) { + this.profiles = this.profiles.map((profile) => ({ + Name: profile.Name, + attributes: profile.attributes, + VideoSourceConfiguration: profile.VideoSourceConfiguration, + AudioSourceConfiguration: this.audioSourceConfiguration, + VideoEncoderConfiguration: profile.VideoEncoderConfiguration, + AudioEncoderConfiguration: this.audioEncoderConfiguration + })); + } + this.onvif = { DeviceService: { Device: { @@ -316,6 +362,46 @@ class OnvifServer { ] }; }, + + GetAudioSources: (args) => { + return { + AudioSources: this.audioConfig ? [ this.audioSource ] : [] + }; + }, + + GetAudioSourceConfigurations: (args) => { + return { + Configurations: this.audioConfig ? [ this.audioSourceConfiguration ] : [] + }; + }, + + GetAudioEncoderConfigurations: (args) => { + return { + Configurations: this.audioConfig ? [ this.audioEncoderConfiguration ] : [] + }; + }, + + GetAudioEncoderConfiguration: (args) => { + if (this.audioConfig) + return { Configuration: this.audioEncoderConfiguration }; + return {}; + }, + + GetAudioEncoderConfigurationOptions: (args) => { + if (!this.audioConfig) + return { Options: {} }; + return { + Options: { + Options: [ + { + Encoding: this.audioConfig.encoding, + BitrateList: { Items: [ this.audioConfig.bitrate ] }, + SampleRateList: { Items: [ this.audioConfig.samplerate ] } + } + ] + } + }; + }, GetSnapshotUri: (args) => { let uri = `http://${this.config.hostname}:${this.config.ports.server}/snapshot.png`; From ee9a90b520f378c887d604fa837dc299e1fae4b1 Mon Sep 17 00:00:00 2001 From: coolham123 Date: Sat, 11 Jul 2026 23:51:35 -0300 Subject: [PATCH 3/3] Document ptz and audio config options in README Co-Authored-By: Claude Fable 5 --- README.md | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/README.md b/README.md index d933961..d6344af 100644 --- a/README.md +++ b/README.md @@ -114,6 +114,35 @@ onvif: The above configuration creates a virtual Onvif device that listens on port 8081 of the `a2:a2:a2:a2:a2:a1` virtual network and forwards the RTSP video streams and snapshots from `192.168.1.152` (the real Onvif server). +## Audio (optional) +If the RTSP stream of the real camera contains an audio track, you can advertise it in the virtual device's Onvif profiles by adding an `audio:` key to the camera entry (the audio bytes themselves already flow through the RTSP proxy unchanged): + +```yaml + audio: true +``` + +By default the audio track is advertised as AAC at 64 kbps / 16 kHz. If your camera uses different settings, they can be specified explicitly: + +```yaml + audio: + encoding: AAC + bitrate: 64 + samplerate: 16 +``` + +## PTZ Passthrough (optional) +If the real camera supports PTZ, adding a `ptz:` key (as a sibling of `target`) makes the virtual device advertise a PTZ service and transparently relay PTZ requests to the real camera: + +```yaml + ptz: + port: 8000 # Onvif port of the real camera + username: # only used once at startup to discover the camera's PTZ profile + password: + # profileToken: '001' # optional: override which camera profile to use for PTZ +``` + +At startup the server looks up the camera's PTZ-capable media profile and embeds its PTZ configuration into the virtual profiles. Incoming PTZ requests are relayed to the real camera unchanged (only the profile token is rewritten), so the client's own credentials are used to authenticate against the camera — the virtual server does not store them. + ## Start Virtual Onvif Servers Finally, to start the virtual Onvif devices run: ```bash