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`, …) |
|
| `-o`, `--order` | LED colour order (`rgb`, `grb`, …) |
|
||||||
| `--preset` / `--pattern` | Create or replace a named preset in **led-driver** `presets.json` |
|
| `--preset` / `--pattern` | Create or replace a named preset in **led-driver** `presets.json` |
|
||||||
| `--default` | Startup preset name |
|
| `--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 |
|
| `--ssid`, `--wifi-password`, `--wifi-channel` | Wi-Fi / channel fields for the driver |
|
||||||
| `-r`, `--reset` | Reset the device |
|
| `-r`, `--reset` | Reset the device |
|
||||||
| `-f`, `--follow` | Follow serial output (optional timeout seconds) |
|
| `-f`, `--follow` | Follow serial output (optional timeout seconds) |
|
||||||
| `--pause` | Sleep N seconds (for chained actions) |
|
| `--pause` | Sleep N seconds (for chained actions) |
|
||||||
| `-u`, `--upload` | Recursive upload: `-u SRC [DEST]` (skips unchanged files via `file_hashes.json` on device) |
|
| `-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` |
|
| `--force-upload` | Upload every file; ignore `file_hashes.json` |
|
||||||
| `-e`, `--erase` | Erase everything at device root (including `settings.json` and `presets.json`) |
|
| `-e`, `--erase` | Erase everything at device root (including `settings.json` and `presets.json`) |
|
||||||
| `--rm` | Remove a path on the device |
|
| `--rm` | Remove a path on the device |
|
||||||
@@ -51,7 +52,7 @@ python web.py
|
|||||||
# open http://<host>:5000/editor
|
# 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.
|
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',
|
'-p', '--port', '-n', '--name', '--pin', '-b', '--brightness',
|
||||||
'-l', '--leds', '-d', '-debug', '--debug', '-o', '--order',
|
'-l', '--leds', '-d', '-debug', '--debug', '-o', '--order',
|
||||||
'--preset', '--pattern', '--default', '--transport', '--ssid',
|
'--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)
|
# Reset logical device name to firmware default (STA MAC based)
|
||||||
%(prog)s --reset-device-name
|
%(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",
|
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(
|
parser.add_argument(
|
||||||
"--ssid",
|
"--ssid",
|
||||||
help="led-driver ssid (Wi-Fi network in wifi mode)",
|
help="led-driver ssid (Wi-Fi network in wifi mode)",
|
||||||
@@ -747,6 +758,18 @@ Examples:
|
|||||||
if args.transport is not None:
|
if args.transport is not None:
|
||||||
edits["transport_type"] = args.transport
|
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:
|
if args.ssid is not None:
|
||||||
edits["ssid"] = args.ssid
|
edits["ssid"] = args.ssid
|
||||||
|
|
||||||
|
|||||||
@@ -155,6 +155,14 @@
|
|||||||
<label for="wifi_channel">WiFi channel</label>
|
<label for="wifi_channel">WiFi channel</label>
|
||||||
<input id="wifi_channel" data-setting="wifi_channel" type="number" min="1" max="11" />
|
<input id="wifi_channel" data-setting="wifi_channel" type="number" min="1" max="11" />
|
||||||
</div>
|
</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>
|
<div>
|
||||||
<label for="default">Default preset</label>
|
<label for="default">Default preset</label>
|
||||||
<input id="default" data-setting="default" type="text" />
|
<input id="default" data-setting="default" type="text" />
|
||||||
@@ -185,10 +193,11 @@
|
|||||||
document.body.getAttribute('data-api-base') ??
|
document.body.getAttribute('data-api-base') ??
|
||||||
(location.pathname.includes('/led-tool') ? '/led-tool' : '');
|
(location.pathname.includes('/led-tool') ? '/led-tool' : '');
|
||||||
const prefix = api + '/static';
|
const prefix = api + '/static';
|
||||||
|
const v = '20260520';
|
||||||
function loadScript(url) {
|
function loadScript(url) {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const s = document.createElement('script');
|
const s = document.createElement('script');
|
||||||
s.src = url;
|
s.src = url + (url.includes('?') ? '&' : '?') + 'v=' + v;
|
||||||
s.onload = () => resolve();
|
s.onload = () => resolve();
|
||||||
s.onerror = () => reject(new Error('Failed to load ' + url));
|
s.onerror = () => reject(new Error('Failed to load ' + url));
|
||||||
document.body.appendChild(s);
|
document.body.appendChild(s);
|
||||||
|
|||||||
@@ -24,8 +24,10 @@
|
|||||||
let hostConnected = false;
|
let hostConnected = false;
|
||||||
let hostConnectedPort = '';
|
let hostConnectedPort = '';
|
||||||
let transferBusy = false;
|
let transferBusy = false;
|
||||||
|
let webConnectBusy = false;
|
||||||
|
|
||||||
const webSerialOk = window.LedToolWebSerial && window.LedToolWebSerial.supported();
|
const webSerialOk = window.LedToolWebSerial && window.LedToolWebSerial.supported();
|
||||||
|
let webSerialGrantedCount = 0;
|
||||||
|
|
||||||
function setStatus(text, isError) {
|
function setStatus(text, isError) {
|
||||||
if (!statusEl) return;
|
if (!statusEl) return;
|
||||||
@@ -40,6 +42,17 @@
|
|||||||
setTimeout(() => messageEl.classList.remove('show'), 5000);
|
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() {
|
function formToObject() {
|
||||||
const out = {};
|
const out = {};
|
||||||
document.querySelectorAll('[data-setting]').forEach((el) => {
|
document.querySelectorAll('[data-setting]').forEach((el) => {
|
||||||
@@ -99,7 +112,11 @@
|
|||||||
} else if (hostOpen) {
|
} else if (hostOpen) {
|
||||||
setStatus(`Host serial: ${hostConnectedPort} (use Download or Upload).`);
|
setStatus(`Host serial: ${hostConnectedPort} (use Download or Upload).`);
|
||||||
} else if (webSerialOk) {
|
} 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 {
|
} else {
|
||||||
setStatus(`Connect host serial (default ${DEFAULT_HOST_PORT}).`);
|
setStatus(`Connect host serial (default ${DEFAULT_HOST_PORT}).`);
|
||||||
}
|
}
|
||||||
@@ -205,21 +222,31 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function connectWebSerial() {
|
async function connectWebSerial() {
|
||||||
|
if (webConnectBusy) return;
|
||||||
if (!window.LedToolWebSerial || !window.LedToolWebSerial.supported()) {
|
if (!window.LedToolWebSerial || !window.LedToolWebSerial.supported()) {
|
||||||
flash('Web Serial not supported (use Chrome or Edge over HTTPS or localhost).', true);
|
flash('Web Serial not supported (use Chrome or Edge over HTTPS or localhost).', true);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (usingWebSerial()) {
|
||||||
|
flash('USB already connected.', false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (usingHostSerial()) {
|
if (usingHostSerial()) {
|
||||||
disconnectHostSerial();
|
disconnectHostSerial();
|
||||||
}
|
}
|
||||||
log('connectWebSerial: requestPort');
|
webConnectBusy = true;
|
||||||
setStatus('Opening USB port…');
|
try {
|
||||||
webPort = await window.LedToolWebSerial.requestPort();
|
log('connectWebSerial: requestPort');
|
||||||
log('connectWebSerial: port selected', webPort);
|
setStatus('Opening USB port…');
|
||||||
webClient = new window.LedToolWebSerial.MicroPythonRawRepl();
|
webPort = await window.LedToolWebSerial.requestPort();
|
||||||
await webClient.connect(webPort);
|
log('connectWebSerial: port selected', webPort);
|
||||||
updateUi();
|
webClient = new window.LedToolWebSerial.MicroPythonRawRepl();
|
||||||
flash('USB port open. Use Download or Upload to talk to the device.', false);
|
await webClient.connect(webPort);
|
||||||
|
updateUi();
|
||||||
|
flash('USB port open. Wait for boot, then Download.', false);
|
||||||
|
} finally {
|
||||||
|
webConnectBusy = false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function disconnectWebSerial() {
|
async function disconnectWebSerial() {
|
||||||
@@ -284,9 +311,10 @@
|
|||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(LOG, 'download failed', e);
|
console.error(LOG, 'download failed', e);
|
||||||
if (rawJson) rawJson.value = String(e.message || e);
|
const msg = usingWebSerial() ? onWebSerialTransferError(e) : errorMessage(e);
|
||||||
flash(e.message, true);
|
if (rawJson) rawJson.value = msg;
|
||||||
setStatus(e.message, true);
|
flash(msg, true);
|
||||||
|
setStatus(msg, true);
|
||||||
} finally {
|
} finally {
|
||||||
transferBusy = false;
|
transferBusy = false;
|
||||||
}
|
}
|
||||||
@@ -310,8 +338,9 @@
|
|||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(LOG, 'upload failed', e);
|
console.error(LOG, 'upload failed', e);
|
||||||
flash(e.message, true);
|
const msg = usingWebSerial() ? onWebSerialTransferError(e) : errorMessage(e);
|
||||||
setStatus(e.message, true);
|
flash(msg, true);
|
||||||
|
setStatus(msg, true);
|
||||||
} finally {
|
} finally {
|
||||||
transferBusy = false;
|
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 });
|
log('settings editor ready', { apiBase, webSerialOk });
|
||||||
updateUi();
|
updateUi();
|
||||||
loadHostPorts();
|
loadHostPorts();
|
||||||
|
refreshWebSerialGrantHint();
|
||||||
})();
|
})();
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
*/
|
*/
|
||||||
(function (global) {
|
(function (global) {
|
||||||
const RAW_REPL_BANNER = 'raw REPL; CTRL-B to exit\r\n';
|
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]';
|
const LOG = '[led-tool/serial]';
|
||||||
|
|
||||||
function log(...args) {
|
function log(...args) {
|
||||||
@@ -29,12 +29,65 @@
|
|||||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
}
|
}
|
||||||
|
|
||||||
function bytesEndsWith(buf, suffix) {
|
/** Common ESP32 USB-UART bridges (usbVendorId only per Web Serial API). */
|
||||||
if (buf.length < suffix.length) return false;
|
const ESP32_USB_FILTERS = [
|
||||||
for (let i = 0; i < suffix.length; i += 1) {
|
{ usbVendorId: 0x303a }, /* Espressif */
|
||||||
if (buf[buf.length - suffix.length + i] !== suffix[i]) return false;
|
{ 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) {
|
function decodeText(u8) {
|
||||||
@@ -84,12 +137,15 @@
|
|||||||
return result.value || null;
|
return result.value || null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Match suffix anywhere in the buffer; keep bytes after the match for the next read. */
|
||||||
async readUntil(suffixBytes, timeoutMs = 15000) {
|
async readUntil(suffixBytes, timeoutMs = 15000) {
|
||||||
const deadline = Date.now() + timeoutMs;
|
const deadline = Date.now() + timeoutMs;
|
||||||
while (Date.now() < deadline) {
|
while (Date.now() < deadline) {
|
||||||
if (bytesEndsWith(this._rxBuf, suffixBytes)) {
|
const idx = bytesIndexOf(this._rxBuf, suffixBytes);
|
||||||
const matched = Uint8Array.from(this._rxBuf);
|
if (idx >= 0) {
|
||||||
this._rxBuf.length = 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);
|
logDebug('readUntil matched', JSON.stringify(decodeText(suffixBytes)), 'len', matched.length);
|
||||||
return matched;
|
return matched;
|
||||||
}
|
}
|
||||||
@@ -106,6 +162,29 @@
|
|||||||
throw new Error(err);
|
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) {
|
async readExact(n, timeoutMs = 10000) {
|
||||||
const deadline = Date.now() + timeoutMs;
|
const deadline = Date.now() + timeoutMs;
|
||||||
while (this._rxBuf.length < n && Date.now() < deadline) {
|
while (this._rxBuf.length < n && Date.now() < deadline) {
|
||||||
@@ -132,21 +211,38 @@
|
|||||||
flowControl: 'none',
|
flowControl: 'none',
|
||||||
bufferSize: 65536,
|
bufferSize: 65536,
|
||||||
});
|
});
|
||||||
if (this.port.setSignals) {
|
await deassertDtrRts(this.port);
|
||||||
try {
|
await sleep(50);
|
||||||
await this.port.setSignals({ dataTerminalReady: false, requestToSend: false });
|
await deassertDtrRts(this.port);
|
||||||
} catch (_) {
|
|
||||||
/* ignore */
|
|
||||||
}
|
|
||||||
}
|
|
||||||
this.reader = this.port.readable.getReader();
|
this.reader = this.port.readable.getReader();
|
||||||
this.writer = this.port.writable.getWriter();
|
this.writer = this.port.writable.getWriter();
|
||||||
this.portOpen = true;
|
this.portOpen = true;
|
||||||
await sleep(100);
|
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)');
|
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() {
|
async ensureRawRepl() {
|
||||||
if (this.inRawRepl) return;
|
if (this.inRawRepl) return;
|
||||||
if (this._replLock) {
|
if (this._replLock) {
|
||||||
@@ -154,7 +250,8 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
log('ensureRawRepl: entering raw REPL');
|
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;
|
this._replLock = null;
|
||||||
});
|
});
|
||||||
await this._replLock;
|
await this._replLock;
|
||||||
@@ -193,6 +290,11 @@
|
|||||||
this.writer = null;
|
this.writer = null;
|
||||||
}
|
}
|
||||||
if (this.port) {
|
if (this.port) {
|
||||||
|
try {
|
||||||
|
await deassertDtrRts(this.port);
|
||||||
|
} catch (_) {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
await this.port.close();
|
await this.port.close();
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
@@ -209,41 +311,147 @@
|
|||||||
await this.writer.write(data);
|
await this.writer.write(data);
|
||||||
}
|
}
|
||||||
|
|
||||||
async drainInput(maxMs = 400) {
|
async drainInput(maxMs = 400, clearBuffer = true) {
|
||||||
const deadline = Date.now() + maxMs;
|
const deadline = Date.now() + maxMs;
|
||||||
while (Date.now() < deadline) {
|
while (Date.now() < deadline) {
|
||||||
const chunk = await this._readStreamChunk(40);
|
const chunk = await this._readStreamChunk(40);
|
||||||
if (!chunk || !chunk.length) break;
|
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). */
|
/** Match mpremote SerialTransport.enter_raw_repl(soft_reset). */
|
||||||
async enterRawRepl(softReset = true) {
|
async enterRawRepl(softReset = true) {
|
||||||
log('enterRawRepl', { softReset });
|
log('enterRawRepl', { softReset });
|
||||||
await this.writeBytes(new Uint8Array([0x0d, 0x03]));
|
const bannerBytes = new TextEncoder().encode(RAW_REPL_BANNER);
|
||||||
await this.drainInput(400);
|
const bannerPromptBytes = new TextEncoder().encode(RAW_REPL_BANNER + '>');
|
||||||
await this.writeBytes(new Uint8Array([0x0d, 0x01]));
|
const softRebootBytes = SOFT_REBOOT_MARKERS.map((m) => new TextEncoder().encode(m));
|
||||||
|
|
||||||
if (softReset) {
|
if (
|
||||||
log('enterRawRepl: wait banner+>, soft reset');
|
!bufferHasMicroPython(this._rxBuf) &&
|
||||||
await this.readUntil(new TextEncoder().encode(RAW_REPL_BANNER + '>'), 15000);
|
!bufferEndsWithFriendlyRepl(this._rxBuf) &&
|
||||||
await this.writeBytes(new Uint8Array([0x04]));
|
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 {
|
try {
|
||||||
await this.readUntil(new TextEncoder().encode(SOFT_REBOOT), 15000);
|
await this.sendCtrlA();
|
||||||
log('enterRawRepl: saw soft reboot');
|
await this.waitRawReplAfterSoftReboot(bannerBytes, bannerPromptBytes, 20000);
|
||||||
|
break;
|
||||||
} catch (e) {
|
} 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 {
|
if (softReset) {
|
||||||
await this.readUntil(new TextEncoder().encode(RAW_REPL_BANNER), 45000);
|
log('enterRawRepl: soft reset');
|
||||||
} catch (e) {
|
await this.writeBytes(new Uint8Array([0x04]));
|
||||||
log('enterRawRepl: retry ctrl-A after', e.message);
|
try {
|
||||||
await this.writeBytes(new Uint8Array([0x0d, 0x01]));
|
await this.readUntilAny(softRebootBytes, 20000);
|
||||||
await this.readUntil(new TextEncoder().encode(RAW_REPL_BANNER), 30000);
|
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;
|
this.inRawRepl = true;
|
||||||
log('enterRawRepl: ready');
|
log('enterRawRepl: ready');
|
||||||
}
|
}
|
||||||
@@ -275,9 +483,34 @@
|
|||||||
await this.readUntil(new Uint8Array([0x04]), 30000);
|
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) {
|
async execRawNoFollow(commandBytes) {
|
||||||
log('execRawNoFollow', commandBytes.length, 'bytes');
|
log('execRawNoFollow', commandBytes.length, 'bytes');
|
||||||
await this.readUntil(new TextEncoder().encode('>'), 10000);
|
await this.awaitRawReplExecPrompt(60000);
|
||||||
|
|
||||||
if (this.useRawPaste) {
|
if (this.useRawPaste) {
|
||||||
await this.writeBytes(new Uint8Array([0x05, 0x41, 0x01]));
|
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 = {
|
global.LedToolWebSerial = {
|
||||||
supported: () => typeof navigator !== 'undefined' && 'serial' in navigator,
|
supported: serialSupported,
|
||||||
requestPort: () => navigator.serial.requestPort(),
|
getGrantedPorts,
|
||||||
|
requestPort,
|
||||||
MicroPythonRawRepl,
|
MicroPythonRawRepl,
|
||||||
|
_test: { bytesIndexOf, decodeText },
|
||||||
};
|
};
|
||||||
})(typeof window !== 'undefined' ? window : globalThis);
|
})(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