929 lines
No EOL
30 KiB
JavaScript
929 lines
No EOL
30 KiB
JavaScript
const CHAR_CTRL_A = '\x01';
|
|
const CHAR_CTRL_B = '\x02';
|
|
const CHAR_CTRL_C = '\x03';
|
|
const CHAR_CTRL_D = '\x04';
|
|
const CHAR_CTRL_E = '\x05';
|
|
const CHAR_CRLF = '\x0a\x0d';
|
|
const CHAR_BKSP = '\x08';
|
|
const CHAR_TITLE_START = "\x1b]0;";
|
|
const CHAR_TITLE_END = "\x1b\\";
|
|
const CHAR_RAW_PASTE_UNSUPPORTED = "R\x00";
|
|
const CHAR_RAW_PASTE_SUPPORTED = "R\x01";
|
|
const REGEX_RAW_PASTE_RESPONSE = /R[\x00\x01]..\x01/;
|
|
|
|
const MODE_NORMAL = 1;
|
|
const MODE_RAW = 2;
|
|
const MODE_RAWPASTE = 3;
|
|
|
|
const TYPE_DIR = 16384;
|
|
const TYPE_FILE = 32768;
|
|
const DEBUG = true;
|
|
|
|
export const LINE_ENDING_CRLF = "\r\n";
|
|
export const LINE_ENDING_LF = "\n";
|
|
|
|
const CONTROL_SEQUENCES = [
|
|
REGEX_RAW_PASTE_RESPONSE
|
|
];
|
|
|
|
// Mostly needed when the terminal echos back the input
|
|
const IGNORE_OUTPUT_LINE_PREFIXES = [/^\... /, /^>>> /];
|
|
|
|
// Default timeouts in milliseconds (can be overridden with properties)
|
|
const PROMPT_TIMEOUT = 20000;
|
|
const CODE_EXECUTION_TIMEOUT = 15000;
|
|
const CODE_INTERRUPT_TIMEOUT = 1000;
|
|
const PROMPT_CHECK_INTERVAL = 50;
|
|
|
|
const REGEX_PROMPT_RAW_MODE = /raw REPL; CTRL-B to exit/;
|
|
|
|
// Class to use python code to get file information
|
|
// We want to do stuff like writing files, reading files, and listing files
|
|
export class FileOps {
|
|
constructor(repl, checkReadOnly=true) {
|
|
this._repl = repl;
|
|
this._isReadOnly = null;
|
|
this._doCheckReadOnly = checkReadOnly;
|
|
}
|
|
|
|
async _checkReadOnly() {
|
|
if (!this._doCheckReadOnly) {
|
|
return;
|
|
}
|
|
|
|
if (this._isReadOnly == null) {
|
|
this._isReadOnly = await this.isReadOnly();
|
|
}
|
|
|
|
if (this._isReadOnly()) {
|
|
throw new Error("File System is Read Only. Try disabling or ejecting the drive.");
|
|
}
|
|
}
|
|
|
|
async _checkReplErrors() {
|
|
let error = this._repl.getErrorOutput();
|
|
if (error) {
|
|
console.error("Python Error - " + error.type + ": " + error.message);
|
|
if (error.type == "OSError" && error.errno == 30) {
|
|
this._isReadOnly = true;
|
|
// Throw an error if needed
|
|
await this._checkReadOnly();
|
|
}
|
|
}
|
|
|
|
return error;
|
|
}
|
|
|
|
// Write a file to the device path with contents beginning at offset. Modification time can be set and if raw is true, contents is written as binary
|
|
async _writeRawFile(path, contents, offset=0, modificationTime=null) {
|
|
let byteString = "";
|
|
// Contents needs to be converted from a ArrayBuffer to a byte string
|
|
let view = new Uint8Array(contents);
|
|
for (let byte of view) {
|
|
byteString += String.fromCharCode(byte);
|
|
}
|
|
contents = btoa(byteString); // Convert binary string to base64
|
|
|
|
let code = `
|
|
import binascii
|
|
with open("${path}", "wb") as f:
|
|
f.seek(${offset})
|
|
byte_string = binascii.a2b_base64("${contents}")
|
|
f.write(byte_string)
|
|
`;
|
|
|
|
if (modificationTime) {
|
|
code += `os.utime("${path}", (os.path.getatime("${path}"), ${modificationTime}))\n`;
|
|
}
|
|
await this._repl.execRawMode(code);
|
|
}
|
|
|
|
async _writeTextFile(path, contents, offset=0, modificationTime=null) {
|
|
// The contents needs to be converted from a UInt8Array to a string
|
|
contents = String.fromCharCode.apply(null, contents);
|
|
contents = contents.replace(/"/g, '\\"');
|
|
|
|
let code = `
|
|
with open("${path}", "w") as f:
|
|
f.seek(${offset})
|
|
f.write("""${contents}""")
|
|
`;
|
|
|
|
if (modificationTime) {
|
|
code += `os.utime("${path}", (os.path.getatime("${path}"), ${modificationTime}))\n`;
|
|
}
|
|
await this._repl.execRawMode(code);
|
|
}
|
|
|
|
// Write a file to the device path with contents beginning at offset. Modification time can be set and if raw is true, contents is written as binary
|
|
async writeFile(path, contents, offset=0, modificationTime=null, raw=false) {
|
|
this._repl.terminalOutput = DEBUG;
|
|
|
|
if (raw) {
|
|
await this._writeRawFile(path, contents, offset, modificationTime);
|
|
} else {
|
|
await this._writeTextFile(path, contents, offset, modificationTime);
|
|
}
|
|
|
|
this._repl.terminalOutput = true;
|
|
}
|
|
|
|
async _readRawFile(path) {
|
|
try {
|
|
let code = `
|
|
import binascii
|
|
with open("${path}", "rb") as f:
|
|
byte_string = f.read()
|
|
print(binascii.b2a_base64(byte_string, False))
|
|
`;
|
|
let result = await this._repl.execRawMode(code);
|
|
if (this._checkReplErrors()) {
|
|
return null;
|
|
}
|
|
|
|
// strip the b, ending newline, and quotes from the beginning and end
|
|
result = result.slice(2, -3);
|
|
|
|
// convert the base64 string to an ArrayBuffer. Each byte of the Array buffer is a byte of the file with a value between 0-255
|
|
result = atob(result); // Convert base64 to binary string
|
|
let length = result.length;
|
|
let buffer = new ArrayBuffer(length);
|
|
let view = new Uint8Array(buffer);
|
|
for (let i = 0; i < length; i++) {
|
|
view[i] = result.charCodeAt(i);
|
|
}
|
|
|
|
result = new Blob([buffer]);
|
|
return result;
|
|
} catch(error) {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
async _readTextFile(path) {
|
|
try {
|
|
let code = `
|
|
with open("${path}", "r") as f:
|
|
print(f.read())
|
|
`;
|
|
let result = await this._repl.execRawMode(code);
|
|
if (await this._checkReplErrors()) {
|
|
return null;
|
|
}
|
|
|
|
// Remove last 2 bytes from the result because \r\n is added to the end
|
|
return result.slice(0, -2);
|
|
} catch(error) {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
// Read a file from the device
|
|
async readFile(path, raw=false) {
|
|
let result;
|
|
this._repl.terminalOutput = DEBUG;
|
|
|
|
if (raw) {
|
|
result = await this._readRawFile(path);
|
|
} else {
|
|
result = await this._readTextFile(path);
|
|
}
|
|
|
|
this._repl.terminalOutput = true;
|
|
return result;
|
|
}
|
|
|
|
// List files using paste mode on the device returning the result as a javascript array
|
|
// We need the file name, whether or not it is a directory, file size and file date
|
|
async listDir(path) {
|
|
this._repl.terminalOutput = DEBUG;
|
|
// Mask sure path has a trailing slash
|
|
if (path[path.length - 1] != "/") {
|
|
path += "/";
|
|
}
|
|
|
|
let code = `
|
|
import os
|
|
import time
|
|
contents = os.listdir("${path}")
|
|
for item in contents:
|
|
result = os.stat("${path}" + item)
|
|
print(item, result[0], result[6], result[9])
|
|
`;
|
|
const result = await this._repl.execRawMode(code);
|
|
let contents = [];
|
|
if (!result) {
|
|
return contents;
|
|
}
|
|
for (let line of result.split("\n")) {
|
|
let [name, isDir, fileSize, fileDate] = line.split(" ");
|
|
contents.push({
|
|
path: name,
|
|
isDir: isDir == TYPE_DIR,
|
|
fileSize: parseInt(fileSize),
|
|
fileDate: parseInt(fileDate) * 1000,
|
|
});
|
|
}
|
|
this._repl.terminalOutput = true;
|
|
return contents;
|
|
}
|
|
|
|
async isReadOnly() {
|
|
this._repl.terminalOutput = DEBUG;
|
|
|
|
let code = `
|
|
import storage
|
|
print(storage.getmount("/").readonly)
|
|
`;
|
|
let result = await this._repl.execRawMode(code);
|
|
let isReadOnly = result.match("True") != null;
|
|
this._repl.terminalOutput = true;
|
|
|
|
return isReadOnly;
|
|
}
|
|
|
|
async makeDir(path, modificationTime=null) {
|
|
await this._checkReadOnly();
|
|
this._repl.terminalOutput = DEBUG;
|
|
let code = `os.mkdir("${path}")\n`;
|
|
if (modificationTime) {
|
|
code += `os.utime("${path}", (os.path.getatime("${path}"), ${modificationTime}))\n`;
|
|
}
|
|
await this._repl.execRawMode(code);
|
|
this._checkReplErrors();
|
|
this._repl.terminalOutput = true;
|
|
}
|
|
|
|
async delete(path) {
|
|
await this._checkReadOnly();
|
|
this._repl.terminalOutput = DEBUG;
|
|
let code = `
|
|
import os
|
|
|
|
stat = os.stat("${path}")
|
|
if stat[0] == ${TYPE_FILE}:
|
|
os.remove("${path}")
|
|
else:
|
|
os.rmdir("${path}")
|
|
`;
|
|
await this._repl.execRawMode(code);
|
|
this._checkReplErrors();
|
|
this._repl.terminalOutput = true;
|
|
}
|
|
|
|
async move(oldPath, newPath) {
|
|
await this._checkReadOnly();
|
|
// we need to check if the new path already exists
|
|
// Return true on success and false on failure
|
|
|
|
this._repl.terminalOutput = DEBUG;
|
|
let code = `
|
|
import os
|
|
os.rename("${oldPath}", "${newPath}")
|
|
`;
|
|
await this._repl.execRawMode(code);
|
|
let error = this._checkReplErrors();
|
|
this._repl.terminalOutput = true;
|
|
return !error;
|
|
}
|
|
}
|
|
|
|
export class REPL {
|
|
constructor() {
|
|
this._pythonCodeRunning = false;
|
|
this._codeOutput = '';
|
|
this._errorOutput = '';
|
|
this._serialInputBuffer = '';
|
|
this._checkingPrompt = false;
|
|
this._titleMode = false;
|
|
this.promptTimeout = PROMPT_TIMEOUT;
|
|
this.promptCheckInterval = PROMPT_CHECK_INTERVAL;
|
|
this.title = '';
|
|
this.serialTransmit = null;
|
|
this._inputLineEnding = LINE_ENDING_CRLF; // The line ending the REPL returns
|
|
this._outputLineEnding = LINE_ENDING_LF; // The line ending for the code result
|
|
this._tokenQueue = [];
|
|
this._mode = MODE_NORMAL;
|
|
this._codeCheckPointer = 0; // Used for looking at code output
|
|
this._promptCheckPointer = 0; // Used for looking at prompt output/control characters
|
|
this._ctrlDCount = 0;
|
|
this.terminalOutput = true;
|
|
}
|
|
|
|
_sleep(ms) {
|
|
return new Promise(resolve => setTimeout(resolve, ms));
|
|
}
|
|
|
|
_timeout(callback, ms) {
|
|
return Promise.race([callback(), this._sleep(ms).then(() => {throw Error("Timed Out");})]);
|
|
}
|
|
|
|
_getControlCharBuffer() {
|
|
// Return the Serial Buffer from _controlCharPointer to the next line ending and update the position of the pointer
|
|
let bufferLines, controlChar = '';
|
|
let remainingBuffer = this._serialInputBuffer.slice(this._controlCharPointer);
|
|
if (remainingBuffer.includes(this._inputLineEnding)) {
|
|
[controlChar, ...bufferLines] = remainingBuffer.split(this._inputLineEnding);
|
|
this._controlCharPointer += controlChar.length + this._inputLineEnding.length;
|
|
}
|
|
return controlChar;
|
|
}
|
|
|
|
_getInputBufferLines() {
|
|
return this._serialInputBuffer.split(this._inputLineEnding);
|
|
}
|
|
|
|
_lineIsPrompt(prompt_regex) {
|
|
let lines = this._getInputBufferLines();
|
|
if (lines.length == 0) {
|
|
return false;
|
|
}
|
|
let lastLine = lines[lines.length - 1];
|
|
return lastLine.match(prompt_regex);
|
|
}
|
|
|
|
_checkForModeChange() {
|
|
let lines = this._getInputBufferLines();
|
|
// Scan through the buffer and keep changing modes as clues are found
|
|
for (let line of lines) {
|
|
if (line.match(REGEX_PROMPT_RAW_MODE)) {
|
|
this._mode = MODE_RAW;
|
|
} else if (this._currentLineIsNormalPrompt()) {
|
|
this._mode = MODE_NORMAL;
|
|
}
|
|
}
|
|
}
|
|
|
|
_currentLineIsNormalPrompt() {
|
|
return this._lineIsPrompt(/>>> $/);
|
|
}
|
|
|
|
_currentLineIsPastePrompt() {
|
|
return this._lineIsPrompt(/=== $/);
|
|
}
|
|
|
|
_currentLineIsPrompt() {
|
|
if (this._mode == MODE_NORMAL) {
|
|
return this._currentLineIsNormalPrompt();
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
_regexEscape(regexString) {
|
|
return regexString.replace(/\\/, "\\\\");
|
|
}
|
|
|
|
setLineEnding(lineEnding) {
|
|
if (lineEnding != LINE_ENDING_CRLF && lineEnding != LINE_ENDING_LF) {
|
|
throw new Error("Line ending expected to be either be LINE_ENDING_CRLF or LINE_ENDING_LF")
|
|
}
|
|
|
|
this._outputLineEnding = lineEnding;
|
|
}
|
|
|
|
// This should help detect lines like ">>> ", but not ">>> 1+1"
|
|
async checkPrompt() {
|
|
if (this._mode == MODE_RAWPASTE || this._mode == MODE_RAW) {
|
|
let bytes = this._serialInputBuffer.slice(this._promptCheckPointer);
|
|
this._promptCheckPointer += bytes.length;
|
|
while (bytes.length > 0) {
|
|
if (bytes.slice(0, 1).match(CHAR_CTRL_D)) {
|
|
this._ctrlDCount++;
|
|
//console.log("CTRL-D Count: " + this._ctrlDCount);
|
|
} else {
|
|
if (this._ctrlDCount == 1) {
|
|
// Code Output
|
|
this._codeOutput += bytes.slice(0, 1);
|
|
} else if (this._ctrlDCount == 2) {
|
|
// Error Output
|
|
this._errorOutput += bytes.slice(0, 1);
|
|
if (this._mode == MODE_RAW) {
|
|
// We're done
|
|
this._pythonCodeRunning = false;
|
|
}
|
|
} else if (this._mode == MODE_RAWPASTE && this._ctrlDCount > 2) {
|
|
// Code is done running
|
|
this._pythonCodeRunning = false;
|
|
} else if (!this._pythonCodeRunning && bytes.slice(0, 1).match(">")) {
|
|
// We're at a prompt
|
|
this._mode = MODE_RAW;
|
|
return;
|
|
}
|
|
}
|
|
console.log("Bytes: " + bytes.slice(0,1));
|
|
bytes = bytes.slice(1); // Remove the first byte
|
|
}
|
|
|
|
return;
|
|
}
|
|
|
|
// 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;
|
|
|
|
// Wait for a prompt
|
|
try {
|
|
await this._timeout(
|
|
async () => {
|
|
while (this._pythonCodeRunning) {
|
|
await this.getToPrompt();
|
|
await this._sleep(100);
|
|
}
|
|
}, this.promptTimeout
|
|
);
|
|
} catch (error) {
|
|
console.error("Awaiting prompt timed out.");
|
|
return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
async softRestart() {
|
|
await this.serialTransmit(CHAR_CTRL_D);
|
|
}
|
|
|
|
async interruptCode() {
|
|
this._pythonCodeRunning = true;
|
|
// Wait for a prompt
|
|
try {
|
|
await this._timeout(
|
|
async () => {
|
|
while (this._pythonCodeRunning) {
|
|
await this.serialTransmit(CHAR_CTRL_C);
|
|
await this.exitRawMode();
|
|
this._checkForModeChange();
|
|
await this.checkPrompt();
|
|
await this._sleep(200);
|
|
}
|
|
}, CODE_INTERRUPT_TIMEOUT
|
|
);
|
|
} catch (error) {
|
|
console.error("Awaiting code interruption timed out. Restarting device.");
|
|
// Can't determine the state, so restart device
|
|
await this.softRestart();
|
|
return false;
|
|
}
|
|
|
|
}
|
|
|
|
getErrorOutput(raw = false) {
|
|
if (raw) {
|
|
return this._errorOutput;
|
|
}
|
|
if (!this._errorOutput) {
|
|
return null;
|
|
}
|
|
let errorOutput = this._errorOutput;
|
|
let errorLines = errorOutput.split(this._inputLineEnding);
|
|
let error = {
|
|
file: null,
|
|
line: null,
|
|
type: null,
|
|
message: null,
|
|
errno: null,
|
|
};
|
|
if (errorLines.length > 0) {
|
|
error.file = errorLines[1].match(/File "(.*)"/)[1];
|
|
error.line = parseInt(errorLines[1].match(/line (\d+)/)[1]);
|
|
error.type = errorLines[2].match(/(.+?):/)[1];
|
|
error.message = errorLines[2].match(/: (.+)$/)[1];
|
|
if (error.type == "OSError") {
|
|
error.errno = parseInt(error.message.match(/\[Errno (\d+)\]/)[1]);
|
|
error.message = error.message.replace(/\[Errno \d+\] /, '');
|
|
}
|
|
}
|
|
error.raw = errorOutput;
|
|
return error;
|
|
}
|
|
|
|
getCodeOutput() {
|
|
return this._codeOutput;
|
|
}
|
|
|
|
async getToPrompt() {
|
|
// Figure out the current mode and change it if needed
|
|
this._checkForModeChange();
|
|
|
|
// these will exit Raw Paste Mode or Raw mode if needed, otherwise they do nothing
|
|
await this.exitRawPasteMode();
|
|
await this.exitRawMode();
|
|
|
|
// We use GetToPrompt to ensure we are at a known place before running code
|
|
// This will get from Paste Mode or Running App to Normal Prompt
|
|
await this.interruptCode();
|
|
|
|
this._mode = MODE_NORMAL;
|
|
}
|
|
|
|
async hjkk execRawMode(code) {
|
|
await this.enterRawMode();
|
|
console.log(this._serialInputBuffer);
|
|
if (this._readUntil(REGEX_PROMPT_RAW_MODE)) {
|
|
this._readUntil(">");
|
|
}
|
|
await this.serialTransmit(code);
|
|
// Execute the code
|
|
await this.serialTransmit(CHAR_CTRL_D);
|
|
let status = this._readSerialBytes(2);
|
|
await this._timeout(
|
|
async () => {
|
|
while (status.length < 2) {
|
|
status = this._readSerialBytes(2);
|
|
console.log("Status: " + status);
|
|
await this._sleep(100);
|
|
}
|
|
}, this.promptTimeout
|
|
);
|
|
|
|
|
|
//if (status == "OK") {
|
|
this._ctrlDCount = 0;
|
|
this._pythonCodeRunning = true;
|
|
this._codeOutput = '';
|
|
this._errorOutput = '';
|
|
await this._waitForCodeExecution();
|
|
//} else {
|
|
// console.error("Failed to execute code in raw mode.");
|
|
//}
|
|
this._clearSerialBytes();
|
|
await this.exitRawMode();
|
|
return this._codeOutput;
|
|
}
|
|
|
|
async execRawPasteMode(code, codeTimeoutMs=CODE_EXECUTION_TIMEOUT) {
|
|
let success = await this.enterRawPasteMode();
|
|
if (success) {
|
|
// We're in raw mode only
|
|
await this.serialTransmit(code);
|
|
// We need to use flow control
|
|
let flowControlWindowSize = this._readSerialBytes(2);
|
|
// Convert 2 bytes from unsigned little endian to an integer
|
|
flowControlWindowSize = flowControlWindowSize.charCodeAt(0) + (flowControlWindowSize.charCodeAt(1) << 8);
|
|
let remainingWindowSize = flowControlWindowSize;
|
|
this._readSerialBytes(1); // Skip the last byte
|
|
this._clearSerialBytes(); // Clear the serial buffer to remove the previous Raw Prompt
|
|
|
|
// Send the code in chunks up to the window size
|
|
let codeLength = code.length;
|
|
let codePointer = 0;
|
|
while (codePointer < codeLength) {
|
|
let chunk = code.slice(codePointer, codePointer + remainingWindowSize);
|
|
await this.serialTransmit(chunk);
|
|
codePointer += remainingWindowSize;
|
|
// Reduce the remain window size
|
|
remainingWindowSize -= chunk.length;
|
|
if (remainingWindowSize <= 0) {
|
|
// Read the next byte at the flow control pointer
|
|
let instruction = this._readSerialBytes(1);
|
|
if (instruction.match(CHAR_CTRL_A)) {
|
|
remainingWindowSize = flowControlWindowSize;
|
|
} else if (instruction.match(CHAR_CTRL_D)) {
|
|
// We're done
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
// Inform the device we're done
|
|
await this.serialTransmit(CHAR_CTRL_D);
|
|
this._ctrlDCount = 0;
|
|
this._pythonCodeRunning = true;
|
|
this._codeOutput = '';
|
|
this._errorOutput = '';
|
|
await this._waitForCodeExecution(codeTimeoutMs);
|
|
this._clearSerialBytes();
|
|
await this.exitRawMode();
|
|
return this._codeOutput.slice(Math.ceil(this._codeOutput.length / 2));
|
|
} else {
|
|
await this.execRawMode(code);
|
|
}
|
|
}
|
|
|
|
async _waitForCodeExecution(codeTimeoutMs=CODE_EXECUTION_TIMEOUT) {
|
|
// 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.error("Code timed out.");
|
|
}
|
|
} else {
|
|
// Run without timeout
|
|
while (this._pythonCodeRunning) {
|
|
await this._sleep(100);
|
|
}
|
|
}
|
|
}
|
|
|
|
_readSerialBytes(byteCount, offset=null) {
|
|
if (offset == null) {
|
|
offset = this._promptCheckPointer;
|
|
}
|
|
let bytes = this._serialInputBuffer.slice(offset, offset + byteCount);
|
|
this._promptCheckPointer += byteCount;
|
|
return bytes;
|
|
}
|
|
|
|
_readUntil(value, offset=null) {
|
|
// Read bytes using the _ReadSerialBytes function until the last x bytes match the value where x is the byte length of value
|
|
if (offset == null) {
|
|
offset = this._promptCheckPointer;
|
|
}
|
|
let bytes = this._readSerialBytes(1, offset);
|
|
let newByte = ' ';
|
|
// Continue to read 1 byte at a time until the last x bytes match the value
|
|
while (!bytes.match(value) && newByte.length > 0) {
|
|
newByte = this._readSerialBytes(1);
|
|
bytes += newByte;
|
|
}
|
|
if (newByte.length == 0) {
|
|
// Buffer end reached, reset the prompt check pointer to the end
|
|
this._promptCheckPointer = offset;
|
|
return false;
|
|
}
|
|
|
|
//console.log("Read Until: " + bytes);
|
|
return true;
|
|
}
|
|
|
|
_clearSerialBytes(offset=null) {
|
|
// Should this only clear up to the lower of the 2 pointers? (codeCheckPointer and promptCheckPointer)
|
|
if (offset == null) {
|
|
offset = this._promptCheckPointer;
|
|
}
|
|
if (offset > this._serialInputBuffer.length) {
|
|
offset = this._serialInputBuffer.length;
|
|
}
|
|
|
|
this._serialInputBuffer = this._serialInputBuffer.slice(offset);
|
|
this._promptCheckPointer -= offset;
|
|
this._codeCheckPointer -= offset;
|
|
}
|
|
|
|
async enterRawPasteMode() {
|
|
await this.enterRawMode();
|
|
let bufferLength = this._serialInputBuffer.length;
|
|
await this.serialTransmit(CHAR_CTRL_E + "A" + CHAR_CTRL_A);
|
|
await this._timeout(
|
|
async () => {
|
|
while (this._serialInputBuffer.length < bufferLength + 2) {
|
|
await this._sleep(100);
|
|
}
|
|
}, this.promptTimeout
|
|
);
|
|
if (this._serialInputBuffer.length < bufferLength + 2) {
|
|
console.error("Failed to enter raw paste mode.");
|
|
return;
|
|
}
|
|
// Grab the two characters after bufferLength
|
|
let response = this._readSerialBytes(2, bufferLength);
|
|
|
|
if (response.match(CHAR_RAW_PASTE_UNSUPPORTED)) {
|
|
console.error("Device does not support raw paste mode.");
|
|
} else if (response.match(CHAR_RAW_PASTE_SUPPORTED)) {
|
|
this._mode = MODE_RAWPASTE;
|
|
return true;
|
|
} else if (response == "ra") {
|
|
console.error("Device does not understand or support raw paste mode.");
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
async _waitForModeChange(mode) {
|
|
await this._timeout(
|
|
async () => {
|
|
while (this._mode != mode) {
|
|
this._checkForModeChange();
|
|
await this._sleep(100);
|
|
}
|
|
}, this.promptTimeout
|
|
);
|
|
}
|
|
|
|
// Raw mode allows code execution without echoing back to the terminal
|
|
async enterRawMode() {
|
|
if (this._mode == MODE_RAW) {
|
|
return;
|
|
}
|
|
await this.getToPrompt();
|
|
await this.serialTransmit(CHAR_CTRL_A);
|
|
await this._waitForModeChange(MODE_RAW);
|
|
}
|
|
|
|
async exitRawMode() {
|
|
if (this._mode != MODE_RAW) {
|
|
return;
|
|
}
|
|
await this.serialTransmit(CHAR_CTRL_B);
|
|
// Wait for >>> to be displayed
|
|
await this._waitForModeChange(MODE_NORMAL);
|
|
}
|
|
|
|
async exitRawPasteMode() {
|
|
if (this._mode != MODE_RAWPASTE) {
|
|
return;
|
|
}
|
|
await this.serialTransmit(CHAR_CTRL_D);
|
|
await this._waitForModeChange(MODE_RAW);
|
|
//this._mode = MODE_RAW;
|
|
}
|
|
|
|
_getSerialCodeOutput() {
|
|
let bufferLines, codeline = '';
|
|
// Get the remaining buffer from _codeCheckPointer to the next line ending and update the position of the pointer
|
|
let remainingBuffer = this._serialInputBuffer.slice(this._codeCheckPointer);
|
|
if (remainingBuffer.includes(this._inputLineEnding)) {
|
|
[codeline, ...bufferLines] = remainingBuffer.split(this._inputLineEnding);
|
|
this._codeCheckPointer += codeline.length + this._inputLineEnding.length;
|
|
}
|
|
return this._formatCodeOutput(codeline);
|
|
}
|
|
|
|
_formatCodeOutput(codeline) {
|
|
// Remove lines that are prompts or control characters and strip control sequences
|
|
// Return the result
|
|
for (let prefix of IGNORE_OUTPUT_LINE_PREFIXES) {
|
|
if (codeline.match(prefix)) {
|
|
return '';
|
|
}
|
|
}
|
|
for (let sequence of CONTROL_SEQUENCES) {
|
|
codeline = codeline.replace(sequence, '');
|
|
}
|
|
return codeline;
|
|
}
|
|
|
|
async onSerialReceive(e) {
|
|
// We tokenize the serial data to handle special character sequences (currently titles only)
|
|
// We don't want to modify e.data, so we make a copy of it
|
|
let data = e.data;
|
|
|
|
// Prepend a partial token if it exists
|
|
if (this._partialToken) {
|
|
data = this._partialToken + data;
|
|
this._partialToken = null;
|
|
}
|
|
|
|
// Tokenize the larger string and send to the parent
|
|
let tokens = this._tokenize(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();
|
|
if (this.terminalOutput) {
|
|
return data;
|
|
}
|
|
|
|
return "";
|
|
}
|
|
|
|
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;
|
|
//console.log("Title Start");
|
|
this._setTitle("");
|
|
} else if (token == CHAR_TITLE_END) {
|
|
//console.log("Title End");
|
|
this._titleMode = false;
|
|
} else if (this._titleMode) {
|
|
//console.log("New Title: " + token);
|
|
this._setTitle(token, true);
|
|
}
|
|
|
|
let codelines = [];
|
|
let codeline;
|
|
this._serialInputBuffer += token;
|
|
if (this._pythonCodeRunning) {
|
|
// Check if we are at a prompt
|
|
this.checkPrompt(); // Run asynchronously to avoid blocking the serial receive
|
|
|
|
if (this._mode != MODE_RAWPASTE && this._mode != MODE_RAW) {
|
|
do {
|
|
codeline = this._getSerialCodeOutput();
|
|
if (codeline) {
|
|
codelines.push(codeline);
|
|
}
|
|
} while (codeline.length > 0);
|
|
}
|
|
}
|
|
|
|
// Is it still running? Then we add to code output if there is any
|
|
if (this._pythonCodeRunning && codelines.length > 0) {
|
|
for (codeline of codelines) {
|
|
//console.log(codeline);
|
|
if (!codeline.match(/^\... /) && !codeline.match(/^>>> /) && !codeline.match(/^=== /)) {
|
|
if (this._mode != MODE_RAWPASTE && this._mode != MODE_RAW) {
|
|
if (codeline.match(REGEX_PROMPT_RAW_MODE)) {
|
|
this._mode = MODE_RAW;
|
|
continue;
|
|
}
|
|
}
|
|
this._codeOutput += codeline + this._outputLineEnding;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Placeholder Function
|
|
setTitle(title, append=false) {
|
|
return;
|
|
}
|
|
|
|
_setTitle(title, append=false) {
|
|
if (append) {
|
|
title = this.title + title;
|
|
}
|
|
|
|
this.title = title;
|
|
|
|
this.setTitle(title, append);
|
|
}
|
|
|
|
getVersion() {
|
|
return this._parseTitleInfo(/\| REPL \| (.*)$/);
|
|
}
|
|
|
|
getIpAddress() {
|
|
return this._parseTitleInfo(/((?:\d{1,3}\.){3}\d{1,3})/);
|
|
}
|
|
|
|
_parseTitleInfo(regex) {
|
|
if (this.title) {
|
|
let matches = this.title.match(regex);
|
|
if (matches && matches.length >= 2) {
|
|
return matches[1];
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
async _serialTransmit(msg) {
|
|
if (!this.serialTransmit) {
|
|
console.error("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 in normal mode
|
|
async runCode(code, codeTimeoutMs=CODE_EXECUTION_TIMEOUT) {
|
|
this.terminalOutput = DEBUG;
|
|
await this.getToPrompt();
|
|
let result = this.execRawMode(code + LINE_ENDING_LF, codeTimeoutMs);
|
|
this.terminalOutput = true;
|
|
return result;
|
|
}
|
|
|
|
// 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) + "|" + this._regexEscape(CHAR_CTRL_D) + ")", "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);
|
|
}
|
|
} |