"use strict";
/**
* Roon API.
* @class RoonApi
* @param {object} desc - Information about your extension. Used by Roon to display to the end user what is trying to access Roon.
* @param {string} desc.extension_id - A unique ID for this extension. Something like @com.your_company_or_name.name_of_extension@.
* @param {string} desc.display_name - The name of your extension.
* @param {string} desc.display_version - A version string that is displayed to the user for this extension. Can be anything you want.
* @param {string} desc.publisher - The name of the developer of the extension.
* @param {string} desc.website - Website for more information about the extension.
* @param {RoonApi~core_paired} [desc.core_paired] - Called when Roon pairs you.
* @param {RoonApi~core_unpaired} [desc.core_unpaired] - Called when Roon unpairs you.
* @param {RoonApi~core_found} [desc.core_found] - Called when a Roon Core is found. Usually, you want to implement pairing instead of using this.
* @param {RoonApi~core_lost} [desc.core_lost] - Called when Roon Core is lost. Usually, you want to implement pairing instead of using this.
*/
/**
* @callback RoonApi~core_paired
* @param {Core} core
*/
/**
* @callback RoonApi~core_unpaired
* @param {Core} core
*/
/**
* @callback RoonApi~core_found
* @param {Core} core
*/
/**
* @callback RoonApi~core_lost
* @param {Core} core
*/
// polyfill websockets in Node
if (typeof(WebSocket) == "undefined") global.WebSocket = require('ws');
var uuid = require('node-uuid'),
Moo = require('./moo.js'),
MooMessage = require('./moomsg.js'),
Core = require('./core.js');
function RoonApi(o) {
this._service_request_handlers = {};
if (typeof(o.extension_id) != 'string') throw new Error("Roon Extension options is missing the required 'extension_id' property.");
if (typeof(o.display_name) != 'string') throw new Error("Roon Extension options is missing the required 'display_name' property.");
if (typeof(o.display_version) != 'string') throw new Error("Roon Extension options is missing the required 'display_version' property.");
if (typeof(o.publisher) != 'string') throw new Error("Roon Extension options is missing the required 'publisher' property.");
if (typeof(o.email) != 'string') throw new Error("Roon Extension options is missing the required 'email' property.");
if (typeof(o.set_persisted_state) == 'undefined')
this.set_persisted_state = state => { this.save_config("roonstate", state); };
else
this.set_persisted_state = o.set_persisted_state;
if (typeof(o.get_persisted_state) == 'undefined')
this.get_persisted_state = () => { return this.load_config("roonstate") || {}; };
else
this.get_persisted_state = o.get_persisted_state;
if (o.core_found && !o.core_lost) throw new Error("Roon Extension options .core_lost is required if you implement .core_found.");
if (!o.core_found && o.core_lost) throw new Error("Roon Extension options .core_found is required if you implement .core_lost.");
if (o.core_paired && !o.core_unpaired) throw new Error("Roon Extension options .core_unpaired is required if you implement .core_paired.");
if (!o.core_paired && o.core_unpaired) throw new Error("Roon Extension options .core_paired is required if you implement .core_unpaired.");
if (o.core_paired && o.core_found) throw new Error("Roon Extension options can not specify both .core_paired and .core_found.");
if (o.core_found && typeof(o.core_found) != "function") throw new Error("Roon Extensions options has a .core_found which is not a function");
if (o.core_lost && typeof(o.core_lost) != "function") throw new Error("Roon Extensions options has a .core_lost which is not a function");
if (o.core_paired && typeof(o.core_paired) != "function") throw new Error("Roon Extensions options has a .core_paired which is not a function");
if (o.core_unpaired && typeof(o.core_unpaired) != "function") throw new Error("Roon Extensions options has a .core_unpaired which is not a function");
this.extension_reginfo = {
extension_id: o.extension_id,
display_name: o.display_name,
display_version: o.display_version,
publisher: o.publisher,
email: o.email,
required_services: [],
optional_services: [],
provided_services: []
};
if (o.website) this.extension_reginfo.website = o.website;
this.extension_opts = o;
}
/**
* Initializes the services you require and that you provide.
*
* @this RoonApi
* @param {object} services - Information about your extension. Used by Roon to display to the end user what is trying to access Roon.
* @param {object[]} [services.required_services] - A list of services which the Roon Core must provide.
* @param {object[]} [services.optional_services] - A list of services which the Roon Core may provide.
* @param {object[]} [services.provided_services] - A list of services which this extension provides to the Roon Core.
*/
RoonApi.prototype.init_services = function(o) {
if (!Array.isArray(o.required_services)) o.required_services = [];
if (!Array.isArray(o.optional_services)) o.optional_services = [];
if (!Array.isArray(o.provided_services)) o.provided_services = [];
if (o.required_services.length || o.optional_services.length)
if (!this.extension_opts.core_paired && !this.extension_opts.core_found) throw new Error("Roon Extensions options has required or optional services, but has neither .core_paired nor .core_found.");
if (this.extension_opts.core_paired) {
let svc = this.register_service("com.roonlabs.pairing:1", {
subscriptions: [
{
subscribe_name: "subscribe_pairing",
unsubscribe_name: "unsubscribe_pairing",
start: (req) => {
req.send_continue("Subscribed", { paired_core_id: this.paired_core_id });
}
}
],
methods: {
get_pairing: (req) => {
req.send_complete("Success", { paired_core_id: this.paired_core_id });
},
pair: (req) => {
this.paired_core_id = req.moo.core.core_id;
svc.send_continue_all("subscribe_pairing", "Changed", { paired_core_id: this.paired_core_id })
},
}
});
this.pairing_service_1 = {
services: [ svc ],
found_core: core => {
if (!this.paired_core_id) {
let settings = this.get_persisted_state();
settings.paired_core_id = core.core_id;
this.set_persisted_state(settings);
this.paired_core_id = core.core_id;
svc.send_continue_all("subscribe_pairing", "Changed", { paired_core_id: this.paired_core_id })
}
if (core.core_id == this.paired_core_id)
if (this.extension_opts.core_paired) this.extension_opts.core_paired(core);
},
lost_core: core => {
if (core.core_id == this.paired_core_id)
if (this.extension_opts.core_unpaired) this.extension_opts.core_unpaired(core);
},
};
o.provided_services.push(this.pairing_service_1);
}
o.provided_services.push({ services: [ this.register_service("com.roonlabs.ping:1", {
methods: {
ping: function(req) {
req.send_complete("Success");
},
}
})]})
o.required_services.forEach(svcobj => { svcobj.services.forEach(svc => { this.extension_reginfo.required_services.push(svc.name); }); });
o.optional_services.forEach(svcobj => { svcobj.services.forEach(svc => { this.extension_reginfo.optional_services.push(svc.name); }); });
o.provided_services.forEach(svcobj => { svcobj.services.forEach(svc => { this.extension_reginfo.provided_services.push(svc.name); }); });
this.services_opts = o;
};
// - pull in Sood and provide discovery methods in Node, but not in WebBrowser
//
// - implement save_config/load_config based on:
// Node: require('fs')
// WebBrowser: localStroage
//
if (typeof(window) == "undefined" || typeof(nw) !== "undefined") {
/**
* Begin the discovery process to find/connect to a Roon Core.
*/
RoonApi.prototype.start_discovery = function() {
if (this._sood) return;
this._sood = require('./sood.js');
this._sood_conns = {};
this._sood.on('message', msg => {
// console.log(msg);
if (msg.props.service_id == "00720724-5143-4a9b-abac-0e50cba674bb" && msg.props.unique_id) {
if (this._sood_conns[msg.props.unique_id]) return;
this._sood_conns[msg.props.unique_id] = true;
this.connect(msg.from.ip, msg.props.http_port, () => {
delete(this._sood_conns[msg.props.unique_id]);
});
}
});
this._sood.start(() => {
this._sood.query({ '_tid':uuid.v4(), query_service_id: "00720724-5143-4a9b-abac-0e50cba674bb" });
});
};
var fs = require('fs');
/**
* Save a key value pair in the configuration data store.
* @param {string} key
* @param {object} value
*/
RoonApi.prototype.save_config = function(k, v) {
try {
let config;
try {
let content = fs.readFileSync("config.json", { encoding: 'utf8' });
config = JSON.parse(content);
} catch (e) {
config = {};
}
if (v === undefined || v === null)
delete(config[k]);
else
config[k] = v;
fs.writeFileSync("config.json", JSON.stringify(config, null, ' '));
} catch (e) { }
};
/**
* Load a key value pair in the configuration data store.
* @param {string} key
* @return {object} value
*/
RoonApi.prototype.load_config = function(k) {
try {
let content = fs.readFileSync("config.json", { encoding: 'utf8' });
return JSON.parse(content)[k];
} catch (e) {
return undefined;
}
};
} else {
RoonApi.prototype.save_config = function(k, v) {
if (v === undefined || v === null)
localStorage.removeItem(k);
else
localStorage.setItem(k, JSON.stringify(v));
};
RoonApi.prototype.load_config = function(k) {
try {
let r = localStorage.getItem(k);
return r ? JSON.parse(r) : undefined;
} catch (e) {
return undefined;
}
};
}
RoonApi.prototype.register_service = function(svcname, spec) {
let ret = {
_subtypes: { }
};
if (spec.subscriptions) {
for (let x in spec.subscriptions) {
let s = spec.subscriptions[x];
let subname = s.subscribe_name;
ret._subtypes[subname] = { };
spec.methods[subname] = (req) => {
// XXX make sure req.body.subscription_key exists or respond send_complete with error
var newreq = {
send_continue: function() {
req.send_continue.apply(req, arguments);
},
send_complete: function() {
req.send_complete.apply(req, arguments);
delete(ret._subtypes[subname][req.body.subscription_key]);
}
};
s.start(newreq, req);
ret._subtypes[subname][req.body.subscription_key] = newreq;
};
spec.methods[s.unsubscribe_name] = (req) => {
// XXX make sure req.body.subscription_key exists or respond send_complete with error
delete(ret._subtypes[subname][req.body.subscription_key]);
if (s.end) s.end(req);
req.send_complete("Unsubscribed");
};
}
}
// process incoming requests from the other side
this._service_request_handlers[svcname] = req => {
// make sure the req's request name is something we know about
if (req) {
let method = spec.methods[req.msg.name];
if (method) {
method(req);
} else {
req.send_complete("InvalidRequest", { error: "unknown request name (" + svcname + ") : " + req.msg.name });
}
} else {
if (spec.subscriptions) {
for (let x in spec.subscriptions) {
let s = spec.subscriptions[x];
let subname = s.subscribe_name;
ret._subtypes[subname] = { };
if (s.end) s.end(req);
}
}
}
};
ret.name = svcname;
ret.send_continue_all = (subtype, name, props) => { for (let x in ret._subtypes[subtype]) ret._subtypes[subtype][x].send_continue(name, props); };
ret.send_complete_all = (subtype, name, props) => { for (let x in ret._subtypes[subtype]) ret._subtypes[subtype][x].send_complete(name, props); };
return ret;
};
RoonApi.prototype.connect = function() {
var host, cb;
var i = 0;
host = arguments[i++];
if (typeof(arguments[i]) != "function") host += ":" + arguments[i++];
cb = arguments[i++];
var ret = {
ws: new WebSocket('ws://' + host + '/api')
};
if (typeof(window) != "undefined") ret.ws.binaryType = 'arraybuffer';
ret.ws.onopen = () => {
// console.log("OPEN");
ret.moo = new Moo(ret.ws);
ret.moo.send_request("com.roonlabs.registry:1/info",
(msg, body) => {
if (!msg) return;
let s = this.get_persisted_state();
if (s.tokens && s.tokens[body.core_id]) this.extension_reginfo.token = s.tokens[body.core_id];
ret.moo.send_request("com.roonlabs.registry:1/register", this.extension_reginfo,
(msg, body) => {
if (!msg) { // lost connection
if (ret.moo.core) {
if (this.pairing_service_1) this.pairing_service_1.lost_core(ret.moo.core);
if (this.extension_opts.core_lost) this.extension_opts.core_lost(ret.moo.core);
ret.moo.core = undefined;
}
} else if (msg.name == "Registered") {
ret.moo.core = new Core(ret.moo, this, body);
let settings = this.get_persisted_state();
if (!settings.tokens) settings.tokens = {};
settings.tokens[body.core_id] = body.token;
this.set_persisted_state(settings);
if (this.pairing_service_1) this.pairing_service_1.found_core(ret.moo.core);
if (this.extension_opts.core_found) this.extension_opts.core_found(ret.moo.core);
}
});
});
};
ret.ws.onclose = () => {
// console.log("CLOSE");
Object.keys(this._service_request_handlers).forEach(e => this._service_request_handlers[e] && this._service_request_handlers[e](null));
if (ret.moo) ret.moo.close();
ret.moo = undefined;
ret.ws.close();
cb && cb();
};
ret.ws.onerror = err => {
// console.log("ERROR", err);
if (ret.moo) ret.moo.close();
ret.moo = undefined;
ret.ws.close();
};
ret.ws.onmessage = event => {
// console.log("GOTMSG");
if (!ret.moo) return;
var msg = ret.moo.parse(event.data);
if (!msg) return;
var body = msg.body;
delete(msg.body);
if (msg.verb == "REQUEST") {
console.log('<-', msg.verb, msg.request_id, msg.service + "/" + msg.name, body ? JSON.stringify(body) : "");
var req = new MooMessage(ret.moo, msg, body);
var handler = this._service_request_handlers[msg.service];
if (handler)
handler(req);
else
req.send_complete("InvalidRequest", { error: "unknown service: " + msg.service });
} else {
console.log('<-', msg.verb, msg.request_id, msg.name, body ? JSON.stringify(body) : "");
ret.moo.handle_response(msg, body);
}
};
return ret;
};
exports = module.exports = RoonApi;