Source: ARCore/device.js

define(["lib/underscore", "common/util/check",        "common/network/dispatcher",
        "lib/jquery",     "common/util/binding_list", "common/util/log"],
function(_,                check,                      Dispatcher,
         $,                BindingList,                Log) {
    "use strict";

    var logger = Log("Device");

    // connect to the proxy that lets us talk to the device app
    var proxyUrl = "http://proxy.lens2.lens/index.html";
    var dispatcherToProxy = (function() {
        var iframe = $("<iframe />", {src: proxyUrl, style: "display: none"});
        iframe.appendTo("body");

        var dispatcherToProxy = Dispatcher(Dispatcher._PostMessageTransport(iframe[0]));

        return dispatcherToProxy;
    })();

    // check validator for ConnectionOptions
    var dataBitsValidator = check.Match.Enum("seven", "eight");
    var parityBitValidator = check.Match.Enum("no", "odd", "even");
    var stopBitsValidator = check.Match.Enum("one", "two");

    var connectionOptionsValidator = {
        persistent:     check.Match.Optional(Boolean),
        name:           check.Match.Optional(String),
        bufferSize:     check.Match.Optional(check.Match.Integer),
        bitrate:        check.Match.Optional(check.Match.Integer),
        dataBits:       check.Match.Optional(dataBitsValidator),
        parityBit:      check.Match.Optional(parityBitValidator),
        stopBits:       check.Match.Optional(stopBitsValidator),
        ctsFlowControl: check.Match.Optional(Boolean),
        receiveTimeout: check.Match.Optional(check.Match.Integer),
        sendTimeout:    check.Match.Optional(check.Match.Integer)
    };

    // this is a proxy for the "chrome" object that exposes the chrome.serial
    // and chrome.usb APIs
    var chromeProxy = {
        serial: {
            getDevices: function(callback) {
                check(callback, Function);

                dispatcherToProxy.callRPC("getDevices", {}, callback);
            },

            connect: function(path, options, callback) {
                if(_.isUndefined(callback) && !_.isUndefined(options)) {
                    options = undefined;
                    callback = options;
                }

                check(path, String);
                check(options, check.Match.Optional(connectionOptionsValidator));
                check(callback, Function);

                dispatcherToProxy.callRPC("connect", [path, options], callback);
            },

            update: function(path, options, callback) {
                check(path, String);
                check(options, connectionOptionsValidator);
                check(callback, check.Match.Optional(Function));

                dispatcherToProxy.callRPC("update", [path, options], callback);
            },

            disconnect: function(connectionId, callback) {
                check(connectionId, check.Match.Integer);
                check(callback, check.Match.Optional(Function));

                dispatcherToProxy.callRPC("disconnect", connectionId, callback);
            },

            setPaused: function(connectionId, paused, callback) {
                check(connectionId, check.Match.Integer);
                check(paused, Boolean);
                check(callback, check.Match.Optional(Function));

                dispatcherToProxy.callRPC("setPaused", [connectionId, paused], callback);
            },

            getInfo: function(connectionId, callback) {
                check(connectionId, check.Match.Integer);
                check(callback, Function);

                dispatcherToProxy.callRPC("getInfo", connectionId, callback);
            },

            getConnections: function(callback) {
                check(callback, Function);
                dispatcherToProxy.callRPC("getConnections", {}, callback);
            },

            send: function(connectionId, data, callback) {
                check(connectionId, check.Match.Integer);
                check(data, ArrayBuffer);
                check(callback, check.Match.Optional(Function));

                var dataArray = _.toArray(new Uint8Array(data));
                dispatcherToProxy.callRPC("send", [connectionId, dataArray], callback);
            },

            flush: function(connectionId, callback) {
                check(connectionId, check.Match.Integer);
                check(callback, check.Match.Optional(Function));

                dispatcherToProxy.callRPC("flush", connectionId, callback);
            },

            getControlSignals: function(connectionId, callback) {
                check(connectionId, check.Match.Integer);
                check(callback, check.Match.Optional(Function));

                dispatcherToProxy.callRPC("getControlSignals", connectionId, callback);
            },

            setControlSignals: function(connectionId, signals, callback) {
                check(connectionId, check.Match.Integer);
                check(signals, {
                    dtr: check.Match.Optional(Boolean),
                    rts: check.Match.Optional(Boolean)
                });

                dispatcherToProxy.callRPC("setControlSignals", [connectionId, signals], callback);
            },

            getUSBSerialInfo: function(callback) {
                check(callback, Function);
                $.getJSON("http://lens2.lens/cgi-bin/usb_serial.rb", callback);
            },

            findUSBSerialDevice: function(attributes, callback) {
                check(attributes, Object);
                check(callback, Function);

                chromeProxy.serial.getUSBSerialInfo(function(data) {
                    callback(_.find(_.keys(data), function(path) {
                        var devAttrs = data[path];
                        return _.all(attributes, function(value, key) {
                            return devAttrs[key] === value;
                        });
                    }));
                });
            },

            onReceive: (function() {
                var handlers = BindingList();

                dispatcherToProxy.subscribe("onReceive", function(data) {
                    // deserialize from an array to a real ArrayBuffer
                    var readInfo = {
                        connectionId: data.connectionId,
                        data: (new Uint8Array(data.data)).buffer
                    };

                    handlers.callAll(readInfo);
                });

                return {
                    addListener: function(callback) {
                        return handlers.add(callback);
                    }
                };
            })(),

            onReceiveError: (function() {
                var handlers = BindingList();

                dispatcherToProxy.subscribe("onReceiveError", function(data) {
                    handlers.callAll(data);
                });

                return {
                    addListener: function(callback) {
                        return handlers.add(callback);
                    }
                };
            })()
        }
    };


    // driver name -> driver factory
    var drivers = {};

    // instantiates a driver and checks that it has open and close methods.
    // returns the driver if these checks pass, throws an error otherwise.
    var checkDriver = function(factory) {
        if(!_.isFunction(factory)) {
            throw "Invalid driver factory: Factory is not a function.";
        }

        var driver = factory();
        if(!driver) {
            throw "Invalid driver factory: Factory did not return an object.";
        }

        if(!(_.isFunction(driver.open) && _.isFunction(driver.close))) {
            throw "Invalid driver factory: Factory did not return an object with open and close methods.";
        }

        return driver;
    };

    // states a driver can be in.
    var STATE_CLOSED = 0;
    var STATE_OPENING = 1;
    var STATE_OPEN = 2;
    var STATE_CLOSING = 3;

    var stateNames = {};
    stateNames[STATE_CLOSED]  = "closed";
    stateNames[STATE_OPENING] = "opening";
    stateNames[STATE_OPEN]    = "open";
    stateNames[STATE_CLOSING] = "closing";

    /**
     * Creates a new device connection. Does not open the connection.
     * @param {String} driver Name of the driver to use.
     *
     * @class Device
     * Represents a connection to a serial device.
     *
     * @memberOf Lens
     */
    var Device = function(driverName) {
        check(driverName, String);

        // create the driver
        var driverFactory = drivers[driverName];
        if(!driverFactory) {
            throw "No such driver: " + driverName;
        }

        var driver = checkDriver(driverFactory);

        var state = STATE_CLOSED;

        /** @alias Lens.Device.prototype */
        var self = {};

        /**
         * Opens a connection to the device.
         *
         * @param  {Array}    args     Arguments to pass to the driver
         * @param  {Function} callback Callback invoked when the connection is
         *                             open.
         */
        self.open = function(args, clientCallback) {
            // allow clients to pass just a callback
            if(!clientCallback && _.isFunction(args)) {
                clientCallback = args;
                args = [];
            }

            // sanity checks
            check(args, check.Match.Optional(Array));
            check(clientCallback, check.Match.Optional(Function));

            if(state !== STATE_CLOSED) {
                throw "Device is currently " + stateNames[state] +
                      ". open can only be called on closed devices.";
            }

            // call the driver's open method
            state = STATE_OPENING;

            var callback = function(success) {
                if(success) {
                    state = STATE_OPEN;
                    if(clientCallback) {
                        clientCallback();
                    }
                }
                else {
                    state = STATE_CLOSED;
                    logger.error("Call to open() failed");
                }
            };

            driver.open(callback, args, chromeProxy);
        };

        /**
         * Closes the connection to the device.
         *
         * @param  {Array}    args     Arguments to pass to the driver
         * @param  {Function} callback Callback invoked when the connection is
         *                             closed.
         */
        self.close = function(args, clientCallback) {
            // allow clients to pass just a callback
            if(!clientCallback && _.isFunction(args)) {
                clientCallback = args;
                args = [];
            }

            // sanity checks
            check(args, check.Match.Optional(Array));
            check(clientCallback, check.Match.Optional(Function));

            if(state !== STATE_OPEN) {
                throw "Device is currently " + stateNames[state] +
                      ". close can only be called on open devices.";
            }

            // call the driver's open method
            state = STATE_CLOSING;

            var callback = function(success) {
                if(success) {
                    state = STATE_CLOSED;
                    if(clientCallback) {
                        clientCallback();
                    }
                }
                else {
                    state = STATE_OPEN;
                    logger.error("Call to close() failed");
                }
            };

            driver.close(callback, args, chromeProxy);
        };

        /**
         * Return true if this device supports the given method.
         *
         * @param  {String} method Name of the method
         * @return {Boolean}
         */
        self.supports = function(method) {
            if((method === "open")     || (method === "close") ||
               (method === "getState") || (method === "setState") ||
               (method === "write")    || (method === "read") ||
               (method === "onReceive")) {

                return !!driver[method];
            }

            return false;
        };

        /**
         * Returns the state of the driver.
         *
         * @return {String} one of "open", "opening", "closed", or "closing".
         */
        self.getDriverState = function() {
            return stateNames[state];
        };


        var proxyMethodToDriver = function(method) {
            return function(args, clientCallback) {
                // allow clients to pass just a callback
                if(!clientCallback && _.isFunction(args)) {
                    clientCallback = args;
                    args = [];
                }

                // sanity checks
                check(args, check.Match.Optional(Array));
                check(clientCallback, check.Match.Optional(Function));

                if(!clientCallback) {
                    clientCallback = function(){};
                }

                if(state !== STATE_OPEN) {
                    throw "Device is currently " + stateNames[state] + ". " +
                          method + " can only be called on open devices.";
                }

                if(!self.supports(method)) {
                    throw "Device does not support method " + method;
                }

                // call the driver method
                driver[method](clientCallback, args, chromeProxy);
            };
        };

        /**
         * Gets the state of this device, as determined by the driver.
         *
         * @memberOf Lens.Device.prototype
         * @method getState
         * @param  {Array}    [args]     Array of arguments.
         * @param  {Function} [callback] Function that will receive the state as
         *                               an argument.
         */
        self.getState = proxyMethodToDriver("getState");

        /**
         * Sets the state of this device, as determined by the driver.
         *
         * @memberOf Lens.Device.prototype
         * @method setState
         * @param  {Array}    [args]     Array of arguments.
         * @param  {Function} [callback] Function that will be called when the
         *                               operation is complete.
         */
        self.setState = proxyMethodToDriver("setState");

        /**
         * Writes to this device. The format of the data is determined by the
         * driver.
         *
         * @memberOf Lens.Device.prototype
         * @method write
         * @param  {Array}    [args]     Array of arguments.
         * @param  {Function} [callback] Function that will be called when the
         *                               operation is complete.
         */
        self.write = proxyMethodToDriver("write");

        /**
         * Reads from this device. The format of the data is determined by the
         * driver.
         *
         * @memberOf Lens.Device.prototype
         * @method read
         * @param  {Array}    [args]     Array of arguments.
         * @param  {Function} [callback] Function that will be called with the
         *                               result of the read operation.
         */
        self.read = proxyMethodToDriver("read");

        /**
         * Registers a callback to be called when new data is received from a
         * device.
         *
         * @memberOf Lens.Device.prototype
         * @method onReceive
         * @param  {Array}    [args]     Array of arguments.
         * @param  {Function} [callback] Function that will be called when data
         *                               arrives.
         */
        self.onReceive = proxyMethodToDriver("onReceive");

        // close on unload
        $(window).unload(function() {
            if(state === STATE_OPEN) {
                self.close();
            }
        });

        Object.freeze(self);
        return self;
    };

    _.extend(Device, /** @lends Lens.Device */ {
        /**
         * Registers a new driver that can be used to communicate with a
         * serial device. Drivers are created by specifying a factory that
         * returns an instance of the driver.
         *
         * An instance of the driver *must* have the properties `open` and
         * `close`. If *may* have one or more of the properties `getState`,
         * `setState`, `write`, `read`, and `onReceive`, depending on the most
         * logical way for clients to interact with the device.
         *
         * Each of these driver methods receive three arguments: first, a
         * callback that should be called with the result of the operation;
         * second, an array of arguments passed to the driver by the
         * client; and third, a proxy for the `chrome` object that gives
         * access to the `chrome.serial` API.
         *
         * The factory should be lightweight. Any heavy-lifting should be done
         * in the `open` method; drivers are sometimes instantiated and never
         * used
         *
         * See {@tutorial Devices} for more information how to create device
         * drivers.
         *
         * @param {String}   name    Name of this driver
         * @param {Function} factory Factory that returns an instance of this
         *                           driver.
         */
        registerDriver: function(name, factory) {
            check(name, String);

            // create a test driver to make sure the factory works OK
            checkDriver(factory);

            drivers[name] = factory;
        },

        /**
         * A helper for registering drivers that connect to a USB-serial device
         * with an identifiable property. open and close methods are
         * automatically written for you, and your factory will receive the
         * connection id from the chrome.serial API as well as the chrome proxy
         * object. The factory should then return an object containing one or
         * more of the properties `getState`, `setState`, `write`, `read`, and
         * `onReceive`, depending on the most logical way for clients to
         * interact with the device.
         *
         * It may also have a `close` property. This close function will be
         * called before the built-in close handler that actually closes the
         * connection to the device. This function must still call the callback
         * with either true or false. False indicates that the close operation
         * should be aborted.
         *
         * @param {String} name         Name of the driver
         * @param {Object} opts         Configuration options
         * @param {Object} opts.id      Identifying properties of the USB-serial
         *                              device.
         * @param {Number} opts.bitrate Bitrate of the device
         */
        registerUSBSerialDriver: function(name, opts, factory) {
            check(name, String);
            check(opts, {
                id: Object,
                bitrate: check.Match.Optional(check.Match.Integer)
            });

            var connectionOptions = {};
            if(opts.bitrate) {
                connectionOptions.bitrate = opts.bitrate;
            }

            Device.registerDriver(name, function() {
                var connectionId;
                var closeHandler;

                var driver = {
                    open: function(callback, args, chrome) {
                        // find device by serial number
                        chrome.serial.findUSBSerialDevice(opts.id, function(path) {
                            // if we can't find it, it means it's not plugged in.
                            if(!path) {
                                logger.error("Error when opening device " + name + ": device not attached");
                                connectionId = undefined;
                                callback(false);
                                return;
                            }

                            // if we can find it, try to connect to it
                            chrome.serial.connect(path, connectionOptions, function(openInfo) {
                                // if we didn't get a connection id, there was an
                                // error opening the device.
                                if(!openInfo) {
                                    logger.error("Error when opening device " + name + ": could not open device");
                                    connectionId = undefined;
                                    callback(false);
                                }

                                // everything worked; store the connection id.
                                connectionId = openInfo.connectionId;
                                logger.info("Connected to device " + name + " with connection Id " + connectionId);

                                // copy the methods from the client's driver
                                // to this driver
                                var clientDriver = factory(connectionId, chrome);

                                driver.getState  = clientDriver.getState;
                                driver.setState  = clientDriver.setState;
                                driver.write     = clientDriver.write;
                                driver.read      = clientDriver.read;
                                driver.onReceive = clientDriver.onReceive;
                                closeHandler     = clientDriver.close;

                                callback(true);
                            });
                        });
                    },

                    close: function(callback, args, chrome) {
                        if(_.isFunction(closeHandler)) {
                            closeHandler(function(success) {
                                if(success) {
                                    chrome.serial.disconnect(connectionId, function() {
                                        callback(true);
                                    });
                                }
                                else {
                                    callback(false);
                                }
                            }, args, chrome);
                        }
                        else {
                            chrome.serial.disconnect(connectionId, function() {
                                callback(true);
                            });
                        }
                    }
                };

                return driver;
            });
        }
    });

    Object.freeze(Device);

    Lens._addMember(Device, "Device");

    return Device;
});