ts-onvif
Version:
Client to ONVIF NVT devices
537 lines • 23 kB
JavaScript
;
/**
* Onvif module
* @author Andrew D.Laptev <a.d.laptev@gmail.com>
* @see https://www.onvif.org/wp-content/uploads/2022/07/ONVIF_Device_Feature_Discovery_Specification_21.12.pdf
*/
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.Onvif = void 0;
const events_1 = require("events");
const https_1 = __importDefault(require("https"));
const http_1 = __importDefault(require("http"));
const buffer_1 = require("buffer");
const crypto_1 = __importDefault(require("crypto"));
const utils_1 = require("./utils");
const device_1 = require("./device");
const media_1 = require("./media");
const media2_1 = require("./media2");
const ptz_1 = require("./ptz");
class Onvif extends events_1.EventEmitter {
/**
* Indicates raw xml request to device.
* @event rawRequest
* @example
* ```typescript
* onvif.on('rawRequest', (xml) => { console.log('-> request was', xml); });
* ```
*/
static rawRequest = 'rawRequest';
/**
* Indicates raw xml response from device.
* @event rawResponse
* @example
* ```typescript
* onvif.on('rawResponse', (xml) => { console.log('<- response was', xml); });
* ```
*/
static rawResponse = 'rawResponse';
/**
* Indicates any warnings
* @event warn
* @example
* ```typescript
* onvif.on('warn', console.warn);
* ```
*/
static warn = 'warn';
/**
* Indicates any errors
* @param error Error object
* @event error
* @example
* ```typescript
* onvif.on('error', console.error);
* ```
*/
static error = 'error';
/**
* Core device namespace for device v1.0 methods
* @example
* ```typescript
* const date = await onvif.device.getSystemDateAndTime();
* console.log(date.toLocaleString());
* ```
*/
device;
media;
media2;
ptz;
useSecure;
secureOptions;
useWSSecurity;
nc = 0;
hostname;
username;
password;
port;
path;
timeout;
agent;
preserveAddress = false;
events;
uri;
timeShift;
capabilities;
defaultProfiles = [];
defaultProfile;
activeSources = [];
activeSource;
urn;
deviceInformation;
constructor(options) {
super();
this.useSecure = options.useSecure ?? false;
this.secureOptions = options.secureOptions ?? {};
this.useWSSecurity = options.useWSSecurity ?? true;
this.hostname = options.hostname;
this.username = options.username;
this.password = options.password;
this.port = options.port ?? (options.useSecure ? 443 : 80);
this.path = options.path ?? '/onvif/device_service';
this.timeout = options.timeout || 120000;
this.urn = options.urn;
this.agent = options.agent ?? false;
this.preserveAddress = options.preserveAddress ?? false;
this.events = {};
this.uri = {};
this.capabilities = {};
this.device = new device_1.Device(this);
this.media = new media_1.Media(this);
this.media2 = new media2_1.Media2(this);
this.ptz = new ptz_1.PTZ(this);
/** Bind event handling to the `event` event */
this.on('newListener', (name) => {
// if this is the first listener, start pulling subscription
if (name === 'event' && this.listeners(name).length === 0) {
setImmediate(() => {
// this._eventRequest(); TODO bring back
});
}
});
if (options.autoConnect) {
setImmediate(() => {
this.connect().catch((error) => this.emit('error', error));
});
}
}
/**
* Envelope header for all SOAP messages
* @param openHeader
* @private
*/
envelopeHeader(openHeader = false) {
let header = '<s:Envelope xmlns:s="http://www.w3.org/2003/05/soap-envelope" xmlns:a="http://www.w3.org/2005/08/addressing">'
+ '<s:Header>';
// Only insert Security if there is a username and password
if (this.useWSSecurity && this.username && this.password) {
const req = this.passwordDigest();
header += '<Security s:mustUnderstand="1" xmlns="http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-wssecurity-secext-1.0.xsd">'
+ '<UsernameToken>'
+ `<Username>${this.username}</Username>`
+ `<Password Type="http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-username-token-profile-1.0#PasswordDigest">${req.passDigest}</Password>`
+ `<Nonce EncodingType="http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-soap-message-security-1.0#Base64Binary">${req.nonce}</Nonce>`
+ `<Created xmlns="http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-wssecurity-utility-1.0.xsd">${req.timestamp}</Created>`
+ '</UsernameToken>'
+ '</Security>';
}
if (!(openHeader !== undefined && openHeader)) {
header += '</s:Header>'
+ '<s:Body xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:xsd="http://www.w3.org/2001/XMLSchema">';
}
return header;
}
/**
* Envelope footer for all SOAP messages
* @private
*/
envelopeFooter() {
return '</s:Body>'
+ '</s:Envelope>';
}
passwordDigest() {
const timestamp = (new Date((process.uptime() * 1000) + (this.timeShift || 0))).toISOString();
const nonce = buffer_1.Buffer.allocUnsafe(16);
nonce.writeUIntLE(Math.ceil(Math.random() * 0x100000000), 0, 4);
nonce.writeUIntLE(Math.ceil(Math.random() * 0x100000000), 4, 4);
nonce.writeUIntLE(Math.ceil(Math.random() * 0x100000000), 8, 4);
nonce.writeUIntLE(Math.ceil(Math.random() * 0x100000000), 12, 4);
const cryptoDigest = crypto_1.default.createHash('sha1');
cryptoDigest.update(buffer_1.Buffer.concat([nonce, buffer_1.Buffer.from(timestamp, 'ascii'), buffer_1.Buffer.from(this.password, 'ascii')]));
const passDigest = cryptoDigest.digest('base64');
return {
passDigest,
nonce: nonce.toString('base64'),
timestamp,
};
}
async rawRequest(options) {
return new Promise((resolve, reject) => {
let alreadyReturned = false;
const requestOptions = {
...options,
hostname: this.hostname,
path: options.service
? (this.uri[options.service] ? this.uri[options.service].pathname : this.path)
: this.path,
port: this.port,
agent: this.agent, // Supports things like https://www.npmjs.com/package/proxy-agent which provide SOCKS5 and other connections}
timeout: this.timeout,
};
requestOptions.headers = {
...options.headers,
'Content-Type': 'application/soap+xml',
'Content-Length': buffer_1.Buffer.byteLength(options.body, 'utf8').toString(),
charset: 'utf-8',
};
requestOptions.method = 'POST';
const httpLibrary = this.useSecure ? https_1.default : http_1.default;
if (this.useSecure) {
Object.assign(requestOptions, this.secureOptions);
}
const request = httpLibrary.request(requestOptions, async (response) => {
const wwwAuthenticate = response.headers['www-authenticate'];
const { statusCode } = response;
if (statusCode === 401 && wwwAuthenticate !== undefined) {
// Re-request with the digest auth header
response.destroy();
try {
options.headers.Authorization = this.digestAuth(wwwAuthenticate, requestOptions.path);
const digestResponse = await this.rawRequest(options);
return resolve(digestResponse);
}
catch (e) {
return reject(e);
}
}
const bufs = [];
let length = 0;
response.on('data', (chunk) => {
bufs.push(chunk);
length += chunk.length;
});
response.on('end', () => {
if (alreadyReturned) {
return;
}
alreadyReturned = true;
const xml = buffer_1.Buffer.concat(bufs, length).toString('utf8');
/**
* Indicates raw xml response from device.
* @event Onvif#rawResponse
* @type {string}
*/
this.emit('rawResponse', xml);
resolve((0, utils_1.parseSOAPString)(xml));
});
return undefined;
});
request.setTimeout(this.timeout, () => {
if (alreadyReturned) {
return;
}
alreadyReturned = true;
request.destroy();
reject(new Error('Network timeout'));
});
request.on('error', (error) => {
if (alreadyReturned) {
return;
}
alreadyReturned = true;
/* address, port number or IPCam error */
if (error.code === 'ECONNREFUSED' && error.errno === 'ECONNREFUSED' && error.syscall === 'connect') {
reject(error);
/* network error */
}
else if (error.code === 'ECONNRESET' && error.errno === 'ECONNRESET' && error.syscall === 'read') {
reject(error);
}
else {
reject(error);
}
});
this.emit('rawRequest', options.body, requestOptions);
request.write(options.body);
request.end();
});
}
digestAuth(wwwAuthenticate, path) {
const challenge = this.parseChallenge(wwwAuthenticate);
const ha1 = crypto_1.default.createHash('md5');
ha1.update([this.username, challenge.realm, this.password].join(':'));
const ha2 = crypto_1.default.createHash('md5');
ha2.update(['POST', path].join(':'));
let cnonce = null;
let nc = null;
if (typeof challenge.qop === 'string') {
const cnonceHash = crypto_1.default.createHash('md5');
cnonceHash.update(Math.random().toString(36));
cnonce = cnonceHash.digest('hex').substring(0, 8);
nc = this.updateNC();
}
const response = crypto_1.default.createHash('md5');
const responseParams = [
ha1.digest('hex'),
challenge.nonce,
];
if (cnonce) {
responseParams.push(nc);
responseParams.push(cnonce);
}
responseParams.push(challenge.qop);
responseParams.push(ha2.digest('hex'));
response.update(responseParams.join(':'));
const authParams = {
username: this.username,
realm: challenge.realm,
nonce: challenge.nonce,
uri: path,
qop: challenge.qop,
response: response.digest('hex'),
};
if (challenge.opaque) {
authParams.opaque = challenge.opaque;
}
if (cnonce && nc) {
authParams.nc = nc;
authParams.cnonce = cnonce;
}
return `Digest ${Object.entries(authParams).map(([key, value]) => `${key}="${value}"`).join(',')}`;
}
request(options) {
if (!options.body) {
throw new Error('There is no \'body\' field in request options');
}
this.emit('requestBody', options.body);
options.headers = options.headers ?? {};
return this.rawRequest({
...options,
body: `${this.envelopeHeader()}${options.body}${this.envelopeFooter()}`,
});
}
parseChallenge(digest) {
const prefix = 'Digest ';
const challenge = digest.substring(digest.indexOf(prefix) + prefix.length);
const parts = challenge.split(',')
.map((part) => part.match(/^\s*?([a-zA-Z0-9]+)="?([^"]*)"?\s*?$/).slice(1));
return Object.fromEntries(parts);
}
updateNC() {
this.nc += 1;
if (this.nc > 99999999) {
this.nc = 1;
}
return String(this.nc).padStart(8, '0');
}
/**
* Parse url with an eye on `preserveAddress` property
* @param address
* @private
*/
parseUrl(address) {
const parsedAddress = new URL(address);
// If host for service and default host differs, also if preserve address property set
// we substitute host, hostname and port from settings then rebuild the href using .format
if (this.preserveAddress && (this.hostname !== parsedAddress.hostname || this.port.toString() !== parsedAddress.port)) {
parsedAddress.hostname = this.hostname;
parsedAddress.host = `${this.hostname}:${this.port}`;
parsedAddress.port = this.port.toString();
parsedAddress.href = parsedAddress.toString();
}
return parsedAddress;
}
/**
* Receive date and time from cam
*/
async getSystemDateAndTime() {
// The ONVIF spec says this should work without a Password as we need to know any difference in the
// remote NVT's time relative to our own time clock (called the timeShift) before we can calculate the
// correct timestamp in nonce SOAP Authentication header.
// But... Panasonic and Digital Barriers both have devices that implement ONVIF that only work with
// authenticated getSystemDateAndTime. So for these devices we need to do an authenticated getSystemDateAndTime.
// As 'timeShift' is not set, the local clock MUST be set to the correct time AND the NVT/Camera MUST be set
// to the correct time if the camera implements Replay Attack Protection (e.g. Axis)
const [data, xml] = await this.rawRequest({
// Try the Unauthenticated Request first. Do not use this._envelopeHeader() as we don't have timeShift yet.
body: '<s:Envelope xmlns:s="http://www.w3.org/2003/05/soap-envelope">'
+ '<s:Body xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:xsd="http://www.w3.org/2001/XMLSchema">'
+ '<GetSystemDateAndTime xmlns="http://www.onvif.org/ver10/device/wsdl"/>'
+ '</s:Body>'
+ '</s:Envelope>',
});
try {
return this.setupSystemDateAndTime(data);
}
catch (error) {
if (xml && xml.toLowerCase().includes('sender not authorized')) {
// Try again with a Username and Password
const [data] = await this.request({
body: '<GetSystemDateAndTime xmlns="http://www.onvif.org/ver10/device/wsdl"/>}',
});
return this.setupSystemDateAndTime(data);
}
throw error;
}
}
/**
* Receive only date and time from cam (old behaviour, returns only Date object)
*/
async getOnlySystemDateAndTime() {
return (await this.getSystemDateAndTime()).dateTime;
}
/**
* Add time shift to use with ONVIF timestamps
* @param data
* @private
*/
setupSystemDateAndTime(data) {
const { systemDateAndTime } = (0, utils_1.linerase)(data).getSystemDateAndTimeResponse;
// UTCDateTime is mandatory since version 2.0
const UTCDateTime = systemDateAndTime.UTCDateTime || systemDateAndTime.localDateTime;
let dateTime;
if (UTCDateTime === undefined) {
// Seen on a cheap Chinese camera from GWellTimes-IPC. Use the current time.
dateTime = new Date();
}
else {
dateTime = new Date(Date.UTC(UTCDateTime.date.year, UTCDateTime.date.month - 1, UTCDateTime.date.day, UTCDateTime.time.hour, UTCDateTime.time.minute, UTCDateTime.time.second));
}
if (!this.timeShift) {
this.timeShift = dateTime.getTime() - (process.uptime() * 1000);
}
systemDateAndTime.dateTime = dateTime;
return systemDateAndTime;
}
/**
* Set the device system date and time
* Supports two possible date and time values: UTCDateTime(ONVIF types) or dateTime(js Date-object, preferred)
*/
async setSystemDateAndTime(options) {
if (!['Manual', 'NTP'].includes(options.dateTimeType)) {
throw new Error('DateTimeType should be `Manual` or `NTP`');
}
if (options.dateTimeType === 'Manual' && !options.dateTime && !options.UTCDateTime) {
throw new Error('`dateTime` or `UTCDateTime` should be defined when the DateTimeType is `Manual`');
}
const body = '<SetSystemDateAndTime xmlns="http://www.onvif.org/ver10/device/wsdl">'
+ `<DateTimeType>${options.dateTimeType}</DateTimeType>`
+ `<DaylightSavings>${!!options.daylightSavings}</DaylightSavings>${options.timezone !== undefined || options.timeZone?.TZ !== undefined
? '<TimeZone>'
+ `<TZ xmlns="http://www.onvif.org/ver10/schema">${options.timezone || options.timeZone?.TZ}</TZ>`
+ '</TimeZone>' : ''}${options.dateTime !== undefined && options.dateTime instanceof Date
? '<UTCDateTime>'
+ '<Time xmlns="http://www.onvif.org/ver10/schema">'
+ `<Hour>${options.dateTime.getUTCHours()}</Hour>`
+ `<Minute>${options.dateTime.getUTCMinutes()}</Minute>`
+ `<Second>${options.dateTime.getUTCSeconds()}</Second>`
+ '</Time>'
+ '<Date xmlns="http://www.onvif.org/ver10/schema">'
+ `<Year>${options.dateTime.getUTCFullYear()}</Year>`
+ `<Month>${options.dateTime.getUTCMonth() + 1}</Month>`
+ `<Day>${options.dateTime.getUTCDate()}</Day>`
+ '</Date>'
+ '</UTCDateTime>'
: (options.UTCDateTime !== undefined ? '<UTCDateTime>'
+ '<Time xmlns="http://www.onvif.org/ver10/schema">'
+ `<Hour>${options.UTCDateTime?.time?.hour}</Hour>`
+ `<Minute>${options.UTCDateTime?.time?.minute}</Minute>`
+ `<Second>${options.UTCDateTime?.time?.second}</Second>`
+ '</Time>'
+ '<Date xmlns="http://www.onvif.org/ver10/schema">'
+ `<Year>${options.UTCDateTime?.date?.year}</Year>`
+ `<Month>${options.UTCDateTime?.date?.month}</Month>`
+ `<Day>${options.UTCDateTime?.date?.day}</Day>`
+ '</Date>'
+ '</UTCDateTime>' : '')}</SetSystemDateAndTime>`;
const [data] = await this.request({
// Try the Unauthenticated Request first. Do not use this._envelopeHeader() as we don't have timeShift yet.
body,
});
if ((0, utils_1.linerase)(data).setSystemDateAndTimeResponse.length !== 0) {
throw new Error(`Wrong 'SetSystemDateAndTime' response: '${(0, utils_1.linerase)(data).setSystemDateAndTimeResponse}'`);
}
// get new system time from device
return this.getSystemDateAndTime();
}
/**
* Check and find out video configuration for device
* @private
*/
async getActiveSources() {
this.media.videoSources.forEach(({ token: videoSrcToken }, idx) => {
// let's choose first appropriate profile for our video source and make it default
const appropriateProfiles = this.media.profiles.filter((profile) => (profile.videoSourceConfiguration?.sourceToken === videoSrcToken) && (profile.videoEncoderConfiguration !== undefined));
if (appropriateProfiles.length === 0) {
if (idx === 0) {
throw new Error('Unrecognized configuration');
}
else {
return;
}
}
if (idx === 0) {
[this.defaultProfile] = appropriateProfiles;
}
[this.defaultProfiles[idx]] = appropriateProfiles;
this.activeSources[idx] = {
sourceToken: videoSrcToken,
profileToken: this.defaultProfiles[idx].token,
videoSourceConfigurationToken: this.defaultProfiles[idx].videoSourceConfiguration.token,
};
if (this.defaultProfiles[idx].videoEncoderConfiguration) {
const configuration = this.defaultProfiles[idx].videoEncoderConfiguration;
this.activeSources[idx].encoding = configuration?.encoding;
this.activeSources[idx].width = configuration?.resolution?.width;
this.activeSources[idx].height = configuration?.resolution?.height;
this.activeSources[idx].fps = configuration?.rateControl?.frameRateLimit;
this.activeSources[idx].bitrate = configuration?.rateControl?.bitrateLimit;
}
if (idx === 0) {
this.activeSource = this.activeSources[idx];
}
if (this.defaultProfiles[idx].PTZConfiguration) {
this.activeSources[idx].ptz = {
name: this.defaultProfiles[idx].PTZConfiguration.name,
token: this.defaultProfiles[idx].PTZConfiguration.token,
};
/*
TODO Think about it
if (idx === 0) {
this.defaultProfile.PTZConfiguration = this.activeSources[idx].PTZConfiguration;
} */
}
});
}
/**
* Connect to the camera and fill device information properties
*/
async connect() {
await this.getSystemDateAndTime();
// Try to get services (new approach). If not, get capabilities
try {
await this.device.getServices();
}
catch (error) {
await this.device.getCapabilities();
}
await Promise.all([this.media.getProfiles(), this.media.getVideoSources()]);
await this.getActiveSources();
this.emit('connect');
return this;
}
}
exports.Onvif = Onvif;
//# sourceMappingURL=onvif.js.map