Source: webrepl.js

import { EventEmitter } from './event-emitter.js';
/**
 * WebSocket connection is opened.
 * @event WebREPL#connected
 */
/**
 * WebSocket connection is closed.
 * @event WebREPL#disconnected
 */
/**
 * Incoming WebSocket connection error or internal WebREPL error.
 * @event WebREPL#error
 * @type {ErrorEvent}
 */
/**
 * WebREPL authenticated session with success.
 * @event WebREPL#authenticated
 */
/**
 * Every message coming from the webrepl to be printed on the terminal.
 * @event WebREPL#output
 * @type {string}
 */
/**
 * `ArrayBuffer` coming from the webrepl.
 * @event WebREPL#data
 * @type {ArrayBuffer}
 */

class WebREPL extends EventEmitter {
    /**
     * Represents a WebREPL connection.
     * @constructor
     * @param {object} [opts] - Initialization option
     * @param {string} [opts.ip] - Ip address to connect to.
     * @param {string} [opts.password] - Password to authenticate webrepl session.
     * @param {boolean} [opts.autoConnect] - Flags if should connect automatically when instantiating WebREPL class.
     * @param {boolean} [opts.autoAuth] - Flags if should authenticate the webrepl session automatically.
     * @param {number} [opts.timeout] - How long, in milliseconds, should wait for response from webrepl.
     * @property {string} [ip='192.168.4.1'] - Ip address to connect to.
     * @property {string} [password='micropythoN'] - Password to authenticate webrepl session.
     * @property {boolean} [autoConnect=false] - Flags if should connect automatically when instantiating WebREPL class.
     * @property {boolean} [autoAuth=false] - Flags if should authenticate the webrepl session automatically.
     * @property {number} [timeout=5000] - How long, in milliseconds, should wait for response from webrepl.
     * @property {Uint8Array} fileBuffer - Buffer for files being requested by `loadFile`.
     * @property {WebSocket} ws - WebSocket connection with board.
     * @example
     * let repl = new WebREPL({
     *     ip: '192.168.1.4',
     *     password: 'micropythoN',
     *     autoConnect: true,
     *     autoAuth: true,
     *     timeout: 5000
     * })
     */
    constructor(opts) {
        super()

        this.STOP = '\r\x03' // CTRL-C
        this.RESET = '\r\x04' // CTRL-D
        this.ENTER_RAW_REPL = '\r\x01' // CTRL-A
        this.EXECUTE_RAW_REPL = '\r\x04' // CTRL-D
        this.EXIT_RAW_REPL = '\r\x02' // CTRL-B

        opts = opts || {}
        this.ip = opts.ip || '192.168.4.1'
        this.password = opts.password || 'micropythoN'
        this.autoAuth = !!opts.autoAuth
        this.autoConnect = !!opts.autoConnect
        this.timeout = opts.timeout || 5000

        this.fileBuffer = new Uint8Array()

        if (this.autoConnect) {
            this.connect()
        }
    }

    /**
     * Creates a websocket connection following the WebREPL standards on the
     * ip set while instantiating `WebREPL` and binds events for its opening
     * and for parsing incoming messages.
     * @example
     * let repl = new WebREPL()
     * repl.connect()
     */
    connect() {
        try {
            this.ws = new WebSocket(`ws://${this.ip}:8266`)
            this.ws.binaryType = 'arraybuffer'
            this.ws.onopen = this._onOpen.bind(this)
            this.ws.onerror = this._onError.bind(this)
            this.ws.onclose = this._onClose.bind(this)
        } catch(err) {
            this._onError(err)
        }
    }

    _onOpen() {
        this.emit('connected')
        this.ws.onmessage = this._handleMessage.bind(this)
    }
    _onError(err) {
        this.emit('error', err)
    }
    _onClose() {
        this.emit('disconnected')
    }

    /**
     * Close the current websocket connection.
     * @example
     * let repl = new WebREPL()
     * repl.disconnect()
     */
    disconnect() {
        this.ws.close()
    }

    /**
     * Sends a keyboard interrupt character (CTRL-C).
     * @example
     * let repl = new WebREPL({ autoConnect: true })
     * let stopButton = document.querySelector('#stop-code')
     * repl.on('authenticated', () => {
     *     stopButton.addEventListener('click', repl.sendStop.bind(repl))
     * })
     */
    sendStop() {
        this.eval(this.STOP)
    }

