Source: org/terraswarm/accessor/accessors/web/devices/Hue.js

// Copyright (c) 2015-2017 The Regents of the University of California.
// All rights reserved.
//
// Permission is hereby granted, without written agreement and without
// license or royalty fees, to use, copy, modify, and distribute this
// software and its documentation for any purpose, provided that the above
// copyright notice and the following two paragraphs appear in all copies
// of this software.
//
// IN NO EVENT SHALL THE UNIVERSITY OF CALIFORNIA BE LIABLE TO ANY PARTY
// FOR DIRECT, INDIRECT, SPECIAL, INCIDENTAL, OR CONSEQUENTIAL DAMAGES
// ARISING OUT OF THE USE OF THIS SOFTWARE AND ITS DOCUMENTATION, EVEN IF
// THE UNIVERSITY OF CALIFORNIA HAS BEEN ADVISED OF THE POSSIBILITY OF
// SUCH DAMAGE.
//
// THE UNIVERSITY OF CALIFORNIA SPECIFICALLY DISCLAIMS ANY WARRANTIES,
// INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF
// MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE SOFTWARE
// PROVIDED HEREUNDER IS ON AN "AS IS" BASIS, AND THE UNIVERSITY OF
// CALIFORNIA HAS NO OBLIGATION TO PROVIDE MAINTENANCE, SUPPORT, UPDATES,
// ENHANCEMENTS, OR MODIFICATIONS.
//

//  FIXME: Allow an IP address to be dynamically provided.

/** This accessor controls a Philips Hue lightbulb via a Hue Bridge.
 *  To use it, you need to know the IP address of the Hue Bridge, which is
 *  unfortunately, somewhat hard to find out.  See below for some hints.
 *
 *  Upon initialization, this accessor will use the userName parameter to
 *  contact the Hue Bridge. If the userName is invalid, then the accessor will
 *  engage in a dialog with the Bridge to create a new user. This will require
 *  the user to push the button on the Hue Bridge when the alert to do so appears.
 *  The assigned userName will be recorded in the userName parameter.
 *
 *  Upon authenticating with the Bridge, this accessor will output a data
 *  structure that reports all the lights that have been registered with the Bridge.
 *  These lights each have a number ID, such as '1'.  The state of each light
 *  will be reported in this output. The most important property of the state
 *  is the 'reachable' property. If this has value false, then the light is not
 *  reachable by the Bridge and therefore cannot be controlled.
 *
 *  The *commands* input is either a single command or an array of commands,
 *  where each command can have the following properties:
 *
 *  * id (required):  The id of the light to manipulate, which is a number.
 *  * on: true to turn on; false to turn off.
 *  * bri: Brightness.  0-255.
 *  * hue: Color, for bulbs that support color. This is a number in the
 *    range 0-65280.
 *  * xy: Two numbers between 0.0 and 1.0 in an array, e.g. [0.4, 0.4],
 *    specifying a color according to the image at
 *    https://www.developers.meethue.com/documentation/core-concepts
 *  * sat: Saturation, for bulbs that support color. This is a number in the
 *    range 0-255.
 *  * ct: Color temperature. This takes values in a scale called "reciprocal
 *    megakelvin" or "mirek". Using this scale, the warmest color 2000K
 *    is 500 mirek ("ct":500) and the coldest color 6500K is 153 mirek ("ct":153).
 *  * transitiontime: The time in ms for the bulb to make the transition.
 *
 *
 *  Please see Hue docs for mapping colors to hue/saturation values:
 *  http://www.developers.meethue.com/documentation/core-concepts.
 *  
 *  Some common colors given as xy are (for a gammut B bulb):
 *  * orange:     [0.60, 0.38]
 *  * red:        [0.67, 0.32]
 *  * yellow:     [0.54, 0.42]
 *  * green:      [0.41, 0.52]
 *  * violet:     [0.17, 0.04]
 *  * blue:       [0.17, 0.05]
 *  * magenta:    [0.41, 0.18]
 *  * cool white: [0.28, 0.28]  (about 10,000 Kelvin)
 *  * warm white: [0.46, 0.41]  (about 2,700 Kelvin)
 *
 *
 *  If a light is not accessible, this accessor warns but does not error.
 *  In CapeCode, this results in a dialog box with a message.
 *  Sometimes Hue lights are transient (get unplugged, become temporarily
 *  disconnected) and may be valid in the future. Rather than terminating the
 *  model, we hope that the lights come back. A good practice is to use the
 *  lights output to determine which lights are reachable.
 *
 *  Discovery: Finding the IP address of the Hue Bridge is not necessarily easy.
 *  The bridge acquires its address via DHCP, so the address will typically change
 *  each time the bridge is rebooted. Moreover, the address will likely not be
 *  accessible except on the local network.  The bridge responds to UPnP packets
 *  (universal plug-and-play), so it is possible to use software such as
 *  <a href="http://4thline.org/projects/cling/">Cling</a> to discover the bridge.
 *  Another option is to use the Discovery accessor and look for a device named
 *  philips-hue (or the name assigned to your bridge if assigned manually).
 *
 *  @accessor devices/Hue
 *  @input {JSON} commands JSON commands for the Hue, for example,
 *   {"id" : 1, "on" : true, "hue" : 120}
 *  @input probe Trigger production of a 'lights' output that gives the status of
 *   lights registered with this bridge.
 *  @output lights An object with one property for each light that is registered
 *   with the bridge. The name of the property is the light ID, an integer given as
 *   a string, and the value is an object with information about the light
 *   (manufacturer, modelid, name, state, etc.). The state property has a boolean
 *   'on' indicating whether the light is on and 'reachable' indicating whether the
 *   light is in communication with the bridge.
 *  @output assignedUserName {string} If a user name is automatically generated and
 *   registered with the bridge, then it will be sent on this output port.
 *  @output response The response from the bridge to a command.
 *  @parameter {string} bridgeIP The bridge IP address (and port, if needed).
 *  @parameter {string} userName The username for logging on to the Hue Bridge.
 *   This must be at least 11 characters, or the Hue regards it as invalid.
 *   A username will be automatically generated if none is available.
 *   The assigned user name will be sent on the assignedUserName output.
 *  @author Edward A. Lee, Marcus Pan, Elizabeth Osyk, Marten Lohstroh
 *  @version $$Id$$
 */

// Stop extra messages from jslint.  Note that there should be no
// space between the / and the * and global.
/*globals alert, clearTimeout, console, error, exports, httpRequest, require, setTimeout  */
/*jshint globalstrict: true*/
"use strict";

var http = require('@accessors-modules/http-client');

// Node Host needs util defined.
var util = require('util');

/** Define inputs and outputs. */
exports.setup = function () {

    this.input('commands');
    this.input('probe');
    
    this.parameter('bridgeIP', {
        type: "string",
        value: ""
    });
    this.parameter('userName', {
        type: "string",
        value: "ptolemyuser"
    });
    this.parameter('onWrapup', {
        value: "turn off",
        type: "string",
        options: ["none", "restore", "turn off"]
    });
    this.output('lights', {
        spontaneous: true
    });
    this.output('assignedUserName', {
        type: "string",
        spontaneous: true
    });
    this.output('response', {
        spontaneous: true
    });
};

/** Define a Hue function using a variant of the Module pattern.  The function
 *  returns a hue object which offers a public connect() function.
 *  This will create an object with its own local state, allowing multiple
 *  Hue accessors to run concurrently without interfering with each other on
 *  hosts with a shared Javascript engine (such as the browser host).
 *
 *  An instance of the returned hue object implements the following public functions:
 *
 *  * connect(): Contact the bridge and register the user, if needed.  Add an
 *    input handler to the trigger input to submit commands to the bridge.
 *  * contactBridge(): Query the bridge for the status of lights registered with
 *    it. The status will be sent to the 'lights' output.
 *  * issueCommand():  Issue a command to the bridge.  A command is an object
 *    that may contain the following fields:
 *
 *    * id (required):  The id of the light to manipulate.
 *    * on: true to turn on; false to turn off.
 *    * bri: Brightness.  0-255.
 *    * hue: Hue (for bulbs that support color).  0-65280.
 *    * sat: Saturation (for bulbs that support color). 0-255.
 *    * transitiontime:  The delay before the bulb responds to the command.  In ms.
 *
 *  For example, {"id" : 1, "on" : true, "hue" : 120}
 *
 */
function Hue() {
    var hue = {};

    // Public variables.
    hue.changedLights = [];
    hue.lights = {};

    // Private variables.
    var authenticated = false;
    var debug = true;
    var handleRegisterUser;
    var ipAddress = "";
    var maxRegisterAttempts = 10;
    var maxRetries = 5;
    var registerInterval = 5000;
    var registerAttempts = 0;
    var retryCount = 0;
    var retryTimeout = 1000;
    var timeout = 3000;
    var url = "";
    var userName = "";
    var pendingCommands = [];
    var alerted = false;
    var errorOccurred = false;

    // Use self in contained functions so the caller does not have to bind "this"
    // on each function call.
    var self = this;

    // Public functions.
    // Available to be used for e.g. inputHandlers.

    /** Utility function to check that an object is a nonempty array.
     *  @param obj The object.
     */
    function isNonEmptyArray(obj) {
        return (obj instanceof Array && obj.length > 0);
    }

    var bridgeRequestErrorHandler;
    var registerUser;

    /** Contact the bridge and send to the 'lights' output the status of all
     *  lights registered with the bridge.  Register the user, if needed.
     */
    hue.contactBridge = function() {
        console.log("Attempting to connect to: " + url + "/" + userName + "/lights/");
        var bridgeRequest = http.get(url + "/" + userName + "/lights/", function (response) {
            if (response !== null) {
                console.log("Got a response from the bridge: " + response.body);
                if (errorOccurred) {
                    // Fatal error has occurred. Ignore response.
                    self.error('Error occurred before response arrive. Response ignored');
                    return;
                }
                if (response.statusCode !== 200) {
                    // Response is other than OK. Retry if not a fatal error.
                    bridgeRequestErrorHandler(response.statusMessage);
                } else {
                    var lights = JSON.parse(response.body);

                    if (isNonEmptyArray(lights) && lights[0].error) {
                        var description = lights[0].error.description;

                        if (description.match("unauthorized user")) {
                            // Add this user.
                            // Prevent the alert from coming up more than once.
                            alerted = true;
                            alert(userName + " is not a registered user.\n" +
                                "Push the link button on the Hue bridge to register.");
                            // Oddly, the invalid userName, which has the right form,
                            // is not an acceptable parameter value. Since it is invalid
                            // anyway, discard it and replace.
                            userName = 'ptolemyuser';
                            // It takes two successive posts to register a new user.
                            // Issue the first one now, then attempt again later.
                            registerUser();
                            console.log("Will register user in " + registerInterval + " ms");
                            handleRegisterUser = setTimeout(registerUser, registerInterval);
                        } else {
                            console.error('Error occurred when trying to get Hue light status:' + description);
                            self.error(description);
                            errorOccurred = true;
                        }
                    } else if (lights) {
                        console.log("Authenticated!");
                        authenticated = true;

                        // Process any previously queued requests.
                        if (pendingCommands) {
                            for (var i = 0; i < pendingCommands.length; i++) {
                                hue.processCommands(pendingCommands[i]);
                            }
                            pendingCommands = [];
                        }
                        hue.lights = lights;
                        self.send('lights', lights);
                    }
                }
            } else {
                self.error("Unable to connect to bridge.");
                errorOccurred = true;
            }
        });
    }

    /** Contact the bridge and register the user, if needed. */
    hue.connect = function () {
        ipAddress = self.getParameter('bridgeIP');
        userName = self.getParameter('userName');

        if (userName.length < 11) {
            throw "Username too short. Hue only accepts usernames that contain at least 11 characters.";
        }

        if (ipAddress === null || ipAddress.trim() === "") {
            throw "No IP Address is given for the Hue Bridge.";
        }

        url = "http://" + ipAddress + "/api";

        hue.contactBridge();
    };

    /** Issue a command to the bridge.  Commands are queued if not yet authenticated. */
    hue.issueCommand = function () {
        if (errorOccurred) {
            return;
        }
        var commands = self.get('commands');
        if (debug) {
            console.log("Hue.js: issueCommand(): " + util.inspect(commands));
        }

        // (Re)connect with the bridge
        if (ipAddress !== self.getParameter('bridgeIP') ||
            userName !== self.getParameter('userName')) {
            console.log("New bridge parameters detected. Need to re-authenticate.");
            authenticated = false;
            hue.connect();
        }

        // If not yet connected, queue the command.
        if (!authenticated) {
            console.log("Not authenticated; queueing command.");
            pendingCommands.push(commands);
            return;
        }
        hue.processCommands(commands);
    };

    /** Utility function to limit the range of a number
     *  and to force it to be an integer. If the value argument
     *  is a string, then it will be converted to a Number.
     *  @param value The value to limit.
     *  @param low The low value.
     *  @param high The high value.
     */
    function limit(value, low, high) {
        var parsed = parseInt(value, 10);
        if (typeof parsed === 'undefined') {
            parsed = parseFloat(value);
            if (typeof parsed === 'undefined') {
                self.error("Expected a number between " + low + " and " + high + ", but got " + value);
                return 0;
            } else {
                parsed = Math.floor(parsed);
            }
        }
        if (parsed < low) {
            return low;
        } else if (parsed > high) {
            return high;
        } else {
            return parsed;
        }
    }

    /** If the response indicates an error, report it.
     *  Return true if the response is an error.
     */
    hue.reportIfError = function (response) {
        var body = response.body;
        if (typeof body == "string") {
            body = JSON.parse(body);
        }
        if (isNonEmptyArray(body) && body[0].error) {
            self.error("Server responds with error: " +
                body[0].error.description);
            return true;
        }
        return false;
    }

    /** Process the specified commands. The argument can be a single object
     *  with properties for the command, or an array of such objects.
     */
    hue.processCommands = function (commands) {
        if (typeof commands === 'string') {
            commands = JSON.parse(commands);
        }
        if (debug) {
            console.log("Hue.js: processCommands() commands: " + util.inspect(commands));
        }
        // Accept both arrays and non-arrays.
        // The following concatenates the input with an empty array, ensuring the result
        // is an array.
        commands = [].concat(commands);

        // Iterate over commands (assuming input is an array of commands)
        for (var i = 0; i < commands.length; i++) {
            var command = {};
            if (typeof commands[i] === 'string') {
                commands[i] = JSON.parse(commands);
            }
            var lightID = commands[i].id;

            // Check whether input is valid
            if (typeof lightID === 'undefined') {
                self.error("Invalid command (no light id): " + commands[i]);
            } else {

                // Keep track of changed lights to turn off during wrap up.
                if (hue.changedLights.indexOf(lightID) == -1) {
                    hue.changedLights.push(lightID);
                }

                // Pack properties into object
                if (typeof commands[i].on !== 'undefined') {
                    command.on = commands[i].on;
                }
                if (typeof commands[i].bri !== 'undefined') {
                    command.bri = limit(commands[i].bri, 0, 255);
                }
                if (typeof commands[i].hue !== 'undefined') {
                    command.hue = limit(commands[i].hue, 0, 65280);
                }
                if (typeof commands[i].sat !== 'undefined') {
                    command.sat = limit(commands[i].sat, 0, 255);
                }
                if (typeof commands[i].transitiontime !== 'undefined') {
                    command.transitiontime = commands[i].transitiontime;
                }
                if (typeof commands[i].xy !== 'undefined') {
                    command.xy = commands[i].xy;
                }
                if (typeof commands[i].ct !== 'undefined') {
                    command.ct = commands[i].ct;
                }
            }

            if (Object.keys(command).length < 1) {
                self.error("Invalid command (no properties): " + JSON.stringify(commands[i]));
            } else {
                if (debug) {
                    console.log("Hue.js: processCommands() command: " + JSON.stringify(command));
                }
                var options = {
                    body: JSON.stringify(command),
                    timeout: 10000,
                    url: url + "/" + userName + "/lights/" + encodeURIComponent(lightID) + "/state/"
                };
                if (debug) {
                    console.log("Hue.js: processCommands(): PUT request: options: " + JSON.stringify(options));
                }
                http.put(options, function (response) {
                    if (debug) {
                        console.log("Hue.js: processCommands(): response status: " + response.statusMessage);
                        console.log("Hue.js: processCommands(): response body: " + response.body);
                    }
                    self.send('response', response);
                    hue.reportIfError(response);
                });
            }
        }
    };

    // Private functions.

    /** Handle an error. This will report it on the console and then retry a
     *  fixed number of times before giving up.  A retry is a re-invocation of
     *  registerUser().
     */
    bridgeRequestErrorHandler = function (err) {
        // FIXME: We should do a UPnP discovery here and find a bridge.
        // Could not connect to the bridge
        console.error('Error connecting to Hue Bridge:');
        console.error(err);
        if (retryCount < maxRetries) {
            console.log('Will retry');
            retryCount++;
            setTimeout(hue.contactBridge, retryTimeout);
        } else {
            self.error('Could not reach the Hue Bridge at ' + url +
                ' after ' + retryCount + ' attempts.');
            errorOccurred = true;
        }
    };

    /** Register a new user.
     *  This function repeats at registerInterval until successful or until
     *  maxRegisterAttempts.  Some wait time is given between attempts for the
     *  user to click the button on the Hue bridge.
     */
    registerUser = function () {

        // Should be of the format {"devicetype":"my_hue_app#iphone peter"}
        // http://www.developers.meethue.com/documentation/getting-started
        // (free registration required).
        var registerData = {
            devicetype: "hue_accessor#" + userName
        };
        var options = {
            body: JSON.stringify(registerData),
            timeout: 10000,
            url: url
        };
        http.post(options, function (response) {
            var rsp = JSON.parse(response.body);
            if (debug) {
                console.log("Hue.js registerUser(): Response " + JSON.stringify(rsp));
            }
            if (isNonEmptyArray(rsp) && rsp[0].error) {

                var description = rsp[0].error.description;

                if (description.match("link button not pressed") ||
                    description.match("invalid value")) {
                    // Retry registration for the given number of attempts.
                    console.log("Please push the link button on the Hue bridge.");
                    registerAttempts++;

                    if (registerAttempts < maxRegisterAttempts) {
                        handleRegisterUser = setTimeout(registerUser, registerInterval);
                    } else {
                        errorOccurred = true;
                        throw "Failed to create user after " + registerAttempts +
                            " attempt(s).";
                    }
                    return;
                } else {
                    errorOccurred = true;
                    throw description;
                }
            } else if ((isNonEmptyArray(rsp) && rsp[0].success)) {
                authenticated = true;

                // The bridge will return a username.  Save it.
                userName = rsp[0].success.username;
                self.setParameter('userName', userName);
                self.send('assignedUserName', userName);
                if (handleRegisterUser !== null) {
                    clearTimeout(handleRegisterUser);
                }
                // contact the bridge and find the available lights
                hue.contactBridge();
            } else {
                throw "Unknown error registering new user";
            }
        });
    };

    return hue;
}

