Source: tinkacore.js

import {TinkaTop, Button, Knob, Slider, Joystick, Distance, Color, Motor} from './tinkatop.js';

/**
 * Class representing a Tinkacore.
 * Currently Supports:
 * - Connection
 *     - ID: 0
 *     - Output: [0|1] string containing name of sensor attached
 * - Button
 *     - ID: 1
 *     - Output: [0|1]
 * - Knob
 *     - ID: 2
 *     - Output: float ranging from -10 to 10
 * - Slider
 *     - ID: 3
 *     - Output: float ranging from 0 to 10
 * - Joystick
 *     - ID: 4
 *     - Output: horizontal float, vertical float ranging from -10 to 10
 * - Distance
 *     - ID: 23
 *     - Output: float ranging from 0 to 20
 * - Color
 *     - ID: 27
 *     - Output: red int, green int, blue int ranging from 0 to 255
 */
class TinkaCore {

    /**
     * Creates an instance of the Tinkacore class
     * @param {number} id
     * @param {Object} characteristics
     */
    constructor(id, characteristics) {

        // Static Variables
        TinkaCore.core_ids = TinkaCore.core_ids || {
            connected: new Set([]),
            disconnected: new Set([])
        }
        TinkaCore.number_added = TinkaCore.number_added || 0;

        TinkaCore.eventTypes = ['*', 'sensor change', 'reading', 'button',
                                'knob', 'slider', 'joystick', 'distance',
                                'color'];

        // Instance Variables
        // Bluetooth
        this.characteristics = characteristics;

        // Core
        this.id = id;
        this.number = TinkaCore.number_added;
        this.name = 'tinka' + TinkaCore.number_added;
        this.connected = true;

        // Sensor
        this.sensor_connected = false;
        this.sensor = null;
        this.reading = {};

        // Event Listeners
        // [ {'eventType': eventType, 'func': func, 'args': args} ]
        this.events = [];

        TinkaCore.add_core(this.id);
    }

    // ----------- Connection Methods -----------

    /**
     * Uses the Chrome Bluetooth API to connect the browser to a TinkaCore
     * and begin subsribing to messages sent by the core.
     *
     * In Progress - Sends an initial message to the TinkaCore to determine
     * its top.
     * @returns {boolean}
     */
    connect() {
        let self = this;

        self.who_am_i_handler = self.who_am_i.bind(self);
        self.characteristics[0].addEventListener('characteristicvaluechanged',
                                                 self.who_am_i_handler);

        self.characteristics[0].startNotifications().then(function(characteristic) {

        // Does it make sense to instantiate a new motor instance here?
	    let motor = new Motor();
	    let motorMessage = motor.createSpeedMotorMessage(0,3,0);
            self.characteristics[1].writeValue(motorMessage);
        });

        return true;
    }
    /**
     * Called when a Tinkacore is turned off or the Bluetooth connection is
     * otherwise lost.
     *
     * At the moment, this function is not meant to be called by the user
     * since it does not stop subscription to sensor messages.
     * @returns {boolean}
     */
    disconnect() {
        this.connected = false;
        TinkaCore.remove_core(this.id);
        return false;
    }

    /**
     * Called when a disconnected sensor is reconnected.
     * At the moment, it simply calls the connect function.
     * @param {Object} characteristics
     * @returns {boolean}
     */
    reconnect(characteristics) {
        let self = this;

        console.log('Reconnecting');
        self.characteristics = characteristics;
        TinkaCore.add_core(this.id);
        this.connected = true;

        self.connect();
        return true;
    }

