Source: Buzz.js


// Garrett Flynn, Apache 2.0 License
import 'regenerator-runtime/runtime' //For async functions on node\\

/** @module neosensory 
 * @description A JavaScript SDK to help streamline controlling Neosensory devices over Bluetooth Low Energy.
 * 
*/

export class Buzz {

    /** @constructor 
     * @alias module:neosensory.Buzz 
     * @description A class to manage connections to the Neosensory Buzz
     * @param {callback} ondata Callback function to handle app logic
    */
    constructor(ondata = () => { }) {

        this.interface = null;
        this.readBuffer = []
        this.lastCommand = []

        this.interface = new BuzzBLE();
        this.interface.onNotificationCallback = (e) => {
            ondata(this.parseResponse(this.interface.decoder.decode(e.target.value)));
        }
    }

    /**
     * @method module:neosensory.Buzz.sendCommand
     * @alias sendCommand
     * @description A function to encode command strings and send to the device
     * @params {string} Command to send to the device
     */
    sendCommand(command = '') {
        this.interface.sendMessage(command);
    }

    /**
     * @method module:neosensory.Buzz.requestAuthorization
     * @alias requestAuthorization
     * @description Request developer authorization (https://neosensory.com/legal/dev-terms-service)
     */
    requestAuthorization() {
        this.sendCommand('auth as developer\n')
    }

    /**
     * @method module:neosensory.Buzz.acceptTerms
     * @alias acceptTerms
     * @description Accept developer terms of the Neosensory Developer API License (https://neosensory.com/legal/dev-terms-service) after calling [requestAuthorization()]{@link module:neosensory.Buzz.requestAuthorization}. Successfully calling
     * this unlocks the following commands: audio start, audio stop, motors_clear_queue, motors start,
     * motors_stop, motors vibrate.
     */

    acceptTerms() {
        this.sendCommand('accept\n')
    }

    /**
     * @method module:neosensory.Buzz.pauseDeviceAlgorithm
     * @alias pauseDeviceAlgorithm
     * @description Pause the default Neosensory algorithm on the device to accept developer commands. Requires users to [requestAuthorization()]{@link module:neosensory.Buzz.requestAuthorization} and [acceptTerms()]{@link module:neosensory.Buzz.acceptTerms}.
     */
    pauseDeviceAlgorithm() {
        this.stopAudio()
        this.startMotors()
    }

    /**
     * @method module:neosensory.Buzz.resumeDeviceAlgorithm
     * @alias resumeDeviceAlgorithm
     * @description Restart the default Neosensory algorithm. Requires users to [requestAuthorization()]{@link module:neosensory.Buzz.requestAuthorization} and [acceptTerms()]{@link module:neosensory.Buzz.acceptTerms}.
     */
    resumeDeviceAlgorithm() {
        this.startAudio()
    }

    /**
     * @method module:neosensory.Buzz.battery
     * @alias battery
     * @description Request the current battery level from the device.
     */
    battery() {
        this.sendCommand('device battery_soc\n')
    }

    /**
     * @method module:neosensory.Buzz.info
     * @alias info
     * @description Obtain device and firmware information.
     */
    info() {
        this.sendCommand('device info\n')
    }

    /**
     * @method module:neosensory.Buzz.connect
     * @alias connect
     * @description Setup BLE interface.
     */

    async connect() {
        return await this.interface.connect();
    }

    /**
     * @method module:neosensory.Buzz.startAudio
     * @alias startAudio
     * @description Starts the device’s microphone audio acquisition. Requires users to [requestAuthorization()]{@link module:neosensory.Buzz.requestAuthorization} and [acceptTerms()]{@link module:neosensory.Buzz.acceptTerms}.
     */
    startAudio = () => {
        this.sendCommand('audio start\n')
    }

    /**
     * @method module:neosensory.Buzz.stopAudio
     * @alias stopAudio
     * @description Stops the device’s microphone audio acquisition. Requires users to [requestAuthorization()]{@link module:neosensory.Buzz.requestAuthorization} and [acceptTerms()]{@link module:neosensory.Buzz.acceptTerms}.
     */
    stopAudio = () => {
        this.sendCommand('audio stop\n')
    }

