Skip to content

wasm: WebUSB/WASM CH341A flash programmer support#1

Open
WLTBAgent wants to merge 4 commits into
themactep:masterfrom
WLTBAgent:wasm-clean
Open

wasm: WebUSB/WASM CH341A flash programmer support#1
WLTBAgent wants to merge 4 commits into
themactep:masterfrom
WLTBAgent:wasm-clean

Conversation

@WLTBAgent
Copy link
Copy Markdown

Summary

Adds WebAssembly/WebUSB support so scriba runs in the browser without driver installation. Uses the same C codebase compiled to WASM via Emscripten, with a WebUSB JavaScript shim replacing native libusb.

Changes

Core C changes (src/)

  • ch341a_spi.c: WASM transport path via libusb_bulk_transfer with row-by-row chunked OUT+IN transfers; write-only shortcut with per-chunk IN FIFO drain to prevent endpoint backpressure; enable_pins WASM variant; static buffers for WASM; #ifndef __EMSCRIPTEN__ guards around native async libusb code
  • spi_nor_flash.c: WASM status polling at 100ms intervals (WebUSB latency); 4096-byte read chunking; combined opcode write+response read via ch341a_spi_send_command() to prevent stale IN buffer corruption; progress output suppressed in WASM build
  • flashcmd_api.c: EEPROM #ifdef guards for WASM compatibility

Web frontend (web/)

  • src/web_main.c: C entry point exporting scriba API to JavaScript
  • src/libusb-webusb.js: WebUSB shim implementing libusb API via browser WebUSB
  • src/app.js: UI logic — connect, detect, read, write, erase, verify
  • CMakeLists.txt: Emscripten/Asyncify build configuration
  • index.html, vite.config.js, package.json: Vite-based web app

Docs

  • README.md: Build and deploy instructions for the web version

Testing

Tested with CH341A programmer and GD25Q128CSIG (16MB NOR flash). Chip detection, full chip erase (~60s), 8MB write + verify all pass. CLI version verified unaffected by #ifdef guards.

Josh at WLTechBlog added 2 commits May 25, 2026 16:47
Core changes against original c590b6a:

spi_nor_flash.c:
- snor_wait_ready: WASM polling at 100ms intervals (WebUSB latency)
- snor_read: 4096-byte chunking for WebUSB transfer limits
- snor_read_sr/rg/devid: combine opcode write+response read
  in a single ch341a_spi_send_command() call to prevent
  stale IN buffer corruption