    /**
     * Determines a sensor based on the sensor id, the appropriate
     * TinkaTop and instantiates a new TinkaTop instance.
     *
     * Unsupported TinkaTops (e.g. LED Grid) are set to an instance of the
     * of the generic TinkaTop class.
     * @param {number} sensor_id
     * @returns {TinkaTop}
     */
    connect_sensor(sensor_id) {
        switch (sensor_id) {
        case 1:
            this.sensor = new Button();
            break;
        case 2:
            this.sensor = new Knob();
            break;
        case 3:
            this.sensor = new Slider();
            break;
        case 4:
            this.sensor = new Joystick();
            break;
        case 5:
            this.sensor = new Motor();
            break;
        case 23:
            this.sensor = new Distance();
            break;
        case 27:
            this.sensor = new Color();
            break;
        default:
            this.sensor = new TinkaTop();
            console.log('not yet implemented');
        }
        this.sensor_connected = true;
        console.log('Sensor connected: ', this.sensor.name);

        return this.sensor;
    }
    /**
     * Called when a TinkaTop is taken off of a TinkaCore.
     * @returns {boolean}
     */
    disconnect_sensor() {
        this.sensor_connected = false;
        this.sensor = null;

        console.log('Sensor disconnected');
        return false;
    }

    // ----------- Sensor Methods -----------

    /**
     * TinkaCores send byte strings of different lengths to indicate an
     * important event like a sensor reading or connection.
     * - (See the protocols folder for more info.)
     *
     * This is the main parser function that unpacks the byte string,
     * interprets its length and meaning, and calls the appropriate functions.
     *
     * User defined event listeners are called here after messages are
     * successfully parsed.
     * @param {Object} event
     * @returns {boolean} - true if interpretable, false otherwise
     */
    parse_packet(event) {
        let self = this;
        let packet = new Uint8Array(event.target.value.buffer);

        // Correctly formed packets should have at least length 10
        if (packet.length < 10) {
            console.log('Error: Invalid packet length; too short');
            return false;
        }

        let packet_length = packet[2];
        let sensor_id = packet[6];
        let command_id = packet[8];
        let command = packet.slice(9);

        switch (sensor_id) {
            case 0: // Connect/Disconnect
                let new_sensor_id = command[0];
                if (new_sensor_id == 255) { this.disconnect_sensor(); }
                else { self.connect_sensor(new_sensor_id); }

                this._callEventListeners({type: 'sensor change',
                                         sensor: this.getSensorName(),
                                         value: this.sensor_connected,
                                         tinkacore: this});
                break;
            default:
                if (!this.sensor_connected) { this.connect_sensor(sensor_id); }
                if (sensor_id != this.sensor.id) { this.connect_sensor(sensor_id); }

                else {
                    let reading = this.sensor.sense(command_id, command);
                    this.reading[this.sensor.name] = reading;
                    console.log(this.name + ': ' + this.getSensorName() + ': ', reading);

                    // new - Iterate through event list
                    this._callEventListeners({type: 'reading',
                                             sensor: this.getSensorName(),
                                             value: reading,
                                             tinkacore: this});
                }
        }
    }

    /**
     * User called function to request the most recent value picked up by a
     * sensor.
     * - Return type depends on the sensor requested.
     * - False if that sensor has not been used yet.
     * @param {string} sensor_name
     * @returns {number | number[] | false}
     */
    getLastReading(sensor_name) {
        if (this.reading[sensor_name]) {
            return this.reading[sensor_name]
        }
        else { return false; }
    }

    /**
     * Getter for the name of the sensor.
     * @returns {string}
     */
    getSensorName() {
        if (this.sensor_connected) return this.sensor.name;
        else return 'none';
    }

    // ----------- Event Listeners -----------

    /**
     * Function allowing the user to define custom event listeners to be called
     * when a TinkaCore instance triggers an event. Events are called in the
     * order with which they were added.
     *
     * eventType must be one of the following:
     * - '*' - any message sent by a TinkaCore
     * - 'sensor change' - When a tinkatop is removed or added
     * - 'reading' - when any sensor triggers a sensor reading
     * - 'button', 'knob', 'slider', 'joystick', 'distance', 'color'
     *
     * @param {string} eventType
     * @param {function} func
     * @param {...*} args
     * @returns {boolean}
     */
     addEventListener(eventType, func, ...args) {
        if (typeof func !== "function") {
            throw "second argument must be a valid function";
            return false;
        }
        if (!TinkaCore.eventTypes.includes(eventType)) {
            throw "event type must be valid"; // list event types
            return false;
        }

        // Callbacks are not bound to the TinkaCore
        let newEvent = {'eventType': eventType, 'func': func, 'args': args};
        this.events.push(newEvent);
        return true;
    }