    /**
     * @method module:neosensory.Buzz.clearMotorQueue
     * @alias clearMotorQueue
     * @description Clear any vibration commands sitting the device’s motor FIFO queue. This should be called prior
     * to streaming control frames using [vibrateMotors]{@link module:neosensory.Buzz.vibrateMotors}. Requires users to [requestAuthorization()]{@link module:neosensory.Buzz.requestAuthorization} and [acceptTerms()]{@link module:neosensory.Buzz.acceptTerms}.
     */

    clearMotorQueue = () => {
        this.sendCommand('motors clear_queue\n')
    }

    /**
     * @method module:neosensory.Buzz.enableMotors
     * @alias enableMotors
     * @description Initialize and start the motors interface. The motors can then accept motors vibrate commands. Requires users to [requestAuthorization()]{@link module:neosensory.Buzz.requestAuthorization} and [acceptTerms()]{@link module:neosensory.Buzz.acceptTerms}.
     */

    enableMotors = () => {
        this.sendCommand('motors start\n')
    }

    /**
     * @method module:neosensory.Buzz.disableMotors
     * @alias disableMotors
     * @description Clear the motors command queue and shut down the motor drivers. Requires users to [requestAuthorization()]{@link module:neosensory.Buzz.requestAuthorization} and [acceptTerms()]{@link module:neosensory.Buzz.acceptTerms}.
     */

    disableMotors = () => {
        this.sendCommand('motors stop\n')
    }

    /**
     * @method module:neosensory.Buzz.vibrateMotors
     * @alias vibrateMotors
     * @description Set the actuators amplitudes on a connected Neosensory device. Requires users to [requestAuthorization()]{@link module:neosensory.Buzz.requestAuthorization} and [acceptTerms()]{@link module:neosensory.Buzz.acceptTerms}.
    * @param {array} controlFrames Nested arrays with a length matching the number of motors of the target device (Buzz: 4). Element values should between 0 and 255. 
    * @example buzz.vibrateMotors([[155,0,0,0]])
     */
    vibrateMotors = (controlFrames) => {
        let base64String = btoa(String.fromCharCode(...new Uint8Array(controlFrames.flat())));
        this.sendCommand(`motors vibrate ${base64String}\n`)
    }

    /**
     * @method module:neosensory.Buzz.setThreshold
     * @alias setThreshold
     * @description Configure how the device responds to the [vibrateMotors()]{@link module:neosensory.Buzz.vibrateMotors} command. Requires users to [requestAuthorization()]{@link module:neosensory.Buzz.requestAuthorization} and [acceptTerms()]{@link module:neosensory.Buzz.acceptTerms}.
    * @param {string} feedbackType Integer between 0-2. 0 = Default; 1 = Always Respond; 2 = Threshold Response.
    * @param {float} threshold Float between 0 - 64.
     */

    setThreshold = (feedbackType, threshold = '') => {
        if (feedbackType == 'default') feedbackType = 0
        if (feedbackType == 'always') feedbackType = 1
        if (feedbackType == 'threshold') feedbackType = 2

        this.sendCommand(`motors config_threshold ${feedbackType} ${threshold}\n`)
    }

    /**
 * @method module:neosensory.Buzz.getThreshold
 * @alias getThreshold
 * @description Return the current [vibrateMotors()]{@link module:neosensory.Buzz.vibrateMotors} command queue configuration. Requires users to [requestAuthorization()]{@link module:neosensory.Buzz.requestAuthorization} and [acceptTerms()]{@link module:neosensory.Buzz.acceptTerms}.
 */

    getThreshold = () => {
        this.sendCommand(`motors get_threshold\n`)
    }

    /**
     * @method module:neosensory.Buzz.setLRA
     * @alias setLRA
     * @description This command sets the LRA operation mode. This setting is not persistent, and will reset to the default (open loop) if the band is reset. Requires users to [requestAuthorization()]{@link module:neosensory.Buzz.requestAuthorization} and [acceptTerms()]{@link module:neosensory.Buzz.acceptTerms}.
     */