- Suppress per-chunk progress output (#ifndef __EMSCRIPTEN__)

ch341a_spi.c:
- WASM usb_transfer: row-by-row chunked OUT+IN USB transfers
- Write-only shortcut: chunked 32-byte SPI packets with
  per-chunk IN FIFO drain to prevent endpoint backpressure
- enable_pins WASM: 4-byte simple UIO (DIR_ALL_OUTPUT)
- Static buffers (em_safe_wbuf/rbuf) for WASM transport
- #ifndef guards around native async libusb code

flashcmd_api.c:
- EEPROM ifdef guards for WASM build compatibility

Web frontend (web/):
- CMakeLists.txt for emscripten/Asyncify build
- libusb-webusb.js: WebUSB shim replacing native libusb
- app.js, main.js, index.html: web UI
- web_main.c: C entry point for WASM exports
Copy link
Copy Markdown
Owner

@themactep themactep left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review: WASM/WebUSB CH341A flash programmer support


Bug: enable_pins WASM path ignores the "disable" case

File: src/ch341a_spi.cSeverity: Medium

In the WASM path of enable_pins(), the DIR command always sets CH341A_UIO_DIR_ALL_OUTPUT, regardless of the enable parameter:

#ifdef __EMSCRIPTEN__
    CH341A_CMD_UIO_STM_DIR | CH341A_UIO_DIR_ALL_OUTPUT,   // <-- always ALL_OUTPUT
#else
    CH341A_CMD_UIO_STM_DIR | (enable ? CH341A_UIO_DIR_ALL_OUTPUT : CH341A_UIO_DIR_INPUT),
#endif

The native path correctly sets CH341A_UIO_DIR_INPUT when disabling. This means enable_pins(false) (called from ch341a_spi_shutdown()) leaves the CH341A's GPIO pins in output mode instead of high-impedance/input on the WASM build. While the subsequent libusb_close/WebUSB disconnect usually resets device state, chips that maintain pin state across interface release could behave unexpectedly after the programmer is "shut down."


Bug: wasmCall busy-wait has no timeout — can permanently hang the UI

File: web/src/app.js:34-43Severity: Medium

async function wasmCall(name, returnType, argTypes, args) {
    while (wasmBusy) {
        await new Promise(function(r) { setTimeout(r, 50); });
    }

If a prior WASM operation hangs (USB timeout, device disconnect mid-operation, etc.), wasmBusy stays true forever. All subsequent operations silently spin forever — the UI becomes permanently non-functional with no indication to the user. The USB disconnect listener only logs to console.

A timeout on the spin-lock OR clearing wasmBusy in the disconnect handler would prevent this.


Bug: snor_wait_ready WASM timeout is dramatically shorter than native

File: src/spi_nor_flash.c:84-105Severity: Low-Medium (depends on flash chip)

Native path: (sleep_ms + 1) * 1000 iterations × 500µs = up to ~500×(sleep_ms+1) ms.
WASM path: (sleep_ms < 100 ? 10 : sleep_ms + 30) iterations × 100ms = up to 100×(sleep_ms+30) ms max.

For sleep_ms=950 (used in snor_erase_sector):

  • Native: ~475 seconds max wait
  • WASM: ~98 seconds max wait

This means erase operations in the browser may time out on slower chips. The 100ms sleep intervals are reasonable for WebUSB but the iteration count should compensate to match the native timeout window.


Bug: snor_read WASM 4096-byte chunking has fragile null-check for len

File: src/spi_nor_flash.c:1110-1155

In the WASM read chunking, len is set to -1 (i.e., 0xFFFFFFFF) on SPI read failure, and the code has:

if (len == (unsigned long)-1) break;

But this check is inside the else block (sector-boundary case), and in the non-boundary case the chunk_remain loop continues even after len is set to -1. The outer while(remain_len > 0) loop will iterate with remain_len potentially still > 0, but subsequent SPI_CONTROLLER_Read_NByte calls will pass a bogus buffer offset. It works out because the read function will likely also fail, but the control flow is fragile.


Weak erase verification

File: web/src/app.js:491-530Severity: Low

The doErase function only verifies the first 256 bytes after erasing. If a chip has bad sectors that fail to erase at higher addresses, the verification passes and the user gets a false "success" message.


Minor: Redundant extern declaration

File: src/ch341a_spi.c:359 and src/ch341a_spi.c:452

extern int usb_clear_halt(void *handle_ptr, int endpoint); appears twice in the same file.


Minor: Dead else if in write verification

File: web/src/app.js:434-436

if (verifyOk) log('Verify: OK - data matches');
else if (verifyOk === false && verifyOffset >= writeLen) {
    /* already logged */
}

The else if condition can never be true — when verifyOk is set to false inside the loop, execution always breaks without incrementing verifyOffset to writeLen.


Minor: package-lock.json in the diff

The PR includes a 1088-line package-lock.json addition. Lockfiles should generally be committed, but they're noisy in reviews. If not already done, consider committing this separately or calling it out in the PR description.

Josh at WLTechBlog added 2 commits May 25, 2026 18:17
- enable_pins WASM: use DIR_INPUT on disable (matching native path)
- snor_wait_ready: increase WASM timeout iterations to match native
  (sleep_ms*5+5 instead of sleep_ms+30, ~475s at sleep_ms=950)
- wasmCall: add 60s busy-wait timeout, clear wasmBusy on USB disconnect
- snor_read: clean up ifdef nesting for clarity (no functional change)
- erase verification: check 64KB instead of 256 bytes
- Remove duplicate extern usb_clear_halt declarations
- Remove dead else-if in write verification
- JS: if erase completes in < 3 seconds, show an error telling user
  to re-seat the flash chip and reload the page
- C: in full_erase_chip, verify WEL is set after WREN before sending
  BE command. If WEL not set, reinit CH341A and retry WREN (matching
  the pattern already used in snor_erase_sector)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants