upnp-device-client
Version:
A simple and versatile UPnP device client
604 lines (477 loc) • 16.4 kB
JavaScript
var http = require('http');
var util = require('util');
var EventEmitter = require('events').EventEmitter;
var et = require('elementtree');
var parseUrl = require('url').parse;
var os = require('os');
var concat = require('concat-stream');
var address = require('network-address');
var debug = require('debug')('upnp-device-client');
var pkg = require('./package.json');
var OS_VERSION = [os.platform(), os.release()].join('/');
var PACKAGE_VERSION = [pkg.name, pkg.version].join('/');
var SUBSCRIPTION_TIMEOUT = 300;
function DeviceClient(url) {
EventEmitter.call(this);
this.url = url;
this.deviceDescription = null;
this.serviceDescriptions = {};
this.server = null;
this.listening = false;
this.subscriptions = {};
}
util.inherits(DeviceClient, EventEmitter);
DeviceClient.prototype.getDeviceDescription = function(callback) {
var self = this;
// Use cache if available
if(this.deviceDescription) {
process.nextTick(function() {
callback(null, self.deviceDescription);
});
return;
}
debug('fetch device description');
fetch(this.url, function(err, body) {
if(err) return callback(err);
var desc = parseDeviceDescription(body, self.url);
self.deviceDescription = desc // Store in cache for next call
callback(null, desc);
});
};
DeviceClient.prototype.getServiceDescription = function(serviceId, callback) {
var self = this;
serviceId = resolveService(serviceId);
this.getDeviceDescription(function(err, desc) {
if(err) return callback(err);
var service = desc.services[serviceId];
if(!service) {
var err = new Error('Service ' + serviceId + ' not provided by device');
err.code = 'ENOSERVICE';
return callback(err);
}
// Use cache if available
if(self.serviceDescriptions[serviceId]) {
return callback(null, self.serviceDescriptions[serviceId]);
}
debug('fetch service description (%s)', serviceId);
fetch(service.SCPDURL, function(err, body) {
if(err) return callback(err);
var desc = parseServiceDescription(body);
self.serviceDescriptions[serviceId] = desc; // Store in cache for next call
callback(null, desc);
});
});
};
DeviceClient.prototype.callAction = function(serviceId, actionName, params, callback) {
var self = this;
serviceId = resolveService(serviceId);
this.getServiceDescription(serviceId, function(err, desc) {
if(err) return callback(err);
if(!desc.actions[actionName]) {
var err = new Error('Action ' + actionName + ' not implemented by service');
err.code = 'ENOACTION';
return callback(err);
}
var service = self.deviceDescription.services[serviceId];
// Build SOAP action body
var envelope = et.Element('s:Envelope');
envelope.set('xmlns:s', 'http://schemas.xmlsoap.org/soap/envelope/');
envelope.set('s:encodingStyle', 'http://schemas.xmlsoap.org/soap/encoding/');
var body = et.SubElement(envelope, 's:Body');
var action = et.SubElement(body, 'u:' + actionName);
action.set('xmlns:u', service.serviceType);
Object.keys(params).forEach(function(paramName) {
var tmp = et.SubElement(action, paramName);
var value = params[paramName];
tmp.text = (value === null)
? ''
: params[paramName].toString();
});
var doc = new et.ElementTree(envelope);
var xml = doc.write({
xml_declaration: true,
});
// Send action request
var options = parseUrl(service.controlURL);
options.method = 'POST';
options.headers = {
'Content-Type': 'text/xml; charset="utf-8"',
'Content-Length': xml.length,
'Connection': 'close',
'SOAPACTION': '"' + service.serviceType + '#' + actionName + '"'
};
debug('call action %s on service %s with params %j', actionName, serviceId, params);
var req = http.request(options, function(res) {
res.pipe(concat(function(buf) {
var doc = et.parse(buf.toString());
if(res.statusCode !== 200) {
var errorCode = doc.findtext('.//errorCode');
var errorDescription = doc.findtext('.//errorDescription').trim();
var err = new Error(errorDescription + ' (' + errorCode + ')');
err.code = 'EUPNP';
err.statusCode = res.statusCode;
err.errorCode = errorCode;
return callback(err);
}
// Extract response outputs
var serviceDesc = self.serviceDescriptions[serviceId];
var actionDesc = serviceDesc.actions[actionName];
var outputs = actionDesc.outputs.map(function(desc) {
return desc.name;
});
var result = {};
outputs.forEach(function(name) {
result[name] = doc.findtext('.//' + name);
});
callback(null, result)
}));
});
req.on('error', callback);
req.end(xml);
});
};
DeviceClient.prototype.subscribe = function(serviceId, listener) {
var self = this;
serviceId = resolveService(serviceId);
if(this.subscriptions[serviceId]) {
// If we already have a subscription to this service,
// add the provided callback to the listeners and return
this.subscriptions[serviceId].listeners.push(listener);
return;
}
// If there's no subscription to this service, create one
// by first fetching the event subscription URL ...
this.getDeviceDescription(function(err, desc) {
if(err) return self.emit('error', err);
var service = desc.services[serviceId];
if(!service) {
var err = new Error('Service ' + serviceId + ' not provided by device');
err.code = 'ENOSERVICE';
return self.emit('error', err);
}
// ... and ensuring the event server is created and listening
self.ensureEventingServer(function() {
var options = parseUrl(service.eventSubURL);
var server = self.server;
options.method = 'SUBSCRIBE';
options.headers = {
'HOST': options.host,
'USER-AGENT': [OS_VERSION, 'UPnP/1.1', PACKAGE_VERSION].join(' '),
'CALLBACK': '<http://' + server.address().address + ':' + server.address().port + '/>',
'NT': 'upnp:event',
'TIMEOUT': 'Second-' + SUBSCRIPTION_TIMEOUT
};
var req = http.request(options, function(res) {
if(res.statusCode !== 200) {
var err = new Error('SUBSCRIBE error');
err.statusCode = res.statusCode;
self.releaseEventingServer();
self.emit('error', err);
return;
}
var sid = res.headers['sid'];
var timeout = parseTimeout(res.headers['timeout']);
function renew() {
debug('renew subscription to %s', serviceId);
var options = parseUrl(service.eventSubURL);
options.method = 'SUBSCRIBE';
options.headers = {
'HOST': options.host,
'SID': sid,
'TIMEOUT': 'Second-' + SUBSCRIPTION_TIMEOUT
};
var req = http.request(options, function(res) {
if(res.statusCode !== 200) {
var err = new Error('SUBSCRIBE renewal error');
err.statusCode = res.statusCode;
// XXX: should we clear the subscription and release the server here ?
self.emit('error', err);
return;
}
var timeout = parseTimeout(res.headers['timeout']);
var renewTimeout = Math.max(timeout - 30, 30); // renew 30 seconds before expiration
debug('renewing subscription to %s in %d seconds', serviceId, renewTimeout);
var timer = setTimeout(renew, renewTimeout * 1000);
self.subscriptions[serviceId].timer = timer;
});
req.on('error', function(err) {
self.emit('error', err);
});
req.end();
}
var renewTimeout = Math.max(timeout - 30, 30); // renew 30 seconds before expiration
debug('renewing subscription to %s in %d seconds', serviceId, renewTimeout);
var timer = setTimeout(renew, renewTimeout * 1000);
self.subscriptions[serviceId] = {
sid: sid,
url: service.eventSubURL,
timer: timer,
listeners: [listener]
};
});
req.on('error', function(err) {
self.releaseEventingServer();
self.emit('error', err);
});
req.end();
});
});
};
DeviceClient.prototype.unsubscribe = function(serviceId, listener) {
var self = this;
serviceId = resolveService(serviceId);
// First make sure there are subscriptions for this service ...
var subscription = this.subscriptions[serviceId];
if(!subscription) return;
// ... and we know about this listener
var idx = subscription.listeners.indexOf(listener);
if(idx === -1) return;
// Remove the listener from the list
subscription.listeners.splice(idx, 1);
if(subscription.listeners.length === 0) {
// If there's no listener left for this service, unsubscribe from it
debug('unsubscribe from service %s', serviceId);
var options = parseUrl(subscription.url);
options.method = 'UNSUBSCRIBE';
options.headers = {
'HOST': options.host,
'SID': subscription.sid
};
var req = http.request(options, function(res) {
if(res.statusCode !== 200) {
var err = new Error('UNSUBSCRIBE error');
err.statusCode = res.statusCode;
return self.emit('error', err);
}
clearTimeout(self.subscriptions[serviceId].timer);
delete self.subscriptions[serviceId];
// Make sure the eventing server is shutdown if there is no
// subscription left for any service
self.releaseEventingServer();
});
req.on('error', function(err) {
self.emit('error', err);
});
req.end();
}
};
DeviceClient.prototype.ensureEventingServer = function(callback) {
var self = this;
if(!this.server) {
debug('create eventing server');
this.server = http.createServer(function(req, res) {
req.pipe(concat(function(buf) {
var sid = req.headers['sid'];
var seq = req.headers['seq'];
var events = parseEvents(buf);
debug('received events %s %d %j', sid, seq, events);
var keys = Object.keys(self.subscriptions);
var sids = keys.map(function(key) {
return self.subscriptions[key].sid;
})
var idx = sids.indexOf(sid);
if(idx === -1) {
debug('WARNING unknown SID %s', sid);
// silently ignore unknown SIDs
return;
}
var serviceId = keys[idx];
var listeners = self.subscriptions[serviceId].listeners;
// Dispatch each event to each listener registered for
// this service's events
listeners.forEach(function(listener) {
events.forEach(function(e) {
listener(e);
});
});
}));
});
this.server.listen(0, address.ipv4());
}
if(!this.listening) {
this.server.on('listening', function() {
self.listening = true;
callback();
});
} else {
process.nextTick(callback);
}
};
DeviceClient.prototype.releaseEventingServer = function() {
if(Object.keys(this.subscriptions).length === 0) {
debug('shutdown eventing server');
this.server.close();
this.server = null;
this.listening = false;
}
};
function parseEvents(buf) {
var events = [];
var doc = et.parse(buf.toString());
var lastChange = doc.findtext('.//LastChange');
if(lastChange) {
// AVTransport and RenderingControl services embed event data
// in an `<Event></Event>` element stored as an URIencoded string.
doc = et.parse(lastChange);
// The `<Event></Event>` element contains one `<InstanceID></InstanceID>`
// subtree per stream instance reporting its status.
var instances = doc.findall('./InstanceID');
instances.forEach(function(instance) {
var data = {
InstanceID: Number(instance.get('val'))
};
instance.findall('./*').forEach(function(node) {
data[node.tag] = node.get('val');
});
events.push(data);
});
} else {
// In any other case, each variable is stored separately in a
// `<property></property>` tag
var data = {};
doc.findall('./property/*').forEach(function(node) {
data[node.tag] = node.text;
});
events.push(data);
}
return events;
}
function parseTimeout(header) {
return Number(header.split('-')[1]);
}
function parseDeviceDescription(xml, url) {
var doc = et.parse(xml);
var desc = extractFields(doc.find('./device'), [
'deviceType',
'friendlyName',
'manufacturer',
'manufacturerURL',
'modelName',
'modelNumber',
'modelDescription',
'UDN'
]);
var nodes = doc.findall('./device/iconList/icon');
desc.icons = nodes.map(function(icon) {
return extractFields(icon, [
'mimetype',
'width',
'height',
'depth',
'url'
]);
});
var nodes = doc.findall('./device/serviceList/service');
desc.services = {};
nodes.forEach(function(service) {
var tmp = extractFields(service, [
'serviceType',
'serviceId',
'SCPDURL',
'controlURL',
'eventSubURL'
]);
var id = tmp.serviceId;
delete tmp.serviceId;
desc.services[id] = tmp;
});
// Make URLs absolute
var baseUrl = extractBaseUrl(url);
desc.icons.map(function(icon) {
icon.url = buildAbsoluteUrl(baseUrl, icon.url);
return icon;
});
Object.keys(desc.services).forEach(function(id) {
var service = desc.services[id];
service.SCPDURL = buildAbsoluteUrl(baseUrl, service.SCPDURL);
service.controlURL = buildAbsoluteUrl(baseUrl, service.controlURL);
service.eventSubURL = buildAbsoluteUrl(baseUrl, service.eventSubURL);
});
return desc;
}
function parseServiceDescription(xml) {
var doc = et.parse(xml);
var desc = {};
desc.actions = {};
var nodes = doc.findall('./actionList/action');
nodes.forEach(function(action) {
var name = action.findtext('./name');
var inputs = [];
var outputs = [];
var nodes = action.findall('./argumentList/argument');
nodes.forEach(function(argument) {
var arg = extractFields(argument, [
'name',
'direction',
'relatedStateVariable'
]);
var direction = arg.direction;
delete arg.direction;
if(direction === 'in') inputs.push(arg);
else outputs.push(arg);
});
desc.actions[name] = {
inputs: inputs,
outputs: outputs
};
});
desc.stateVariables = {};
var nodes = doc.findall('./serviceStateTable/stateVariable');
nodes.forEach(function(stateVariable) {
var name = stateVariable.findtext('./name');
var nodes = stateVariable.findall('./allowedValueList/allowedValue');
var allowedValues = nodes.map(function(allowedValue) {
return allowedValue.text;
});
desc.stateVariables[name] = {
dataType: stateVariable.findtext('./dataType'),
sendEvents: stateVariable.get('sendEvents'),
allowedValues: allowedValues,
defaultValue: stateVariable.findtext('./defaultValue')
};
});
return desc;
}
function fetch(url, callback) {
var req = http.get(url, function(res) {
if(res.statusCode !== 200) {
var err = new Error('Request failed');
err.statusCode = res.statusCode;
return callback(err);
}
res.pipe(concat(function(buf) {
callback(null, buf.toString())
}));
});
req.on('error', callback);
req.end();
}
function extractFields(node, fields) {
var data = {};
fields.forEach(function(field) {
var value = node.findtext('./' + field);
if(typeof value !== 'undefined') {
data[field] = value;
}
});
return data;
}
function buildAbsoluteUrl(base, url) {
if(url === '') return '';
if(url.substring(0, 4) === 'http') return url;
if(url[0] === '/') {
var root = base.split('/').slice(0, 3).join('/'); // http://host:port
return root + url;
} else {
return base + '/' + url;
}
}
function extractBaseUrl(url) {
return url.split('/').slice(0, -1).join('/');
}
function resolveService(serviceId) {
return (serviceId.indexOf(':') === -1)
? 'urn:upnp-org:serviceId:' + serviceId
: serviceId;
}
module.exports = DeviceClient;