    setLRA = (mode) => {
        if (mode == 'open') mode = 0
        if (mode == 'closed') mode = 1

        this.sendCommand(`motors config_lra_mode ${mode}\n`)

    }

    /**
     * @method module:neosensory.Buzz.getLRA
     * @alias getLRA
     * @description This command allows you to read the current LRA vibration mode. Requires users to [requestAuthorization()]{@link module:neosensory.Buzz.requestAuthorization} and [acceptTerms()]{@link module:neosensory.Buzz.acceptTerms}.
     */

    getLRA = () => {
        this.sendCommand(`motors get_lra_mode ${mode}\n`)
    }

    /**
     * @method module:neosensory.Buzz.getLEDs
     * @alias getLEDs
     * @description Read the current RGB and intensity calues of the device's LEDs. Requires users to [requestAuthorization()]{@link module:neosensory.Buzz.requestAuthorization} and [acceptTerms()]{@link module:neosensory.Buzz.acceptTerms}.
     */

    getLEDs = () => {
        this.sendCommand('leds get\n')
    }

    /**
     * @method module:neosensory.Buzz.setLEDs
     * @alias setLEDs
     * @description Control the color and intensity of the device's 3 LEDs. Requires users to [requestAuthorization()]{@link module:neosensory.Buzz.requestAuthorization} and [acceptTerms()]{@link module:neosensory.Buzz.acceptTerms}.
     * @param {array} colors Three nested arrays each containing the rgb values (0-255) for an LED.
     * @param {array} intensities An array of length 3 containing the LED intensity (0-50).
     * @example buzz.setLEDS([[255,0,0],[0,255,0],[0,0,255]],[50,50,50])
     */

    setLEDs = (colors = [], intensities = []) => {

        let rgbToHex = (r, g, b) => {
            return "0x" + ((1 << 24) + (r << 16) + (g << 8) + b).toString(16).slice(1);
        }
        colors = colors.map(c => c = rgbToHex(...c))
        this.sendCommand(`leds set ${colors.join(' ')} ${intensities.map(i => i * 50).join(' ')}\n`)
    }

        /**
     * @method module:neosensory.Buzz.setButton
     * @alias setButton
     * @description Set button response and sensitivity. Requires users to [requestAuthorization()]{@link module:neosensory.Buzz.requestAuthorization} and [acceptTerms()]{@link module:neosensory.Buzz.acceptTerms}.
     * @param {boolean} feedback Allow the device to send a response when any button is pressed.
     * @param {boolean} sensitivity Allow sensitivity changes to the microphone.
     */
    setButton = (feedback, sensitivity) => {
        if (feedback == true || feedback == 'true') feedback = 1
        if (feedback == false || feedback == 'false') feedback = 0
        if (sensitivity == true || sensitivity == 'true') sensitivity = 1
        if (sensitivity == false || sensitivity == 'false') sensitivity = 0

        this.sendCommand(`set_buttons_response ${feedback} ${sensitivity}\n`)
    }

    /**
     * @method module:neosensory.Buzz.parseResponse
     * @alias parseResponse
     * @description Parse JSON response from the device.
     * @param {utf8} response UTF-8 byte array.
     */

    parseResponse(response) {
        let complete = false
        if (response.indexOf("{") != -1 && this.readBuffer.length == 0) {
            if (response.lastIndexOf("}") != -1) {
                this.lastCommand = response.slice(0, response.indexOf("{"))
                this.readBuffer.push(response.substring(
                    response.indexOf("{"),
                    response.lastIndexOf("}") + 1,
                ))
                complete = true;
            } else {
                this.readBuffer.push(response.substring(response.indexOf("{")))
            }
        } else {
            if (response.lastIndexOf("}") != -1) {
                this.readBuffer.push(response.substring(
                    0,
                    response.lastIndexOf("}") + 1,
                ))
                complete = true;
            } else if (this.readBuffer.length != 0) {
                this.readBuffer.push(response)
            }
        }

        if (complete) {
            let joinedBuffer = this.readBuffer.join('')
            this.readBuffer = []
            response = JSON.parse(joinedBuffer)
            response.command = this.lastCommand
            this.readBuffer = []
            return response
        }
    }