    /**
     * Sends a keyboard interruption (sendStop) and then the character to
     * perform a software reset on the board (CTRL-D).
     * @example
     * let repl = new WebREPL({ autoConnect: true })
     * let resetButton = document.querySelector('#reset-button')
     * repl.on('authenticated', function() {
     *     resetButton.addEventListener('click', (e) => repl.softReset())
     * })
     */
    softReset() {
        this.sendStop()
        this.eval(this.RESET)
    }

    /**
     * Sends character to enter RAW Repl mode (CTRL-A).
     * @return {Promise} - Resolves when board enters in raw repl mode, rejects if timeout.
     * @example
     * let repl = new WebREPL({ autoConnect: true })
     * repl.on('authenticated', function() {
     *     repl.enterRawRepl()
     *         .then(() => {
     *             // RAW REPL
     *         })
     *     })
     * })
     */
    enterRawRepl() {
        return new Promise((resolve, reject) => {
            let timeout = setTimeout(() => {
                reject(new Error('Timeout: Could not enter raw repl mode.'))
            }, this.timeout)
            let onOutput = (output) => {
                if (output.indexOf('raw REPL; CTRL-B to exit') != -1) {
                    this.removeListener('output', onOutput)
                    resolve()
                }
            }
            this.on('output', onOutput)
            this.eval(this.ENTER_RAW_REPL)
        })
    }

    /**
     * Evaluate the code on raw repl line by line and resolve promise when it
     * gets executed (prints "OK"). If `interval` is passed, wait that amount
     * of time before evaluating next line (important for ESP32 WebREPL).
     * @param {string} code - String containing lines of code separated by `\n`.
     * @param {number} interval - Interval in milliseconds between the lines of code.
     * @return {Promise} Resolves when code is pasted and executed (got `OK` from repl) on raw repl mode.
     * @example
     * let repl = new WebREPL({ autoConnect: true })
     * let code = `for i in range(0, 10):\n    print(i)`
     * repl.on('authenticated', function() {
     *     this.enterRawRepl()
     *         .then(() => this.execRaw(code, 30))
     *         .then(() => this.exitRawRepl(code))
     * })
     */
    execRaw(code, interval) {
        return new Promise((resolve, reject) => {
            let buffer = ''
            let onOutput = (output) => {
                if (output.indexOf('OK') != -1) {
                    this.removeListener('output', onOutput)
                    resolve()
                }
            }
            this.on('output', onOutput)
            if(interval) {
                code.split('\n').forEach((line, i) => {
                    setTimeout(() => {
                        this.eval(`${line}\r`)
                        this.emit('output', '.')
                    }, i*interval)
                })
                setTimeout(() => {
                    this.eval(this.EXECUTE_RAW_REPL)
                }, (code.split('\n').length + 1) * interval )
            } else {
                this.eval(code)
                this.eval(this.EXECUTE_RAW_REPL)
            }
        })
    }

    /**
     * Sends character to enter RAW Repl mode (CTRL-D + CTRL-B).
     * @return {Promise} Resolves when exits raw repl mode (Gets MicroPython booting message).
     * @example
     * let repl = new WebREPL({ autoConnect: true })
     * repl.on('authenticated', function() {
     *     repl.enterRawRepl()
     *         .then(() => repl.execRaw('print("hello world!")'))
     *         .then(() => repl.exitRawRepl())
     * })
     */
    exitRawRepl() {
        return new Promise((resolve, reject) => {
            let onOutput = (output) => {
                let endRaw = 'Type "help()" for more information.'
                if (output.indexOf(endRaw) != -1) {
                    this.removeListener('output', onOutput)
                    resolve()
                }
            }
            this.on('output', onOutput)
            this.eval(this.EXIT_RAW_REPL)
        })
    }

