diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1140251 --- /dev/null +++ b/.gitignore @@ -0,0 +1,120 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +lerna-debug.log* +.pnpm-debug.log* + +# Diagnostic reports (https://nodejs.org/api/report.html) +report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json + +# Runtime data +pids +*.pid +*.seed +*.pid.lock + +# Directory for instrumented libs generated by jscoverage/JSCover +lib-cov + +# Coverage directory used by tools like istanbul +coverage +*.lcov + +# nyc test coverage +.nyc_output + +# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) +.grunt + +# Bower dependency directory (https://bower.io/) +bower_components + +# node-waf configuration +.lock-wscript + +# Compiled binary addons (https://nodejs.org/api/addons.html) +build/Release + +# Dependency directories +node_modules/ +jspm_packages/ + +# Snowpack dependency directory (https://snowpack.dev/) +web_modules/ + +# TypeScript cache +*.tsbuildinfo + +# Optional npm cache directory +.npm + +# Optional eslint cache +.eslintcache + +# Microbundle cache +.rpt2_cache/ +.rts2_cache_cjs/ +.rts2_cache_es/ +.rts2_cache_umd/ + +# Optional REPL history +.node_repl_history + +# Output of 'npm pack' +*.tgz + +# Yarn Integrity file +.yarn-integrity + +# dotenv environment variables file +.env +.env.test +.env.production + +# parcel-bundler cache (https://parceljs.org/) +.cache +.parcel-cache + +# Next.js build output +.next +out + +# Nuxt.js build / generate output +.nuxt +dist + +# Gatsby files +.cache/ +# Comment in the public line in if your project uses Gatsby and not Next.js +# https://nextjs.org/blog/next-9-1#public-directory-support +# public + +# vuepress build output +.vuepress/dist + +# Serverless directories +.serverless/ + +# FuseBox cache +.fusebox/ + +# DynamoDB Local files +.dynamodb/ + +# TernJS port file +.tern-port + +# Stores VSCode versions used for testing VSCode extensions +.vscode-test + +# yarn v2 +.yarn/cache +.yarn/unplugged +.yarn/build-state.yml +.yarn/install-state.gz +.pnp.* + +*.DS_Store diff --git a/README.md b/README.md index 91c0d71..1bcf019 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,2 @@ # circuitpython-repl-js -A JavaScript Module to help with interfacing to CircuitPython Devices +A JavaScript Module to help with interfacing to the REPL on CircuitPython Devices over serial. This has been tested with CircuitPython 8.0.0-beta.6. \ No newline at end of file diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..a03f833 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,14 @@ +{ + "name": "@adafruit/circuitpython-repl-js", + "version": "1.0.0", + "lockfileVersion": 2, + "requires": true, + "packages": { + "": { + "name": "@adafruit/circuitpython-repl-js", + "version": "1.0.0", + "license": "MIT", + "devDependencies": {} + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..2d49c4c --- /dev/null +++ b/package.json @@ -0,0 +1,29 @@ +{ + "name": "@adafruit/circuitpython-repl-js", + "publishConfig": { + "registry": "https://npm.pkg.github.com" + }, + "version": "1.0.0", + "description": "A JavaScript Module to help with interfacing to the REPL on CircuitPython Devices over serial", + "main": "repl.js", + "dependencies": { + }, + "devDependencies": {}, + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1" + }, + "repository": { + "type": "git", + "url": "git://github.com/adafruit/circuitpython-repl-js.git" + }, + "keywords": [ + "BLE", + "WebBluetooth" + ], + "author": "Melissa LeBlanc-Williams", + "license": "MIT", + "bugs": { + "url": "https://github.com/adafruit/circuitpython-repl-js/issues" + }, + "homepage": "https://github.com/adafruit/circuitpython-repl-js#readme" +} diff --git a/repl.js b/repl.js new file mode 100644 index 0000000..260816b --- /dev/null +++ b/repl.js @@ -0,0 +1,232 @@ +const CHAR_CTRL_C = '\x03'; +const CHAR_CTRL_D = '\x04'; +const CHAR_CRLF = '\x0a\x0d'; +const CHAR_BKSP = '\x08'; +const CHAR_TITLE_START = "\x1b]0;"; +const CHAR_TITLE_END = "\x1b\\"; + +const LINE_ENDING = "\r\n"; + +// Default timeouts in milliseconds (can be overridden with properties) +const PROMPT_TIMEOUT = 10000; +const PROMPT_CHECK_INTERVAL = 50; + +export class REPL { + constructor() { + this._pythonCodeRunning = false; + this._codeOutput = ''; + this._currentSerialReceiveLine = ''; + this._checkingPrompt = false; + this._titleMode = false; + this.promptTimeout = PROMPT_TIMEOUT; + this.promptCheckInterval = PROMPT_CHECK_INTERVAL; + this.withholdTitle = false; + this.serialTransmit = null; + this._tokenQueue = []; + } + + _sleep(ms) { + return new Promise(resolve => setTimeout(resolve, ms)); + } + + _timeout(callback, ms) { + return Promise.race([callback(), this._sleep(ms).then(() => {throw Error("Timed Out");})]); + } + + _currentLineIsPrompt() { + return this._currentSerialReceiveLine.match(/>>> $/); + } + + _regexEscape(regexString) { + return regexString.replace(/\\/, "\\\\"); + } + + // This should help detect lines like ">>> ", but not ">>> 1+1" + async checkPrompt() { + // Only allow one instance of this function to run at a time (unless this could cause it to miss a prompt) + if (!this._currentLineIsPrompt()) { + return; + } + + // Check again after a short delay to see if it's still a prompt + await this._sleep(this.promptCheckInterval); + + if (!this._currentLineIsPrompt()) { + return; + } + + this._pythonCodeRunning = false; + } + + async waitForPrompt() { + this._pythonCodeRunning = true; + await this.getToPrompt(); + + // Wait for a prompt + try { + await this._timeout( + async () => { + while (this._pythonCodeRunning) { + await this._sleep(100); + } + }, this.promptTimeout + ); + } catch (error) { + console.log("Awaiting prompt timed out."); + return false; + } + return true; + } + + async softRestart() { + await this.serialTransmit(CHAR_CTRL_D); + } + + async getToPrompt() { + await this.serialTransmit(CHAR_CTRL_C); + } + + async onSerialReceive(e) { + // Prepend a partial token if it exists + + if (this._partialToken) { + e.data = this._partialToken + e.data; + this._partialToken = null; + } + + // Tokenize the larger string and send to the parent + let tokens = this._tokenize(e.data); + + // Remove any partial tokens and store for the next serial data receive + if (tokens.length && this._hasPartialToken(tokens.slice(-1))) { + this._partialToken = tokens.pop(); + } + + // Send only full tokens to the token queue + for (let token of tokens) { + this._tokenQueue.push(token); + } + await this._processQueuedTokens(); + } + + async _processQueuedTokens() { + if (this._processing) { + return; + } + this._processing = true; + while (this._tokenQueue.length) { + await this._processToken(this._tokenQueue.shift()); + } + this._processing = false; + } + + async _processToken(token) { + if (token == CHAR_TITLE_START) { + this._titleMode = true; + this.setTitle(""); + } else if (token == CHAR_TITLE_END) { + this._titleMode = false; + } else if (this._titleMode) { + this.setTitle(token, true); + } + + let codeline = ''; + if (this._pythonCodeRunning) { + this._currentSerialReceiveLine += token; + + // Run asynchronously to avoid blocking the serial receive + this.checkPrompt(); + + if (this._currentSerialReceiveLine.includes(LINE_ENDING)) { + [codeline, this._currentSerialReceiveLine] = this._currentSerialReceiveLine.split(LINE_ENDING, 2); + } + } + + // Is it still running? Then we add to code output + if (this._pythonCodeRunning && codeline.length > 0) { + if (!codeline.match(/^\... /) && !codeline.match(/^>>> /)) { + this._codeOutput += codeline + LINE_ENDING; + } + } + } + + // Placeholder Function + setTitle(title, append=false) { + if (append) { + title = this.title + title; + } + + this.title = title; + } + + async _serialTransmit(msg) { + if (!this.serialTransmit) { + console.log("Default serial transmit function called. Message: " + msg); + throw new Error("REPL serialTransmit must be connected to an external transmit function"); + } else { + return await this.serialTransmit(msg); + } + } + + // Allows for supplied python code to be run on the device via the REPL + async runCode(code, codeTimeoutMs=15000) { + + // Wait for the prompt to appear + if (!(await this.waitForPrompt())) { + return null; + } + + // Slice the code up into block and lines and run it + this._pythonCodeRunning = true; + this._codeOutput = ''; + const codeBlocks = code.split(/(?:\r?\n)+(?!\s)/); + + let indentLevel = 0; + for (const block of codeBlocks) { + for (const line of block.split(/\r?\n/)) { + const codeIndent = Math.floor(line.match(/^\s*/)[0].length / 4); + // Send code line with indents removed + await this._serialTransmit(line.slice(codeIndent * 4) + CHAR_CRLF); + if (codeIndent < indentLevel) { + // Remove indents to match the code + await this._serialTransmit(CHAR_BKSP.repeat(indentLevel - codeIndent) + CHAR_CRLF); + } + indentLevel = codeIndent; + } + } + + // Wait for the code to finish running, so we can capture the output + if (codeTimeoutMs) { + try { + await this._timeout( + async () => { + while (this._pythonCodeRunning) { + await this._sleep(100); + } + }, codeTimeoutMs + ); + } catch (error) { + console.log("Code timed out."); + } + } else { + // Run without timeout + while (this._pythonCodeRunning) { + await this._sleep(100); + } + } + + return this._codeOutput; + } + + // Split a string up by full title start and end character sequences + _tokenize(string) { + const tokenRegex = new RegExp("(" + this._regexEscape(CHAR_TITLE_START) + "|" + this._regexEscape(CHAR_TITLE_END) + ")", "gi"); + return string.split(tokenRegex); + } + + // Check if a chunk of data has a partial title start/end character sequence at the end + _hasPartialToken(chunk) { + const partialToken = /\\x1b(?:\](?:0"?)?)?$/gi; + return partialToken.test(chunk); + } +} \ No newline at end of file