Source: ubitwebusb.js


/*
 * JavaScript functions for interacting with micro:bit microcontrollers over WebUSB
 * (Only works in Chrome browsers;  Pages must be either HTTPS or local)
 */

// Add a delay() method to promises 
// NOTE: I found this on-line somewhere but didn't note the source and haven't been able to find it!
Promise.delay = function(duration){
    return new Promise(function(resolve, reject){
        setTimeout(function(){
            resolve();
        }, duration)
    });
}

const MICROBIT_VENDOR_ID = 0x0d28
const MICROBIT_PRODUCT_ID = 0x0204
const MICROBIT_DAP_INTERFACE = 4

const controlTransferGetReport = 0x01
const controlTransferSetReport = 0x09
const controlTransferOutReport = 0x200
const controlTransferInReport = 0x100

const uBitBadMessageDelay = 500         // Delay if message failed
const uBitIncompleteMessageDelay = 150  // Delay if no message ready now
const uBitGoodMessageDelay = 20         // Time to try again if message was good


const DAPOutReportRequest = {
    requestType: "class",
    recipient: "interface",
    request: controlTransferSetReport,
    value: controlTransferOutReport,
    index: MICROBIT_DAP_INTERFACE
}

const DAPInReportRequest =  {
    requestType: "class",
    recipient: "interface",
    request: controlTransferGetReport,
    value: controlTransferInReport,
    index: MICROBIT_DAP_INTERFACE
}


/*
   Open and configure a selected device and then start the read-loop
 */
function uBitOpenDevice(device, callback) {
    function controlTransferOutFN(data) {
        return () => { return device.controlTransferOut(DAPOutReportRequest, data) }
    }

    let buffer=""
    let decoder = new TextDecoder("utf-8")
    const parser = /([^.:]*)\.*([^:]+|):(.*)/

    let transferLoop = function () {
        device.controlTransferOut(DAPOutReportRequest, Uint8Array.from([0x83])) // DAP ID_DAP_Vendor3: https://github.com/ARMmbed/DAPLink/blob/0711f11391de54b13dc8a628c80617ca5d25f070/source/daplink/cmsis-dap/DAP_vendor.c
        .then(() => device.controlTransferIn(DAPInReportRequest, 64))
        .then((data) => { 
            if (data.status != "ok") {
                return Promise.delay(uBitBadMessageDelay).then(transferLoop);
            }
            // First byte is echo of get UART command

            let arr = new Uint8Array(data.data.buffer)
            if(arr.length<2)  // Not a valid array: Delay
                return Promise.delay(uBitIncompleteMessageDelay).then(transferLoop)

            // Data: Process and get more
            let len = arr[1]  // Second byte is length of remaining message
            if(len==0) // If no data: delay
                return Promise.delay(uBitIncompleteMessageDelay).then(transferLoop)
            
            let msg = arr.slice(2,2+len)
            let string =  decoder.decode(msg);
            buffer += string;
            let firstNewline = buffer.indexOf("\n")
            while(firstNewline>=0) {
                let messageToNewline = buffer.slice(0,firstNewline)
                let now = new Date() 
                // Deal with line
                // If it's a graph/series format, break it into parts
                let parseResult = parser.exec(messageToNewline)
                if(parseResult) {
                    let graph = parseResult[1]
                    let series = parseResult[2]
                    let data = parseResult[3]
                    let callbackType = "graph-event"
                    // If data is numeric, it's a data message and should be sent as numbers
                    if(!isNaN(data)) {
                        callbackType = "graph-data"
                        data = parseFloat(data)
                    }
                    // Build and send the bundle
                    let dataBundle = {
                        time: now,
                        graph: graph, 
                        series: series, 
                        data: data
                    }
                    callback(callbackType, device, dataBundle)
                } else {
                    // Not a graph format.  Send it as a console bundle
                    let dataBundle = {time: now, data: messageToNewline}
                    callback("console", device, dataBundle)
                }

                buffer = buffer.slice(firstNewline+1)
                firstNewline = buffer.indexOf("\n")
            }
            // Delay long enough for complete message
            return Promise.delay(uBitGoodMessageDelay).then(transferLoop);
        })
        // Error here probably means micro:bit disconnected
        .catch(error => { if(device.opened) callback("error", device, error); device.close(); callback("disconnected", device, null);});
    }

    device.open()
        .then(() => device.selectConfiguration(1))
        .then(() => device.claimInterface(4))
        .then(controlTransferOutFN(Uint8Array.from([0x82, 0x00, 0xc2, 0x01, 0x00]))) // Vendor Specific command 2 (ID_DAP_Vendor2): https://github.com/ARMmbed/DAPLink/blob/0711f11391de54b13dc8a628c80617ca5d25f070/source/daplink/cmsis-dap/DAP_vendor.c ;  0x0001c200 = 115,200kBps
        .then( () => { callback("connected", device, null); return Promise.resolve()}) 
        .then(transferLoop)
        .catch(error => callback("error", device, error))
}

/**
 * Disconnect from a device 
 * @param {USBDevice} device to disconnect from 
 */
function uBitDisconnect(device) {
    if(device && device.opened) {
        device.close()
    }
}

/**
 * Send a string to a specific device
 * @param {USBDevice} device 
 * @param {string} data to send (must not include newlines)
 */
function uBitSend(device, data) {
    if(!device.opened)
        return
    // Need to send 0x84 (command), length (including newline), data's characters, newline
    let fullLine = data+'\n'
    let encoded = new TextEncoder("utf-8").encode(fullLine)
    let message = new Uint8Array(1+1+fullLine.length)
    message[0] = 0x84
    message[1] = encoded.length
    message.set(encoded, 2)
    device.controlTransferOut(DAPOutReportRequest, message) // DAP ID_DAP_Vendor3: https://github.com/ARMmbed/DAPLink/blob/0711f11391de54b13dc8a628c80617ca5d25f070/source/daplink/cmsis-dap/DAP_vendor.c
}


/**
 * Callback for micro:bit events
 * 
 
   Event data varies based on the event string:
  <ul>
   <li>"connection failure": null</li>
   <li>"connected": null</li>
   <li>"disconnected": null</li>
   <li>"error": error object</li>
   <li>"console":  { "time":Date object "data":string}</li>
   <li>"graph-data": { "time":Date object "graph":string "series":string "data":number}</li>
   <li>"graph-event": { "time":Date object "graph":string "series":string "data":string}</li>
  </ul>

 * @callback uBitEventCallback
 * @param {string} event ("connection failure", "connected", "disconnected", "error", "console", "graph-data", "graph-event" )
 * @param {USBDevice} device triggering the callback
 * @param {*} data (event-specific data object). See list above for variants
 * 
 */


/**
 * Allow users to select a device to connect to.
 * 
 * @param {uBitEventCallback} callback function for device events
 */
function uBitConnectDevice(callback) { 
    navigator.usb.requestDevice({filters: [{ vendorId: MICROBIT_VENDOR_ID, productId: 0x0204 }]})
        .then(  d => { if(!d.opened) uBitOpenDevice(d, callback)} )
        .catch( () => callback("connection failure", null, null))
}