/** Add an input handler to react to commands.
 *  Commands will be ignored until the user is authenticated.
 *  If a bridge IP address has been given, contact the bridge to check if it is
 *  present.  Next, register the user if not already registered.
 */
exports.initialize = function () {
    // Call the Hue function binding "this", to create local state variables
    // while providing access to accessor functions.
    // Setting "this.hue" makes hue available in other accessor functions, e.g.
    // initialize().
    // TODO:  Test with two accessors to make sure each has separate state.
    this.hue = Hue.call(this);

    // FIXME:  We need a way to dynamically supply the IP address.
    // Recommend using a separate port.
    this.addInputHandler('commands', this.hue.issueCommand);
    this.addInputHandler('probe', this.hue.contactBridge);
    this.hue.connect();
};

/** Turn off changed lights on wrapup. */
exports.wrapup = function () {
    var action = this.getParameter('onWrapup'),
        cmd = JSON.stringify({
            on: false
        }),
        debug = false,
        errorLights = [],
        options = {};

    if (action !== "none") {
        // wrapup() gets called by the code generator after setting
        // the types, so there is a chance that changedLights has not been set.
        if (typeof this.hue !== 'undefined' && typeof this.hue.changedLights !== 'undefined') {

            for (var i = 0; i < this.hue.changedLights.length; i++) {
                options = {
                    body: cmd,
                    timeout: 10000,
                    url: "http://" + this.get("bridgeIP") + "/api/" +
                        this.getParameter("userName") + "/lights/" + this.hue.changedLights[i] +
                        "/state/"
                };

                var self = this;

                http.put(options, function (response) {
                    if (debug) {
                        console.log("Hue.js wrapup(): Response " + JSON.stringify(response));
                    }
                    if (self.hue.reportIfError(response)) {
                        errorLights.push(this.lightID);
                    }
                });
            }
        }
        if (errorLights.length !== 0) {
            error("Error turning off lights " + errorLights.toString());
        }
    }
};