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 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..b6f5e6b 100644 --- a/src/onvif-server.js +++ b/src/onvif-server.js @@ -34,6 +34,44 @@ class OnvifServer { if (!this.config.hostname) this.config.hostname = getIpAddressFromMac(this.config.mac); + 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' @@ -126,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: { @@ -224,6 +273,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 +299,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 +316,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 }; }, @@ -295,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`; @@ -338,6 +445,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 +454,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);