    /**
     * Execute a string containing lines of code separated by `\n` in raw repl
     * mode. It will send a keyboard interrupt before entering RAW REPL mode.
     * @param {string} code - String containing lines of code separated by `\n`.
     * @param {number} interval - Interval in milliseconds between the lines of code.
     * @return {Promise} Resolves when exits raw repl mode.
     * @example
     * let repl = new WebREPL({ autoConnect: true })
     * let code = `for i in range(0, 10):\n    print(i)`
     * repl.on('authenticated', function() {
     *     this.execFromString(code)
     *         .then(() => console.log('code executed'))
     *
     * })
     */
    execFromString(code, interval) {
        this.sendStop()
        return this.enterRawRepl()
            .then(() => this.execRaw(code, interval))
            .then(() => this.exitRawRepl())
    }

    /**
     * Send command to websocket connection.
     * @param {string} command - Command to be sent.
     * @example
     * let repl = new WebREPL({ autoConnect: true })
     * repl.on('authenticated', function() {
     *     this.eval('print("hello world!")\r')
     * })
     */
    eval(command) {
        this.ws.send(command)
    }

    /**
     * Evaluate command to the board followed by a line break (\r).
     * @param {string} command - Command to be executed by WebREPL.
     * @example
     * let repl = new WebREPL({ autoConnect: true })
     * repl.on('authenticated', function() {
     *     repl.exec('print("hello world!")')
     * })
     */
    exec(command) {
        this.eval(command + '\r')
    }

    /**
     * Save file to MicroPython's filesystem. Will use the filename from the
     * `file` argument object as the filesystem path.
     * @param {string} filename - Name of file to be sent
     * @param {Uint8Array} putFileData - Typed array buffer with content of file to be sent.
     * @return {Promise} Resolves when file is sent.
     * @example
     * let repl = new WebREPL({ autoConnect: true })
     * let filename = 'foo.py'
     * let buffer = new TextEncoder("utf-8").encode('print("hello world!")');
     * repl.on('authenticated', function() {
     *     repl.sendFile(filename, buffer)
     * })
     */
    sendFile(filename, putFileData) {
        return new Promise((resolve, reject) => {
            let timeout = setTimeout(() => {
                reject(new Error('Timeout: Could not send file.'))
            }, this.timeout)
            let initialResponse = (data) => {
                clearTimeout(timeout)
                let response = new Uint8Array(data)
                if (this._decode_response(response) == 0) {
                    this.removeListener('data', initialResponse)
                    // Register listener for final response
                    this.on('data', finalResponse)
                    // Send file in chunks
                    for (let offset = 0; offset < putFileData.length; offset += 1024) {
                        this.ws.send(putFileData.slice(offset, offset + 1024))
                    }
                }
            }
            let finalResponse = (data) => {
                let response = new Uint8Array(data)
                if (this._decode_response(response) == 0) {
                    this.removeListener('data', finalResponse)
                    resolve()
                }
            }
            // Register listener for initial response
            this.on('data', initialResponse)
            // Send request to open file
            let rec = this._getPutBinary(filename, putFileData.length)
            this.ws.send(rec)
        })
    }

    /*
     * Given a filename and the file size, get a `Uint8Array` with fixed length
     * and specific bits set to send a "put" request to MicroPython.
     * @param {string} filename - File name
     * @param {number} filesize - Length of ArrayBuffer containing file data
     * @returns {Uint8Array}
     */
    _getPutBinary(filename, filesize) {
        // WEBREPL_FILE = "<2sBBQLH64s"
        let rec = new Uint8Array(2 + 1 + 1 + 8 + 4 + 2 + 64)
        rec[0] = 'W'.charCodeAt(0)
        rec[1] = 'A'.charCodeAt(0)
        rec[2] = 1 // put
        rec[3] = 0
        rec[4] = 0; rec[5] = 0; rec[6] = 0; rec[7] = 0; rec[8] = 0; rec[9] = 0; rec[10] = 0; rec[11] = 0;
        rec[12] = filesize & 0xff; rec[13] = (filesize >> 8) & 0xff; rec[14] = (filesize >> 16) & 0xff; rec[15] = (filesize >> 24) & 0xff;
        rec[16] = filename.length & 0xff; rec[17] = (filename.length >> 8) & 0xff;
        for (let i = 0; i < 64; ++i) {
            if (i < filename.length) {
                rec[18 + i] = filename.charCodeAt(i)
            } else {
                rec[18 + i] = 0
            }
        }
        return rec
    }

