Compare commits
5 Commits
f74e21f206
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 2961ad2a29 | |||
| 35c0df8f88 | |||
| 5c97fa0d0b | |||
| 179ac9c540 | |||
| bd4d2060ae |
@@ -20,13 +20,14 @@ Connection is always via **`-p` / `--port`** (default `/dev/ttyACM0`). There is
|
||||
| `-o`, `--order` | LED colour order (`rgb`, `grb`, …) |
|
||||
| `--preset` / `--pattern` | Create or replace a named preset in **led-driver** `presets.json` |
|
||||
| `--default` | Startup preset name |
|
||||
| `--transport` | `espnow` or `wifi` (`transport_type` on device) |
|
||||
| `--transport` | `espnow` or `wifi` (`transport_type` on led-driver) |
|
||||
| `--serial-baudrate` | Bridge UART1 baud for Pi serial link (e.g. `921600`; also sets GPIO UART pins) |
|
||||
| `--ssid`, `--wifi-password`, `--wifi-channel` | Wi-Fi / channel fields for the driver |
|
||||
| `-r`, `--reset` | Reset the device |
|
||||
| `-f`, `--follow` | Follow serial output (optional timeout seconds) |
|
||||
| `--pause` | Sleep N seconds (for chained actions) |
|
||||
| `-u`, `--upload` | Recursive upload: `-u SRC [DEST]` (skips unchanged files via `file_hashes.json` on device) |
|
||||
| `--src`, `--lib`, `--all` | Deploy led-driver trees to flash root, `patterns/`, and `lib/` |
|
||||
| `--src`, `--lib`, `--all` | Deploy `src/` to device root and `lib/` to `/lib` (led-driver, espnow-sender, …) |
|
||||
| `--force-upload` | Upload every file; ignore `file_hashes.json` |
|
||||
| `-e`, `--erase` | Erase everything at device root (including `settings.json` and `presets.json`) |
|
||||
| `--rm` | Remove a path on the device |
|
||||
@@ -51,7 +52,7 @@ python web.py
|
||||
# open http://<host>:5000/editor
|
||||
```
|
||||
|
||||
**Embedded in led-controller:** open **LED Tool** in the main UI, or visit **`/led-tool/editor`**.
|
||||
**Embedded in led-controller:** open **Settings → LED Tool** in the main UI (Edit mode), or visit **`/led-tool/editor`**.
|
||||
|
||||
Legacy Flask form UI remains at **`/`** on port 5000; prefer **`/editor`** for Web Serial support.
|
||||
|
||||
|
||||
25
cli.py
25
cli.py
@@ -150,7 +150,8 @@ _FLAGS_WITH_VALUE = frozenset({
|
||||
'-p', '--port', '-n', '--name', '--pin', '-b', '--brightness',
|
||||
'-l', '--leds', '-d', '-debug', '--debug', '-o', '--order',
|
||||
'--preset', '--pattern', '--default', '--transport', '--ssid',
|
||||
'--wifi-password', '--wifi-channel', '--src', '--lib', '--patterns', '--paterns',
|
||||
'--wifi-password', '--wifi-channel', '--serial-baudrate',
|
||||
'--src', '--lib', '--patterns', '--paterns',
|
||||
})
|
||||
|
||||
|
||||
@@ -321,6 +322,9 @@ Examples:
|
||||
|
||||
# Reset logical device name to firmware default (STA MAC based)
|
||||
%(prog)s --reset-device-name
|
||||
|
||||
# ESP-NOW bridge: Pi on GPIO UART1 (USB-serial adapter)
|
||||
%(prog)s -p /dev/ttyUSB0 --serial-baudrate 921600
|
||||
"""
|
||||
)
|
||||
|
||||
@@ -413,6 +417,13 @@ Examples:
|
||||
help="led-driver transport_type",
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
"--serial-baudrate",
|
||||
type=int,
|
||||
metavar="BAUD",
|
||||
help="bridge: UART1 baud for Pi serial link (default 921600)",
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
"--ssid",
|
||||
help="led-driver ssid (Wi-Fi network in wifi mode)",
|
||||
@@ -747,6 +758,18 @@ Examples:
|
||||
if args.transport is not None:
|
||||
edits["transport_type"] = args.transport
|
||||
|
||||
if args.serial_baudrate is not None:
|
||||
edits["uplink_transport"] = "serial"
|
||||
edits["serial_usb"] = False
|
||||
edits["serial_uart_id"] = 1
|
||||
edits["serial_tx_pin"] = 2
|
||||
edits["serial_rx_pin"] = 3
|
||||
baud = int(args.serial_baudrate)
|
||||
if baud < 9600 or baud > 3000000:
|
||||
print("Error: --serial-baudrate must be 9600–3000000", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
edits["serial_baudrate"] = baud
|
||||
|
||||
if args.ssid is not None:
|
||||
edits["ssid"] = args.ssid
|
||||
|
||||
|
||||
@@ -155,6 +155,14 @@
|
||||
<label for="wifi_channel">WiFi channel</label>
|
||||
<input id="wifi_channel" data-setting="wifi_channel" type="number" min="1" max="11" />
|
||||
</div>
|
||||
<div>
|
||||
<label for="ap_ip">Bridge AP IP</label>
|
||||
<input id="ap_ip" data-setting="ap_ip" type="text" placeholder="192.168.4.1" />
|
||||
</div>
|
||||
<div>
|
||||
<label for="ap_password">Bridge AP password</label>
|
||||
<input id="ap_password" data-setting="ap_password" type="password" placeholder="min 8 chars, or empty for open" />
|
||||
</div>
|
||||
<div>
|
||||
<label for="default">Default preset</label>
|
||||
<input id="default" data-setting="default" type="text" />
|
||||
@@ -185,10 +193,11 @@
|
||||
document.body.getAttribute('data-api-base') ??
|
||||
(location.pathname.includes('/led-tool') ? '/led-tool' : '');
|
||||
const prefix = api + '/static';
|
||||
const v = '20260520';
|
||||
function loadScript(url) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const s = document.createElement('script');
|
||||
s.src = url;
|
||||
s.src = url + (url.includes('?') ? '&' : '?') + 'v=' + v;
|
||||
s.onload = () => resolve();
|
||||
s.onerror = () => reject(new Error('Failed to load ' + url));
|
||||
document.body.appendChild(s);
|
||||
|
||||
@@ -24,8 +24,10 @@
|
||||
let hostConnected = false;
|
||||
let hostConnectedPort = '';
|
||||
let transferBusy = false;
|
||||
let webConnectBusy = false;
|
||||
|
||||
const webSerialOk = window.LedToolWebSerial && window.LedToolWebSerial.supported();
|
||||
let webSerialGrantedCount = 0;
|
||||
|
||||
function setStatus(text, isError) {
|
||||
if (!statusEl) return;
|
||||
@@ -40,6 +42,17 @@
|
||||
setTimeout(() => messageEl.classList.remove('show'), 5000);
|
||||
}
|
||||
|
||||
function errorMessage(e) {
|
||||
if (!e) return 'Unknown error';
|
||||
if (typeof e === 'string') return e;
|
||||
return e.message || String(e);
|
||||
}
|
||||
|
||||
function onWebSerialTransferError(e) {
|
||||
if (webClient) webClient.inRawRepl = false;
|
||||
return errorMessage(e);
|
||||
}
|
||||
|
||||
function formToObject() {
|
||||
const out = {};
|
||||
document.querySelectorAll('[data-setting]').forEach((el) => {
|
||||
@@ -99,7 +112,11 @@
|
||||
} else if (hostOpen) {
|
||||
setStatus(`Host serial: ${hostConnectedPort} (use Download or Upload).`);
|
||||
} else if (webSerialOk) {
|
||||
setStatus('Connect host serial or Web Serial USB, then Download or Upload.');
|
||||
if (webSerialGrantedCount === 1) {
|
||||
setStatus('Connect USB (one remembered port) or host serial, then Download or Upload.');
|
||||
} else {
|
||||
setStatus('Connect host serial or Web Serial USB, then Download or Upload.');
|
||||
}
|
||||
} else {
|
||||
setStatus(`Connect host serial (default ${DEFAULT_HOST_PORT}).`);
|
||||
}
|
||||
@@ -205,21 +222,31 @@
|
||||
}
|
||||
|
||||
async function connectWebSerial() {
|
||||
if (webConnectBusy) return;
|
||||
if (!window.LedToolWebSerial || !window.LedToolWebSerial.supported()) {
|
||||
flash('Web Serial not supported (use Chrome or Edge over HTTPS or localhost).', true);
|
||||
return;
|
||||
}
|
||||
if (usingWebSerial()) {
|
||||
flash('USB already connected.', false);
|
||||
return;
|
||||
}
|
||||
if (usingHostSerial()) {
|
||||
disconnectHostSerial();
|
||||
}
|
||||
log('connectWebSerial: requestPort');
|
||||
setStatus('Opening USB port…');
|
||||
webPort = await window.LedToolWebSerial.requestPort();
|
||||
log('connectWebSerial: port selected', webPort);
|
||||
webClient = new window.LedToolWebSerial.MicroPythonRawRepl();
|
||||
await webClient.connect(webPort);
|
||||
updateUi();
|
||||
flash('USB port open. Use Download or Upload to talk to the device.', false);
|
||||
webConnectBusy = true;
|
||||
try {
|
||||
log('connectWebSerial: requestPort');
|
||||
setStatus('Opening USB port…');
|
||||
webPort = await window.LedToolWebSerial.requestPort();
|
||||
log('connectWebSerial: port selected', webPort);
|
||||
webClient = new window.LedToolWebSerial.MicroPythonRawRepl();
|
||||
await webClient.connect(webPort);
|
||||
updateUi();
|
||||
flash('USB port open. Wait for boot, then Download.', false);
|
||||
} finally {
|
||||
webConnectBusy = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function disconnectWebSerial() {
|
||||
@@ -284,9 +311,10 @@
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(LOG, 'download failed', e);
|
||||
if (rawJson) rawJson.value = String(e.message || e);
|
||||
flash(e.message, true);
|
||||
setStatus(e.message, true);
|
||||
const msg = usingWebSerial() ? onWebSerialTransferError(e) : errorMessage(e);
|
||||
if (rawJson) rawJson.value = msg;
|
||||
flash(msg, true);
|
||||
setStatus(msg, true);
|
||||
} finally {
|
||||
transferBusy = false;
|
||||
}
|
||||
@@ -310,8 +338,9 @@
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(LOG, 'upload failed', e);
|
||||
flash(e.message, true);
|
||||
setStatus(e.message, true);
|
||||
const msg = usingWebSerial() ? onWebSerialTransferError(e) : errorMessage(e);
|
||||
flash(msg, true);
|
||||
setStatus(msg, true);
|
||||
} finally {
|
||||
transferBusy = false;
|
||||
}
|
||||
@@ -374,7 +403,19 @@
|
||||
}
|
||||
});
|
||||
|
||||
async function refreshWebSerialGrantHint() {
|
||||
if (!webSerialOk || !window.LedToolWebSerial.getGrantedPorts) return;
|
||||
try {
|
||||
const ports = await window.LedToolWebSerial.getGrantedPorts();
|
||||
webSerialGrantedCount = ports.length;
|
||||
updateUi();
|
||||
} catch (_) {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
log('settings editor ready', { apiBase, webSerialOk });
|
||||
updateUi();
|
||||
loadHostPorts();
|
||||
refreshWebSerialGrantHint();
|
||||
})();
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
*/
|
||||
(function (global) {
|
||||
const RAW_REPL_BANNER = 'raw REPL; CTRL-B to exit\r\n';
|
||||
const SOFT_REBOOT = 'soft reboot\r\n';
|
||||
const SOFT_REBOOT_MARKERS = ['soft reboot\r\n', 'MPY: soft reboot\r\n'];
|
||||
const LOG = '[led-tool/serial]';
|
||||
|
||||
function log(...args) {
|
||||
@@ -29,12 +29,65 @@
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
function bytesEndsWith(buf, suffix) {
|
||||
if (buf.length < suffix.length) return false;
|
||||
for (let i = 0; i < suffix.length; i += 1) {
|
||||
if (buf[buf.length - suffix.length + i] !== suffix[i]) return false;
|
||||
/** Common ESP32 USB-UART bridges (usbVendorId only per Web Serial API). */
|
||||
const ESP32_USB_FILTERS = [
|
||||
{ usbVendorId: 0x303a }, /* Espressif */
|
||||
{ usbVendorId: 0x10c4 }, /* Silicon Labs CP210x */
|
||||
{ usbVendorId: 0x1a86 }, /* WCH CH340 */
|
||||
{ usbVendorId: 0x0403 }, /* FTDI */
|
||||
];
|
||||
|
||||
async function deassertDtrRts(port) {
|
||||
if (!port || !port.setSignals) return;
|
||||
try {
|
||||
await port.setSignals({ dataTerminalReady: false, requestToSend: false });
|
||||
} catch (_) {
|
||||
/* ignore — some adapters/browsers reject setSignals */
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function bytesIndexOf(buf, suffix) {
|
||||
if (!suffix.length) return 0;
|
||||
if (buf.length < suffix.length) return -1;
|
||||
for (let i = 0; i <= buf.length - suffix.length; i += 1) {
|
||||
let ok = true;
|
||||
for (let j = 0; j < suffix.length; j += 1) {
|
||||
if (buf[i + j] !== suffix[j]) {
|
||||
ok = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (ok) return i;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
function bufferEndsWithFriendlyRepl(buf) {
|
||||
if (bufferLooksLikeEspRomBoot(buf) && !bufferHasMicroPython(buf)) return false;
|
||||
const tail = decodeText(Uint8Array.from(buf.slice(-80)));
|
||||
return /(?:\r\n|\n)>>> ?$/.test(tail);
|
||||
}
|
||||
|
||||
function bufferLooksLikeEspRomBoot(buf) {
|
||||
if (bufferIndicatesRuntime(buf)) return false;
|
||||
const text = decodeText(Uint8Array.from(buf.slice(-512)));
|
||||
return /SPI_FAST_FLASH_BOOT|rst:0x/.test(text) && !/entry 0x403/.test(text);
|
||||
}
|
||||
|
||||
/** MicroPython is up (banner, REPL, raw REPL, or post-ROM heap lines from boot). */
|
||||
function bufferIndicatesRuntime(buf) {
|
||||
const text = decodeText(Uint8Array.from(buf));
|
||||
if (text.includes('MicroPython')) return true;
|
||||
if (text.includes('raw REPL; CTRL-B to exit')) return true;
|
||||
if (/(?:\r\n|\n)>>> ?/.test(text)) return true;
|
||||
if (text.includes('entry 0x') && (/['"]?free['"]?\s*:/.test(text) || /,\s*'free'/.test(text))) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function bufferHasMicroPython(buf) {
|
||||
return bufferIndicatesRuntime(buf);
|
||||
}
|
||||
|
||||
function decodeText(u8) {
|
||||
@@ -84,12 +137,15 @@
|
||||
return result.value || null;
|
||||
}
|
||||
|
||||
/** Match suffix anywhere in the buffer; keep bytes after the match for the next read. */
|
||||
async readUntil(suffixBytes, timeoutMs = 15000) {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
while (Date.now() < deadline) {
|
||||
if (bytesEndsWith(this._rxBuf, suffixBytes)) {
|
||||
const matched = Uint8Array.from(this._rxBuf);
|
||||
this._rxBuf.length = 0;
|
||||
const idx = bytesIndexOf(this._rxBuf, suffixBytes);
|
||||
if (idx >= 0) {
|
||||
const end = idx + suffixBytes.length;
|
||||
const matched = Uint8Array.from(this._rxBuf.slice(0, end));
|
||||
this._rxBuf.splice(0, end);
|
||||
logDebug('readUntil matched', JSON.stringify(decodeText(suffixBytes)), 'len', matched.length);
|
||||
return matched;
|
||||
}
|
||||
@@ -106,6 +162,29 @@
|
||||
throw new Error(err);
|
||||
}
|
||||
|
||||
async readUntilAny(suffixList, timeoutMs = 15000) {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
while (Date.now() < deadline) {
|
||||
for (const suffixBytes of suffixList) {
|
||||
const idx = bytesIndexOf(this._rxBuf, suffixBytes);
|
||||
if (idx >= 0) {
|
||||
const end = idx + suffixBytes.length;
|
||||
const matched = Uint8Array.from(this._rxBuf.slice(0, end));
|
||||
this._rxBuf.splice(0, end);
|
||||
logDebug('readUntilAny matched', JSON.stringify(decodeText(suffixBytes)));
|
||||
return matched;
|
||||
}
|
||||
}
|
||||
const chunk = await this._readStreamChunk(Math.min(300, Math.max(1, deadline - Date.now())));
|
||||
if (chunk && chunk.length) this._appendRx(chunk);
|
||||
else await sleep(5);
|
||||
}
|
||||
const tail = decodeText(Uint8Array.from(this._rxBuf.slice(-240)));
|
||||
throw new Error(
|
||||
`Timed out waiting for one of ${suffixList.map((s) => JSON.stringify(decodeText(s))).join(', ')} (tail: ${JSON.stringify(tail)})`,
|
||||
);
|
||||
}
|
||||
|
||||
async readExact(n, timeoutMs = 10000) {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
while (this._rxBuf.length < n && Date.now() < deadline) {
|
||||
@@ -132,21 +211,38 @@
|
||||
flowControl: 'none',
|
||||
bufferSize: 65536,
|
||||
});
|
||||
if (this.port.setSignals) {
|
||||
try {
|
||||
await this.port.setSignals({ dataTerminalReady: false, requestToSend: false });
|
||||
} catch (_) {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
await deassertDtrRts(this.port);
|
||||
await sleep(50);
|
||||
await deassertDtrRts(this.port);
|
||||
this.reader = this.port.readable.getReader();
|
||||
this.writer = this.port.writable.getWriter();
|
||||
this.portOpen = true;
|
||||
await sleep(100);
|
||||
await this.drainInput(300);
|
||||
await this.collectIncoming(8000);
|
||||
if (bufferIndicatesRuntime(this._rxBuf)) {
|
||||
log('connect: MicroPython output seen during open');
|
||||
}
|
||||
log('connect: port open (raw REPL not entered yet)');
|
||||
}
|
||||
|
||||
/** Read serial into _rxBuf without discarding (for post-open boot). */
|
||||
async collectIncoming(maxMs) {
|
||||
const deadline = Date.now() + maxMs;
|
||||
while (Date.now() < deadline) {
|
||||
const chunk = await this._readStreamChunk(Math.min(80, Math.max(1, deadline - Date.now())));
|
||||
if (chunk && chunk.length) this._appendRx(chunk);
|
||||
else if (
|
||||
bufferHasMicroPython(this._rxBuf) ||
|
||||
bufferEndsWithFriendlyRepl(this._rxBuf) ||
|
||||
bytesIndexOf(this._rxBuf, new TextEncoder().encode(RAW_REPL_BANNER)) >= 0
|
||||
) {
|
||||
break;
|
||||
} else {
|
||||
await sleep(15);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async ensureRawRepl() {
|
||||
if (this.inRawRepl) return;
|
||||
if (this._replLock) {
|
||||
@@ -154,7 +250,8 @@
|
||||
return;
|
||||
}
|
||||
log('ensureRawRepl: entering raw REPL');
|
||||
this._replLock = this.enterRawRepl(true).finally(() => {
|
||||
// USB open already resets the ESP32; skip soft-reset (unlike host led-cli/mpremote).
|
||||
this._replLock = this.enterRawRepl(false).finally(() => {
|
||||
this._replLock = null;
|
||||
});
|
||||
await this._replLock;
|
||||
@@ -193,6 +290,11 @@
|
||||
this.writer = null;
|
||||
}
|
||||
if (this.port) {
|
||||
try {
|
||||
await deassertDtrRts(this.port);
|
||||
} catch (_) {
|
||||
/* ignore */
|
||||
}
|
||||
try {
|
||||
await this.port.close();
|
||||
} catch (_) {
|
||||
@@ -209,41 +311,147 @@
|
||||
await this.writer.write(data);
|
||||
}
|
||||
|
||||
async drainInput(maxMs = 400) {
|
||||
async drainInput(maxMs = 400, clearBuffer = true) {
|
||||
const deadline = Date.now() + maxMs;
|
||||
while (Date.now() < deadline) {
|
||||
const chunk = await this._readStreamChunk(40);
|
||||
if (!chunk || !chunk.length) break;
|
||||
if (!clearBuffer && chunk.length) this._appendRx(chunk);
|
||||
}
|
||||
this._rxBuf.length = 0;
|
||||
if (clearBuffer) this._rxBuf.length = 0;
|
||||
}
|
||||
|
||||
async sendCtrlA() {
|
||||
await this.writeBytes(new Uint8Array([0x0d, 0x01]));
|
||||
}
|
||||
|
||||
/** USB open often resets the ESP32; wait through ROM boot until MicroPython is up. */
|
||||
async waitForMicroPythonBoot(timeoutMs = 60000) {
|
||||
log('enterRawRepl: wait for MicroPython after boot', 'buf', this._rxBuf.length);
|
||||
if (bufferHasMicroPython(this._rxBuf) || bufferEndsWithFriendlyRepl(this._rxBuf)) {
|
||||
log('enterRawRepl: MicroPython already in buffer');
|
||||
return;
|
||||
}
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
let nudgeSent = false;
|
||||
let lastLog = Date.now();
|
||||
while (Date.now() < deadline) {
|
||||
if (bufferHasMicroPython(this._rxBuf) || bufferEndsWithFriendlyRepl(this._rxBuf)) {
|
||||
log('enterRawRepl: MicroPython running');
|
||||
return;
|
||||
}
|
||||
const text = decodeText(Uint8Array.from(this._rxBuf));
|
||||
if (!nudgeSent && text.includes('entry 0x')) {
|
||||
nudgeSent = true;
|
||||
log('enterRawRepl: past ROM loader, nudge REPL');
|
||||
await this.writeBytes(new Uint8Array([0x0d]));
|
||||
await sleep(200);
|
||||
}
|
||||
if (Date.now() - lastLog > 5000) {
|
||||
lastLog = Date.now();
|
||||
log('enterRawRepl: still waiting…', 'buf', this._rxBuf.length);
|
||||
}
|
||||
const rem = Math.max(1, deadline - Date.now());
|
||||
const chunk = await this._readStreamChunk(Math.min(300, rem));
|
||||
if (chunk && chunk.length) this._appendRx(chunk);
|
||||
else await sleep(bufferLooksLikeEspRomBoot(this._rxBuf) ? 20 : 10);
|
||||
}
|
||||
const tail = decodeText(Uint8Array.from(this._rxBuf.slice(-240)));
|
||||
throw new Error(`Timed out waiting for MicroPython boot (tail: ${JSON.stringify(tail)})`);
|
||||
}
|
||||
|
||||
/** Wait until raw REPL banner and `>` prompt (not banner alone — device may still be booting). */
|
||||
async waitRawReplAfterSoftReboot(bannerBytes, bannerPromptBytes, timeoutMs = 15000) {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
while (Date.now() < deadline) {
|
||||
const rem = Math.max(1, deadline - Date.now());
|
||||
const promptAt = bytesIndexOf(this._rxBuf, bannerPromptBytes);
|
||||
if (promptAt >= 0) {
|
||||
this._rxBuf.splice(0, promptAt + bannerPromptBytes.length);
|
||||
log('enterRawRepl: raw REPL prompt ready');
|
||||
return;
|
||||
}
|
||||
if (bufferEndsWithFriendlyRepl(this._rxBuf)) {
|
||||
log('enterRawRepl: friendly REPL, ctrl-A');
|
||||
await this.sendCtrlA();
|
||||
await sleep(30);
|
||||
continue;
|
||||
}
|
||||
if (bufferLooksLikeEspRomBoot(this._rxBuf) && !bufferHasMicroPython(this._rxBuf)) {
|
||||
logDebug('enterRawRepl: ROM boot in progress…');
|
||||
}
|
||||
const chunk = await this._readStreamChunk(Math.min(300, rem));
|
||||
if (chunk && chunk.length) this._appendRx(chunk);
|
||||
else await sleep(10);
|
||||
}
|
||||
const tail = decodeText(Uint8Array.from(this._rxBuf.slice(-240)));
|
||||
throw new Error(
|
||||
`Timed out waiting for raw REPL after soft reboot (tail: ${JSON.stringify(tail)})`,
|
||||
);
|
||||
}
|
||||
|
||||
/** Match mpremote SerialTransport.enter_raw_repl(soft_reset). */
|
||||
async enterRawRepl(softReset = true) {
|
||||
log('enterRawRepl', { softReset });
|
||||
await this.writeBytes(new Uint8Array([0x0d, 0x03]));
|
||||
await this.drainInput(400);
|
||||
await this.writeBytes(new Uint8Array([0x0d, 0x01]));
|
||||
const bannerBytes = new TextEncoder().encode(RAW_REPL_BANNER);
|
||||
const bannerPromptBytes = new TextEncoder().encode(RAW_REPL_BANNER + '>');
|
||||
const softRebootBytes = SOFT_REBOOT_MARKERS.map((m) => new TextEncoder().encode(m));
|
||||
|
||||
if (softReset) {
|
||||
log('enterRawRepl: wait banner+>, soft reset');
|
||||
await this.readUntil(new TextEncoder().encode(RAW_REPL_BANNER + '>'), 15000);
|
||||
await this.writeBytes(new Uint8Array([0x04]));
|
||||
if (
|
||||
!bufferHasMicroPython(this._rxBuf) &&
|
||||
!bufferEndsWithFriendlyRepl(this._rxBuf) &&
|
||||
bytesIndexOf(this._rxBuf, bannerBytes) < 0
|
||||
) {
|
||||
await this.waitForMicroPythonBoot(60000);
|
||||
} else {
|
||||
log('enterRawRepl: already have REPL output in buffer');
|
||||
}
|
||||
|
||||
if (bufferEndsWithFriendlyRepl(this._rxBuf)) {
|
||||
await this.writeBytes(new Uint8Array([0x0d, 0x03]));
|
||||
await sleep(50);
|
||||
}
|
||||
|
||||
let ctrlARetries = 0;
|
||||
const maxCtrlARetries = 3;
|
||||
while (ctrlARetries <= maxCtrlARetries) {
|
||||
try {
|
||||
await this.readUntil(new TextEncoder().encode(SOFT_REBOOT), 15000);
|
||||
log('enterRawRepl: saw soft reboot');
|
||||
await this.sendCtrlA();
|
||||
await this.waitRawReplAfterSoftReboot(bannerBytes, bannerPromptBytes, 20000);
|
||||
break;
|
||||
} catch (e) {
|
||||
log('enterRawRepl: no soft reboot banner', e.message);
|
||||
if (ctrlARetries >= maxCtrlARetries) throw e;
|
||||
ctrlARetries += 1;
|
||||
log('enterRawRepl: retry ctrl-A', ctrlARetries, e.message);
|
||||
this._rxBuf.length = 0;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await this.readUntil(new TextEncoder().encode(RAW_REPL_BANNER), 45000);
|
||||
} catch (e) {
|
||||
log('enterRawRepl: retry ctrl-A after', e.message);
|
||||
await this.writeBytes(new Uint8Array([0x0d, 0x01]));
|
||||
await this.readUntil(new TextEncoder().encode(RAW_REPL_BANNER), 30000);
|
||||
if (softReset) {
|
||||
log('enterRawRepl: soft reset');
|
||||
await this.writeBytes(new Uint8Array([0x04]));
|
||||
try {
|
||||
await this.readUntilAny(softRebootBytes, 20000);
|
||||
log('enterRawRepl: saw soft reboot');
|
||||
this._rxBuf.length = 0;
|
||||
} catch (e) {
|
||||
log('enterRawRepl: no soft reboot banner', e.message);
|
||||
}
|
||||
ctrlARetries = 0;
|
||||
while (ctrlARetries <= maxCtrlARetries) {
|
||||
try {
|
||||
await this.waitRawReplAfterSoftReboot(bannerBytes, bannerPromptBytes, 60000);
|
||||
break;
|
||||
} catch (e) {
|
||||
if (ctrlARetries >= maxCtrlARetries) throw e;
|
||||
ctrlARetries += 1;
|
||||
log('enterRawRepl: post-reboot retry ctrl-A', ctrlARetries, e.message);
|
||||
this._rxBuf.length = 0;
|
||||
await this.sendCtrlA();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.inRawRepl = true;
|
||||
log('enterRawRepl: ready');
|
||||
}
|
||||
@@ -275,9 +483,34 @@
|
||||
await this.readUntil(new Uint8Array([0x04]), 30000);
|
||||
}
|
||||
|
||||
async awaitRawReplExecPrompt(timeoutMs = 60000) {
|
||||
const bannerPromptBytes = new TextEncoder().encode(RAW_REPL_BANNER + '>');
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
while (Date.now() < deadline) {
|
||||
const promptAt = bytesIndexOf(this._rxBuf, bannerPromptBytes);
|
||||
if (promptAt >= 0) {
|
||||
this._rxBuf.splice(0, promptAt + bannerPromptBytes.length);
|
||||
return;
|
||||
}
|
||||
if (bufferEndsWithFriendlyRepl(this._rxBuf)) {
|
||||
log('exec: friendly REPL before prompt, ctrl-A');
|
||||
this._rxBuf.length = 0;
|
||||
await this.sendCtrlA();
|
||||
} else if (bufferLooksLikeEspRomBoot(this._rxBuf)) {
|
||||
logDebug('exec: still in ROM boot, waiting…');
|
||||
}
|
||||
const rem = Math.max(1, deadline - Date.now());
|
||||
const chunk = await this._readStreamChunk(Math.min(300, rem));
|
||||
if (chunk && chunk.length) this._appendRx(chunk);
|
||||
else await sleep(10);
|
||||
}
|
||||
const tail = decodeText(Uint8Array.from(this._rxBuf.slice(-240)));
|
||||
throw new Error(`Timed out waiting for raw REPL prompt (tail: ${JSON.stringify(tail)})`);
|
||||
}
|
||||
|
||||
async execRawNoFollow(commandBytes) {
|
||||
log('execRawNoFollow', commandBytes.length, 'bytes');
|
||||
await this.readUntil(new TextEncoder().encode('>'), 10000);
|
||||
await this.awaitRawReplExecPrompt(60000);
|
||||
|
||||
if (this.useRawPaste) {
|
||||
await this.writeBytes(new Uint8Array([0x05, 0x41, 0x01]));
|
||||
@@ -388,9 +621,33 @@
|
||||
}
|
||||
}
|
||||
|
||||
function serialSupported() {
|
||||
return typeof navigator !== 'undefined' && 'serial' in navigator;
|
||||
}
|
||||
|
||||
async function getGrantedPorts() {
|
||||
if (!serialSupported()) return [];
|
||||
return navigator.serial.getPorts();
|
||||
}
|
||||
|
||||
async function requestPort() {
|
||||
if (!serialSupported()) {
|
||||
throw new Error('Web Serial not supported');
|
||||
}
|
||||
const granted = await navigator.serial.getPorts();
|
||||
if (granted.length === 1) {
|
||||
log('requestPort: reusing single granted port');
|
||||
return granted[0];
|
||||
}
|
||||
log('requestPort: showing picker', { grantedCount: granted.length });
|
||||
return navigator.serial.requestPort({ filters: ESP32_USB_FILTERS });
|
||||
}
|
||||
|
||||
global.LedToolWebSerial = {
|
||||
supported: () => typeof navigator !== 'undefined' && 'serial' in navigator,
|
||||
requestPort: () => navigator.serial.requestPort(),
|
||||
supported: serialSupported,
|
||||
getGrantedPorts,
|
||||
requestPort,
|
||||
MicroPythonRawRepl,
|
||||
_test: { bytesIndexOf, decodeText },
|
||||
};
|
||||
})(typeof window !== 'undefined' ? window : globalThis);
|
||||
|
||||
69
static/web_serial_readuntil_test.mjs
Normal file
69
static/web_serial_readuntil_test.mjs
Normal file
@@ -0,0 +1,69 @@
|
||||
/**
|
||||
* Node regression tests for Web Serial readUntil buffer handling.
|
||||
* Run: node led-tool/static/web_serial_readuntil_test.mjs
|
||||
*/
|
||||
|
||||
function bytesIndexOf(buf, suffix) {
|
||||
if (!suffix.length) return 0;
|
||||
if (buf.length < suffix.length) return -1;
|
||||
for (let i = 0; i <= buf.length - suffix.length; i += 1) {
|
||||
let ok = true;
|
||||
for (let j = 0; j < suffix.length; j += 1) {
|
||||
if (buf[i + j] !== suffix[j]) {
|
||||
ok = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (ok) return i;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
function readUntilConsume(rxBuf, suffixBytes) {
|
||||
const idx = bytesIndexOf(rxBuf, suffixBytes);
|
||||
if (idx < 0) return null;
|
||||
const end = idx + suffixBytes.length;
|
||||
const matched = rxBuf.slice(0, end);
|
||||
rxBuf.splice(0, end);
|
||||
return matched;
|
||||
}
|
||||
|
||||
function enc(s) {
|
||||
return [...new TextEncoder().encode(s)];
|
||||
}
|
||||
|
||||
function assert(cond, msg) {
|
||||
if (!cond) throw new Error(msg);
|
||||
}
|
||||
|
||||
const RAW = 'raw REPL; CTRL-B to exit\r\n';
|
||||
const SOFT = 'soft reboot\r\n';
|
||||
|
||||
// Old bug: one chunk with soft reboot + banner; suffix-at-end + clear-all loses banner.
|
||||
{
|
||||
const rx = enc(`MPY: ${SOFT}${RAW}boot.py line\r\n`);
|
||||
const soft = enc(SOFT);
|
||||
const m1 = readUntilConsume(rx, soft);
|
||||
assert(m1 !== null, 'soft reboot should match');
|
||||
const banner = enc(RAW);
|
||||
const m2 = readUntilConsume(rx, banner);
|
||||
assert(m2 !== null, 'banner must remain in buffer after soft reboot match');
|
||||
assert(rx.length > 0, 'boot.py tail should remain after banner');
|
||||
}
|
||||
|
||||
// Banner anywhere in buffer, not only at end.
|
||||
{
|
||||
const rx = enc(`noise${RAW}>>> `);
|
||||
const banner = enc(RAW);
|
||||
const m = readUntilConsume(rx, banner);
|
||||
assert(m !== null, 'banner should match mid-buffer');
|
||||
}
|
||||
|
||||
// MPY: soft reboot marker
|
||||
{
|
||||
const rx = enc(`MPY: ${SOFT}`);
|
||||
const soft = enc('MPY: soft reboot\r\n');
|
||||
assert(readUntilConsume(rx, soft) !== null, 'MPY soft reboot marker');
|
||||
}
|
||||
|
||||
console.log('web_serial_readuntil_test: ok');
|
||||
Reference in New Issue
Block a user