A direct reference for AI coding agents working on Scriba. No fluff. No hedging.
Scriba is a flash programming tool for IP camera chips. It talks SPI NOR, SPI NAND, and EEPROM through cheap USB programmers (CH341A, CH347, EZP2019). Two targets:
- CLI — native C binary,
gcc+libusb-1.0, runs on Linux. - Web — same C code compiled to WASM via Emscripten, WebUSB bridge in JS, Bootstrap UI, Vite bundler. Runs in Chromium-based browsers.
Origin: forked from SNANDer by McMCC,
modified by Droid-MAX, heavily reworked by Paul Philippov (themactep).
License: GPL-2.0-or-later.
┌──────────────────────────────────────────────────┐
│ main.c (CLI) web_main.c (WASM API) │ ← entry points
├──────────────────────────────────────────────────┤
│ flashcmd_api.c — chip-type dispatch │ ← picks NOR/NAND/EEPROM
├──────────────────┬───────────────┬───────────────┤
│ spi_nor_flash │ spi_nand_flash│ *eeprom.c │ ← chip families
│ │ + protocol │ + microwire │
│ │ + tables │ │
├──────────────────┴───────────────┴───────────────┤
│ spi_controller.c — programmer dispatch │ ← CS, read/write bytes
├────────────────┬────────────────┬────────────────┤
│ ch341a_spi.c │ ch347_spi.c │ ezp2019_spi.c │ ← USB programmer drivers
│ libusb-1.0 │ libusb-1.0 │ libusb-1.0 │
└────────────────┴────────────────┴────────────────┘
| File | Role |
|---|---|
src/main.c |
CLI: getopt_long, dispatch to flashcmd_api, do_verify helper |
web/src/web_main.c |
WASM: flat C API (scriba_read_flash, etc.), no CLI parsing |
Both use the same struct flash_cmd interface — flash_read / flash_write /
flash_erase function pointers set by flash_cmd_init().
struct flash_cmd {
int (*flash_read)(unsigned char *buf, unsigned long from, unsigned long len);
int (*flash_erase)(unsigned long offs, unsigned long len);
int (*flash_write)(unsigned char *buf, unsigned long to, unsigned long len);
};flash_cmd_init() probes in order:
- SPI NOR (
snor_init) — if it returns >0 bytes - SPI NAND (
snand_init) — fallback - EEPROM variants (I2C, Microwire, SPI) — if
-Ewas given on CLI
Whichever succeeds first fills in the cmd struct and the caller never knows
which flash type is underneath.
Each driver exports one const struct spi_programmer (in spi_controller.h):
init, shutdown, send_command, cs_select, cs_deselect,
libusb_version, and an optional reinit (WASM recovery after a stall, and
re-applying clock/options).
spi_controller.c keeps a table of them; spi_controller_init(type) picks
one, and every SPI_CONTROLLER_* call goes through the active vtable. Types:
PROGRAMMER_CH341A = 0PROGRAMMER_EZP2019 = 1PROGRAMMER_AUTO = 2(probes EZP2019, then CH347, then CH341A)PROGRAMMER_CH347 = 3(appended so the older values stay stable)
The flash layers call SPI_CONTROLLER_* functions, never the driver
functions directly. CS is separate from send_command: one flash command is
cs_select, one or more send_command calls, cs_deselect.
spi_controller_set_speed_khz() (CLI --spi-speed) records a requested SPI
clock before init; only drivers with a hardware clock (CH347) read it.
spi_controller_set_options() takes SPI_OPT_* flags;
SPI_OPT_NO_CS_FLAGS (CLI --no-cs-flags, web "Fast CS" unticked) makes
the CH347 driver use separate chip-select commands.
The CH347 driver holds writes back while CS is low and sends them as one
frame (CS high, a following read, or a full frame flushes them), and folds a
short read after held writes into one full-duplex 0xC2 frame. So a call
returning success does not mean the bytes are on the wire yet; a USB error
sets a sticky failure that fails every later call until reinit. On CH347T
firmware 0x0341+ or a CH347F (version from the 0xCA chip-info read) it
also defers cs_select() and carries CS in 0xC2 length flags (bit 7 =
assert before, bit 6 = release after), as WCH's Linux driver does in its
default mode. Nothing is held past cs_deselect().
Sending several commands in one USB transfer does NOT work: CH347T firmware
0544 answers with an error and drops what follows (tested 2026-11). WCH's
"quick speed" mode merges SPI transfers into one 0xC2 command instead.
- ch341a_spi.c (26 KB, most complex): bit-bangs SPI over CH341A USB-I2C/SPI
bridge IC. Also has I2C (
ch341a_i2c.c), GPIO (ch341a_gpio.c), and Microwire (bitbang_microwire.c) support for EEPROM variants. Its USB handle is a global namedhandlethat those helpers use directly. - ch347_spi.c: WCH CH347T (mode 1,
1a86:55db, interface 2) and CH347F (1a86:55de, interface 4). Hardware SPI master with framed bulk commands on EP 0x06/0x86:0xC0config,0xC1CS,0xC4write (4-byte status reply),0xC3read ([C3 len data]frames). Read frames are 510 bytes on older firmware and up to 4 KB on newer firmware, so the driver parses IN data as a stream of frames and always requests whole 512-byte packets (no overflow) that the device is certain to send (no stall). Interface 0 is the UART; only the SPI interface is claimed. I2C/Microwire EEPROM is CH341A-only for now (main.crefuses-Efor those on a CH347). - ezp2019_spi.c (22 KB): packet protocol over bulk USB endpoints for the EZP2019/2023 programmer family. Simpler than CH341A — dedicated SPI commands.
Several globals cross-cut the codebase. All defined in main.c and declared
extern where needed:
| Variable | File | Meaning |
|---|---|---|
active, active_type |
spi_controller.c |
Active programmer vtable and its type |
debug_enabled, trace_enabled |
main.c |
USB/SPI debug logging |
bsize |
spi_nor_flash.c |
Flash block erase size (set at init) |
ECC_fcheck, ECC_ignore, OOB_size, Skip_BAD_page, _ondie_ecc_flag |
NAND subsystem | ECC control flags |
eepromsize, mw_eepromsize, seepromsize, spage_size, org, fix_addr_len |
EEPROM subsystem | EEPROM parameters |
spi_chip_info |
NOR subsystem | Pointer to detected chip's table entry |
No global mutex. Single-threaded. No reentrancy concerns for the CLI, but the WASM build uses ASYNCIFY (Emscripten) — libusb calls are async-suspended.
src/
├── main.c CLI entry, option parsing, operation dispatch
├── flashcmd_api.c / .h Flash-type detection, cmd struct init
├── flash_layout.c / .h Partition layout of an image: mtdparts + header scan
├── spi_controller.c / .h Programmer abstraction, CS, byte r/w
├── ch341a_spi.c / .h CH341A USB-SPI driver
├── ch341a_i2c.c / .h CH341A I2C bit-bang (EEPROM)
├── ch341a_gpio.c / .h CH341A GPIO helpers
├── ch347_spi.c / .h CH347T/CH347F USB-SPI driver
├── ezp2019_spi.c / .h EZP2019/2023 USB-SPI driver
├── spi_nor_flash.c / .h SPI NOR commands, chip table, erase/write
├── spi_nand_flash.c / .h SPI NAND core
├── spi_nand_flash_protocol.c NAND protocol layer
├── spi_nand_flash_tables.c NAND chip ID table (73 KB — largest file)
├── spi_nand_flash_defs.h NAND command/register definitions
├── spi_nand_flash_feature.h NAND feature register helpers
├── timer.c / .h Progress display, elapsed time
├── types.h u8/u16/u32/u64 typedefs
├── i2c_eeprom.c I2C EEPROM support
├── spi_eeprom.c / .h SPI EEPROM support
├── mw_eeprom.c Microwire EEPROM support
├── bitbang_microwire.c / .h Microwire 3-wire bit-bang
├── 40-persistent-ch341a.rules udev rules
├── 40-persistent-ch347.rules
└── 40-persistent-ezp2019.rules
web/
├── index.html Single-page UI (Bootstrap dark theme)
├── src/
│ ├── main.js Vite entry, module loader
│ ├── app.js UI logic, WASM binding, operations
│ ├── web_main.c C entry for WASM (flat API)
│ └── libusb-webusb.js JS reimplementation of libusb API over WebUSB
├── libusb-stub/libusb-1.0/ Stub libusb headers for WASM build
├── Makefile Emscripten build
├── build.sh One-shot build script
├── package.json Vite + Bootstrap
└── vite.config.js Outputs to web/dist/
make # dynamic linking (libusb shared)
make static # downloads + builds libusb from source, static link
sudo make install # binary → /usr/bin, udev rules → /etc/udev/rules.dCFLAGS: -std=gnu99 -Wall -O2 -D_FILE_OFFSET_BITS=64. No -Wextra in the
default Makefile (PHASE1_SUMMARY claims it passes, but the Makefile doesn't
set it — if you're adding warnings, add them to the Makefile, not just the
CLI invocation).
make static downloads libusb 1.0.30 tarball into dl/, builds it into
build/usr/, and links statically. The CONFIG_STATIC define gates the
difference in title() output and libusb include path.
cd web
./build.sh # emcmake + npm install + vite build, output to dist/
# or for dev:
npm run dev # Vite dev server on localhost:5173The WASM build uses the same .c files as CLI except main.c → web_main.c.
The libusb-webusb.js is a JS shim that implements libusb API on top of
WebUSB (browsers can't do raw USB). ASYNCIFY=1 is on because WebUSB calls
are inherently async.
Exported WASM functions: scriba_init, scriba_detect_chip, scriba_read_flash,
scriba_write_flash, scriba_erase_flash, scriba_shutdown, scriba_get_version,
etc. See the EXPORTS list in web/Makefile for the full set.
The flash_cmd struct is the only vtable-like pattern. It's filled once at
init based on chip probe, then the CLI/WASM layer calls through it. Clean.
Don't break this abstraction by casting to concrete types in main/web_main.
spi_controller.c holds a programmers[] table of {type, vtable} pairs in
auto-probe order; spi_controller_init() walks it. Adding a programmer is
one table row plus the driver file — see "Adding a new programmer" below.
The PHASE1 work added proper malloc return checks. Every malloc in main.c
has a corresponding free you can see within 20 lines. The convention:
free(buf); goto out;on errorgoto okout;on successout:label does programmer shutdown +return 1okout:label does programmer shutdown +return 0
The flash layers (spi_nor_flash.c, spi_nand_flash.c) do their own allocation
with less rigorous checking. Audit those before claiming "no leaks."
- NOR: static array in
spi_nor_flash.c, each entry has JEDEC ID, name, size, erase block size, supported opcodes. - NAND: massive array in
spi_nand_flash_tables.c(73 KB), each entry has manufacturer ID, page size, OOB size, ECC info.
Detection is linear scan through the table matching read ID bytes. qsort was proposed in OPTIMIZATION_PLAN.md but not implemented — the tables are small enough that linear scan is fine.
- Add entry to the appropriate table:
- NOR:
spi_nor_flash.c— find the rightstruct SPI_NOR_FLASH_INFO_Tarray - NAND:
spi_nand_flash_tables.c— find the rightSPI_NAND_FLASH_INFO_TABLEarray
- NOR:
- Match by JEDEC ID or NAND manufacturer+device ID.
- Verify
-idetects it,-Rreads it correctly,-Wwrites + verifies. - Test on actual hardware. Datasheets are the only truth — not what some other chip in the same family does.
ch347_spi.c is the most recent worked example. Steps:
- Write
src/<name>_spi.cexporting aconst struct spi_programmer(keep the USB handlestatic;handleis already taken by the CH341A). - Append a
PROGRAMMER_*value inspi_controller.hand a row inprogrammers[]inspi_controller.c. main.c:-Pname, not-found message, usage text.- Add the source to
SRCSin bothMakefileandweb/Makefile. - Add a udev rules file and install it from
install-rules. - Web: add the VID:PID to the
requestDevicefilters inweb/src/app.jsand toSCRIBA_PIDSinweb/src/libusb-webusb.js, and the name to theprogNamesmap inapp.js. - Wire up in
flashcmd_api.cif needed (usually not — flashcmd only cares about the chip, not the programmer).
Everything is in main.c. The getopt_long block handles option parsing.
The op variable controls which operation runs. Adding a new operation
means:
- New case in
getopt_long - New
if (op == 'X')block before ther || wblock - Respect the
out:/okout:label pattern for cleanup
- UI:
web/index.html(Bootstrap 5, dark theme) +web/src/app.js - WASM bridge:
web/src/web_main.cfor C side,web/src/libusb-webusb.jsfor USB shim - The JS app calls
Module.ccallorModule._scriba_*to invoke WASM
Despite PHASE1_SUMMARY claiming "compiles with -Wall -Wextra without warnings,"
the Makefile only sets -Wall. If you add -Wextra, expect a burst of warnings
from the legacy code. Fix them if you touch the file, but don't add -Wextra
to the default build without a plan.
Adding threading or reentrancy would require gutting programmer_type,
debug_enabled, bsize, and the NAND global flags. Don't.
The WebUSB shim (libusb-webusb.js) returns Promises, but Emscripten's
ASYNCIFY makes the C code wait synchronously. If you change the shim, you
must keep ASYNCIFY_IMPORTS in web/Makefile in sync with all async
functions. Missing one = runtime hang.
The WASM build uses stub headers in web/libusb-stub/libusb-1.0/ — these
declare libusb functions but the actual implementation is in
web/src/libusb-webusb.js. The stubs must match the JS implementation's
signatures. libusb version skew here will cause silent failures.
#ifdef EEPROM_SUPPORT guards EEPROM files. Makefile sets
EEPROM_SUPPORT = yes and adds the source files + -DEEPROM_SUPPORT.
The web Makefile does NOT include EEPROM source files — WASM build
is SPI NOR/NAND only.
PHASE1 replaced byte-by-byte file comparison with memcmp(). The standalone
flashcmd_verify() function in flashcmd_api.c also uses memcmp, but
do_verify() in main.c reimplements the same pattern. Consolidating these
was on the PHASE2 plan but didn't happen. If you touch verification, use
do_verify() as the canonical pattern — it allocates, checks, frees, and
handles the NAND case correctly.
If a programmer isn't detected, check dmesg for USB permissions. The udev
rules grant 0664 access. Replug the device after install.
The test scripts (test_suite.sh, test_advanced.sh) and docs reference
paths from an earlier checkout. The scripts assume ./scriba is in CWD.
Review before running.
The black-PCB CH341A ships with 5V on the data lines. Flash chips expect 3.3V. Users must physically modify the board. The tool can't detect this — it'll just silently fry chips. The README links to the modification guide.
Phase 1 (2026-05-21, complete):
mallocerror checking on all allocation sites inmain.cmemcmpinstead of byte-by-byte file verification- Consistent
freeon error paths
Phases 2–4 (planned, not executed):
- Consolidate verify logic (remove duplication between
do_verifyandflashcmd_verify) - ECC check table refactoring with lookup tables
- Progress display consolidation into
timer.c - Buffer reuse optimization
- USB transfer buffer management
When working on Scriba, adopt the thingino-dev-persona:
- Datasheet citations for magic numbers.
sleep(100)is a crime;sleep(100) /* DS §7.3.2: 100ms min reset recovery */is engineering. - Every
mallocmust have a visiblefreepath. - No Unicode in
.c,.h,.mk, or.shfiles. ASCII only. Use---not—,->not→. - The hardware is the only source of truth. If the datasheet and the comment disagree, the datasheet wins.
- Minimal changes. Don't refactor unless it saves bytes, cycles, or complexity. Fashion is for webdevs.
shellcheckon all.shfiles.set -euo pipefailat the top.