Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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: <camera user> # only used once at startup to discover the camera's PTZ profile
password: <camera pass>
# 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
Expand Down
8 changes: 8 additions & 0 deletions main.js
Original file line number Diff line number Diff line change
Expand Up @@ -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] = {}

Expand Down
237 changes: 233 additions & 4 deletions src/onvif-server.js
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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: {
Expand Down Expand Up @@ -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`,
Expand All @@ -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`,
Expand All @@ -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
};
},

Expand Down Expand Up @@ -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`;
Expand Down Expand Up @@ -338,15 +445,137 @@ 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');
response.end();
}
}

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);

Expand Down