Source: lib.js

"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 {string} desc.log_level - How much logging information to print.  "all" for all messages, "none" for no messages, anything else for all messages not tagged as "quiet" by the Roon core.
 * @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
 */

/**
 * @callback RoonApi~onclose
 */

var WSTransport = require('./transport-websocket.js'),
    Moo         = require('./moo.js'),
    MooMessage  = require('./moomsg.js'),
    Core        = require('./core.js');

function Logger(roonapi) {
    this.roonapi = roonapi;
};

Logger.prototype.log = function() {
    if (this.roonapi.log_level != "none") {
        console.log.apply(null, arguments);
    }
};

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.logger = new Logger(this);
    this.log_level = o.log_level;
    this.extension_opts = o;
    this.is_paired = false;
}

 /**
 * 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 (!(o.required_services instanceof Array)) o.required_services = []; 
    if (!(o.optional_services instanceof Array)) o.optional_services = [];
    if (!(o.provided_services instanceof Array)) 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) => {
                    if (this.paired_core_id != req.moo.core.core_id) {
		        if (this.paired_core) {
                            this.pairing_service_1.lost_core(this.paired_core);
                            delete this.paired_core_id;
                            delete this.paired_core;
                        }
                        this.pairing_service_1.found_core(req.moo.core);
                    }
		},
	    }
	});

	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;
                    this.paired_core = core;
                    this.is_paired = true;
		    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)
                    this.is_paired = false;
		    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.logger);
        this._sood_conns = {};
        this._sood.on('message', msg => {
//	    this.logger.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.ws_connect({ host: msg.from.ip, port: msg.props.http_port, onclose: () => { delete(this._sood_conns[msg.props.unique_id]); } });
            }
        });
        this._sood.on('network', () => {
            this._sood.query({ query_service_id: "00720724-5143-4a9b-abac-0e50cba674bb" });
        });
        this._sood.start(() => {
	    this._sood.query({ query_service_id: "00720724-5143-4a9b-abac-0e50cba674bb" });
            setInterval(() => this.periodic_scan(), (10 * 1000));
            this.scan_count = -1;
	});
    };

    RoonApi.prototype.periodic_scan = function() {
        this.scan_count += 1;
        if (this.is_paired) return;
        if ((this.scan_count < 6) || ((this.scan_count % 6) == 0)) {
            this._sood.query({ query_service_id: "00720724-5143-4a9b-abac-0e50cba674bb" });
        }
    };

    var fs = ((typeof _fs) === 'undefined') ? require('fs') : _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

                req.orig_send_complete = req.send_complete; 
                req.send_complete = function() {
                    this.orig_send_complete.apply(this, arguments);
                    delete(ret._subtypes[subname][req.moo.mooid][this.body.subscription_key]);
                };
		s.start(req);
                if (!ret._subtypes[subname].hasOwnProperty(req.moo.mooid)) {
                    ret._subtypes[subname][req.moo.mooid] = { };
                }
		ret._subtypes[subname][req.moo.mooid][req.body.subscription_key] = req;
	    };
	    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.moo.mooid][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, mooid) => {
	// 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;
                    delete(ret._subtypes[subname][mooid]);
                    if (s.end) s.end(req);
                }
            }
        }
    };

    ret.name = svcname;
    ret.send_continue_all = (subtype, name, props) => {
        for (let id in ret._subtypes[subtype]) {
            for (let x in ret._subtypes[subtype][id]) (ret._subtypes[subtype][id][x].send_continue(name, props));
        }
    };
    ret.send_complete_all = (subtype, name, props) => {
        for (let id in ret._subtypes[subtype]) {
            for (let x in ret._subtypes[subtype][id]) (ret._subtypes[subtype][id][x].send_complete(name, props));
        }
    };
    return ret;
};

 /**
 * If not using Roon discovery, call this to connect to the Core via a websocket.
 *
 * @this RoonApi
 * @param {object}          options
 * @param {string}          options.host - hostname or ip to connect to
 * @param {number}          options.port - port to connect to
 * @param {RoonApi~onclose} [options.onclose] - Called once when connect to host is lost
 */
RoonApi.prototype.ws_connect = function({ host, port, onclose }) {
    let moo = new Moo(new WSTransport(host, port, this.logger));

    moo.transport.onopen = () => {
        //        this.logger.log("OPEN");

        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];
			         
			         moo.send_request("com.roonlabs.registry:1/register", this.extension_reginfo,
					                    (msg, body) => {
						                ev_registered.call(this, moo, msg, body);
					                    });
			     });
    };

    moo.transport.onclose = () => {
//        this.logger.log("CLOSE");
        Object.keys(this._service_request_handlers).forEach(e => this._service_request_handlers[e] && this._service_request_handlers[e](null, moo.mooid));
        moo.clean_up();
        onclose && onclose();
        onclose = undefined;
    };

    /*
    moo.transport.onerror = err => {
//        this.logger.log("ERROR", err);
	if (moo) moo.close();
	moo = undefined;
        moo.transport.close();
    };*/

    moo.transport.onmessage = msg => {
//        this.logger.log("GOTMSG");
        var body = msg.body;
        delete(msg.body);
        var logging = msg && msg.headers && msg.headers["Logging"];
        msg.log = ((this.log_level == "all") || (logging != "quiet"));
        if (msg.verb == "REQUEST") {
            if (msg.log) this.logger.log('<-', msg.verb, msg.request_id, msg.service + "/" +  msg.name, body ? JSON.stringify(body) : "");
            var req = new MooMessage(moo, msg, body, this.logger);
            var handler = this._service_request_handlers[msg.service];
            if (handler)
                handler(req, req.moo.mooid);
            else
                req.send_complete("InvalidRequest", { error: "unknown service: " + msg.service });
        } else {
            if (msg.log) this.logger.log('<-', msg.verb, msg.request_id, msg.name, body ? JSON.stringify(body) : "");
            if (!moo.handle_response(msg, body)) {
                moo.transport.close(); // this will trigger the above onclose handler
            }
        }
    };

    return moo;
};

// DO NOT USE -- internal only
RoonApi.prototype.ws_connect_with_token = function({ host, port, token, onclose }) {
    var moo = this.ws_connect({ host, port, onclose })

    moo.transport.onopen = () => {
        let args = Object.assign({}, this.extension_reginfo);
        args.token = token;
        moo.send_request("com.roonlabs.registry:1/register_one_time_token", args,
                                   (msg, body) => {
				       ev_registered.call(this, moo, msg, body);
                                   });
    };

    return moo;
}

function ev_registered(moo, msg, body) {
    if (!msg) { // lost connection
	if (moo.core) {
	    if (this.pairing_service_1)        this.pairing_service_1.lost_core(moo.core);
	    if (this.extension_opts.core_lost) this.extension_opts.core_lost(moo.core);
	    moo.core = undefined;
	}
    } else if (msg.name == "Registered") {
	moo.core = new Core(moo, this, body, this.logger);

	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(moo.core);
	if (this.extension_opts.core_found) this.extension_opts.core_found(moo.core);
    }
}

exports = module.exports = RoonApi;