    /**
     * Removes a callback function from the events list preventing further calls.
     *
     * @param {string} eventType
     * @param {function} func
     * @returns {boolean}
     */
    removeEventListener(eventType, func) {
        this.events = this.events.filter(ev => (ev.eventType != eventType || ev.func != func));
        return true;
    }

    /**
     * Iterates through the events list and upon ANY TinkaCore event, calls
     * the relevent functions.
     *
     * @param {string} event
     * @returns {boolean}
     * @private
     */
    _callEventListeners(event) {
        for (let evObj of this.events) {
            if (
                evObj.eventType == '*' ||
                (evObj.eventType == 'reading' && event.type == 'reading') ||
                (evObj.eventType == event.sensor && event.type == 'reading') ||
                (evObj.eventType == 'sensor change' && event.type == 'sensor change')
            ) {
                evObj.func(event, ...evObj.args);
            }
        }
    }

    /**
     * Function that gets called right when a Tinkacore is first connected and
     * begins subscribing to messages. Determines if it is a motor and what
     * sensor if any is currently connected.
     * @param {Object} event
     * @returns {boolean}
     * @private
     */
    who_am_i(event) {
        let self = this;
        let found = false;
        let packet = new Uint8Array(event.target.value.buffer);

        // We are a motor
        // Motor responds with whether the message succeeded or failed
        if (packet.length == 10) {
            console.log('Motor!');
            found = true;
        }

        // We are a sensor
        // Core responds with a connect/disconnect message (almost)
        else if (packet.length == 13) {
            console.log('TinkaCore!');
            packet[6] = 0; // Swap the 5 with a 0
            found = true;
        }

        if (found) {
            self.parse_packet(event);
            self.characteristics[0].removeEventListener('characteristicvaluechanged',
                        self.who_am_i_handler);

            // If 'self' is not bound here. Then the event itself becomes 'self'
            self.characteristics[0].addEventListener('characteristicvaluechanged',
                        self.parse_packet.bind(self));
        }

        return found;
    }

    // ----------- Static Methods -----------

    /**
     * Keeps track of the Tinkacores that have been added.
     * Adds a TinkaCore ID to the connected set, and removes it from the
     * disconnected set if it has already been connected.
     * @param {string} peripheral_id
     * @returns {string}
     * @private
     */
    static add_core(peripheral_id) {
        // Check if TinkaCore is undefined
        if (TinkaCore.core_ids.disconnected.has(peripheral_id)) {
            TinkaCore.core_ids.disconnected.delete(peripheral_id);
        }
        else {
            TinkaCore.number_added += 1;
        }
        TinkaCore.core_ids.connected.add(peripheral_id);
        return peripheral_id;
    }

    /**
     * Moves a TinkaCore ID from the connected set to the disconnected set.
     * @param {string} peripheral_id
     * @returns {string}
     * @private
     */
    static remove_core(peripheral_id) {
        TinkaCore.core_ids.connected.delete(peripheral_id);
        TinkaCore.core_ids.disconnected.add(peripheral_id);
        return peripheral_id;
    }

    /**
     * In process function - forms and sends a message to a Tinkacore
     * that controls the motor. It can also be used to request the type of
     * TinkaTop indicated in the device's response.
     * @param {number} direction
     * @param {number} intensityInt
     * @param {number} intensityDecimal
     * @returns {number[]}
     */
    static createMessage(direction, intensityInt, intensityDecimal){
        let motorMessage = new Uint8Array([90,171, 10,0,0,2,5,0,0,direction, intensityInt, intensityDecimal]);
        return motorMessage;
    }
}

export { TinkaCore };