    /**
     * @method module:neosensory.Buzz.disconnect
     * @alias disconnect
     * @description Disconnect the device.
     */
    disconnect() {
        this.interface.disconnect();
        delete this.audio
        delete this.motors
        delete this.leds
    }
}

export class BuzzBLE { //This is formatted for the way the Neosensory Buzz sends/receives information. Other BLE devices will likely need changes to this to be interactive.
    constructor(
        serviceUUID = '6e400001-b5a3-f393-e0a9-e50e24dcca9e', rxUUID = '6e400002-b5a3-f393-e0a9-e50e24dcca9e', txUUID = '6e400003-b5a3-f393-e0a9-e50e24dcca9e'
        // serviceUUID = '6E400001-B5A3-F393-E0A9-E50E24DCCA9E', rxUUID = '6E400002-B5A3-F393-E0A9-E50E24DCCA9E', txUUID = '6E400003-B5A3-F393-E0A9-E50E24DCCA9E', async = false 
    ) {
        this.serviceUUID = serviceUUID;
        this.rxUUID = rxUUID; //characteristic that can receive input from this device
        this.txUUID = txUUID; //characteristic that can transmit input to this device
        this.encoder = new TextEncoder("utf-8");
        this.decoder = new TextDecoder("utf-8");

        this.device = null;
        this.server = null;
        this.service = null;
        this.rxchar = null; //receiver on the BLE device (write to this)
        this.txchar = null; //transmitter on the BLE device (read from this)


        this.android = navigator.userAgent.toLowerCase().indexOf("android") > -1; //Use fast mode on android (lower MTU throughput)

        this.n; //nsamples  
    }


    //Typical web BLE calls
    connect = async (serviceUUID = this.serviceUUID, rxUUID = this.rxUUID, txUUID = this.txUUID) => { //Must be run by button press or user-initiated call
        return await navigator.bluetooth.requestDevice({
            filters: [
                { services: [serviceUUID] },
                { namePrefix: 'Buzz' }
            ],
            optionalServices: [serviceUUID]
        })
            .then(device => {
                this.device = device;
                return device.gatt.connect(); //Connect to Buzz
            })
            .then(sleeper(100)).then(server => server.getPrimaryService(serviceUUID))
            .then(sleeper(100)).then(service => {
                this.service = service;
                service.getCharacteristic(rxUUID).then(sleeper(100)).then(characteristic => {
                    this.rxchar = characteristic;
                    return true // tx.writeValue(this.encoder.encode("t")); // Send command to start HEG automatically (if not already started)
                });
                return service.getCharacteristic(txUUID) // Get stream source
            })
            .then(sleeper(1100)).then(characteristic => {
                this.txchar = characteristic;
                return this.txchar.startNotifications().then(() => { this.txchar.addEventListener('characteristicvaluechanged', this.onNotificationCallback) }); // Subscribe to stream
            })
            .then(sleeper(100)).then(()=>{
                this.onConnectedCallback()
                return this.device
            })
            .catch(err => { console.error(err); this.onErrorCallback(err); });

        function sleeper(ms) {
            return function (x) {
                return new Promise(resolve => setTimeout(() => resolve(x), ms));
            };
        }
    }

    onNotificationCallback = (e) => { }

    onConnectedCallback = () => { }
    onErrorCallback = () => { }

    sendMessage = (msg) => {
        if (msg[msg.length - 2] != '\n') msg += '\n'
        let encoded = this.encoder.encode(msg)
        this.rxchar.writeValue(encoded);
    }

    disconnect = () => { this.server?.disconnect(); this.onDisconnectedCallback() };

    onDisconnectedCallback = () => { }
}