    /**
     * Load file from MicroPython's filesystem.
     * @param {string} filename - File name
     * @return {Promise} Resolves with `Uint8Array` containing the data from requested file.
     * @example
     * let repl = new WebREPL({ autoConnect: true })
     * let filename = 'foo.py'
     * repl.saveAs = (blob) => {
     *     console.log('File content', blob)
     * }
     * repl.loadFile(filename)
     */
    loadFile(filename) {
        return new Promise((resolve, reject) => {
            this.fileBuffer = new Uint8Array()
            let timeout = setTimeout(() => {
                reject(new Error('Timeout: Could not get file.'))
            }, this.timeout)
            let initialResponse = (data) => {
                let response = new Uint8Array(data)
                if (this._decode_response(response) == 0) {
                    this.removeListener('data', initialResponse)
                    this.on('data', onFileData)
                    let nextRec = new Uint8Array(1)
                    nextRec[0] = 0
                    this.ws.send(nextRec)
                }
            }
            let onFileData = (data) => {
                let response = new Uint8Array(data)
                var sz = response[0] | (response[1] << 8);
                if (response.length == 2 + sz) {
                    // we assume that the data comes in single chunks
                    if (sz != 0) {
                        // accumulate incoming data to fileBuffer
                        let newBuffer = new Uint8Array(this.fileBuffer.length + sz)
                        newBuffer.set(this.fileBuffer)
                        newBuffer.set(response.slice(2), this.fileBuffer.length)
                        this.fileBuffer = newBuffer
                        let rec = new Uint8Array(1)
                        rec[0] = 0
                        this.ws.send(rec)
                    }
                } else {
                    // Done receiving
                    this.removeListener('data', onFileData)
                    resolve(this.fileBuffer)
                }
            }
            let rec = this._getGetBinary(filename)
            this.on('data', initialResponse)
            this.ws.send(rec)
        })
    }

    /*
     * Given a filename, get a `Uint8Array` with fixed length and specific bits
     * set to send a "get" request to MicroPython.
     * @param {string} filename - File name
     * @returns {Uint8Array}
     */
    _getGetBinary(filename) {
        // WEBREPL_FILE = "<2sBBQLH64s"
        let rec = new Uint8Array(2 + 1 + 1 + 8 + 4 + 2 + 64);
        rec[0] = 'W'.charCodeAt(0);
        rec[1] = 'A'.charCodeAt(0);
        rec[2] = 2; // get
        rec[3] = 0;
        rec[4] = 0; rec[5] = 0; rec[6] = 0; rec[7] = 0; rec[8] = 0; rec[9] = 0; rec[10] = 0; rec[11] = 0;
        rec[12] = 0; rec[13] = 0; rec[14] = 0; rec[15] = 0;
        rec[16] = filename.length & 0xff; rec[17] = (filename.length >> 8) & 0xff;
        for (let i = 0; i < 64; ++i) {
            if (i < filename.length) {
                rec[18 + i] = filename.charCodeAt(i);
            } else {
                rec[18 + i] = 0;
            }
        }
        return rec
    }

    /*
     * Makes sure incoming data is valid.
     * @param {ArrayBuffer} - Incoming data from webrepl.
     * @return {number} Returns `0` if valid and `-1` otherwise.
     */
    _decode_response(data) {
        if (data[0] == 'W'.charCodeAt(0) && data[1] == 'B'.charCodeAt(0)) {
            var code = data[2] | (data[3] << 8);
            return code;
        } else {
            return -1;
        }
    }

    /*
     * Handles incoming data from websocket based on the data and current
     * binaryState the WebREPL currently is set to.
     * @param {object} event - Incoming event object from websocket connection.
     * @param {ArrayBuffer|String} event.data - Data sent by MicroPython
     * through websocket.
     */
    _handleMessage(event) {
        if (event.data instanceof ArrayBuffer) {
            this.emit('data', event.data)
        } else if (typeof event.data === 'string') {
            this.emit('output', event.data)
            // If is asking for password, send password
            if (event.data == 'Password: ' && this.autoAuth) {
                this.ws.send(`${this.password}\r`)
            }
            if (event.data.indexOf('WebREPL connected') != -1) {
                this.emit('authenticated')
            }
        } else {
            this._onError(new Error('Unrecognized data format.'))
        }
    }
}

export default WebREPL

export { WebREPL }