Prototype for running parts of CHIRP in the browser with a CHIRP-like UI.
This is live and running on codeplug.org
chirpis included as a git submodule atchirp/- Browser UI with a CHIRP-inspired Channels table and a Settings editor, switchable with tabs.
- Python runtime in-browser (Pyodide) running unmodified CHIRP driver code.
- Radio make/model dropdowns populated from CHIRP driver sources, served from a
prebuilt static catalog (
web/radio-catalog.json) with live enumeration as a fallback. - Selection-aware Download Radio / Upload Radio using the selected CHIRP
clone-mode driver (
sync_in/sync_out), with the clone image cached per driver. - Radio settings read and validated through the selected driver
(
get_radio_settings/validate_radio_settings). - Channel editing: insert / remove / move, copy / cut / paste, plus GMRS, FRS, and PMR446 band-preset generators.
- Repeater imports from external data sources (RepeaterBook, Przemienniki).
- CSV import/export and CHIRP
.imgclone-file import/export, all round-tripped through CHIRP Python code. - Web Serial bridge (browser serial in JS, called from Python in Pyodide) with a WebUSB fallback path (FTDI / PL2303 chip drivers) for browsers without native Web Serial.
- Upload preflight that validates edited rows before any bytes are written.
From the repository root:
npm run devOpen http://127.0.0.1:8000/.
Serial access requires a browser with Web Serial support and a secure context
(http://localhost works).
There are two connect buttons:
- Connect uses native Web Serial when available, otherwise WebUSB.
- Connect via WebUSB forces the WebUSB path. Use this when native Web Serial
exists but cannot drive your adapter — notably Chrome on Android, which now
exposes
navigator.serialbut only supports a limited set of devices (not FTDI/PL2303-class USB UART chips). There is no way to detect this in advance, hence the explicit button.
Over WebUSB a single device chooser is shown and the selected adapter is dispatched to a chip-specific driver:
- FTDI adapters (FT231X, FT232R, ...) use a built-in FTDI-over-WebUSB driver, verified end-to-end on Android Chrome with an FT231X cable and a Baofeng UV-5R.
- Prolific PL2303 adapters use a built-in PL2303-over-WebUSB driver that detects the chip generation (01/HX/TA/TB and the newer HXN family: GC/GB/GT/GL/GE/GS) and applies the matching init and register map. Also verified end-to-end on Android Chrome.
- WCH CH340/CH341 adapters use a built-in CH340-over-WebUSB driver that reads the chip version to pick the right register map and probes for the clone-silicon prescaler quirk. Enumeration and baud/line configuration are verified against a real adapter; a full clone against a radio has not been exercised yet, so the bulk read path is still untested.
- USB CDC-ACM devices are dispatched to Google's
web-serial-polyfill. This path is wired up but untested — most radio programming cables are not CDC-ACM. - Other vendor-specific UART bridges (CP2102) are not supported over
WebUSB; they need chip-specific drivers that have not been written yet (see
web/js/ftdi-webusb.js,web/js/pl2303-webusb.jsandweb/js/ch340-webusb.jsfor the pattern) and still require native Web Serial on desktop.
npm run dev serves with cross-origin isolation headers (COOP/COEP) so
Pyodide synchronous JS bridging can use SharedArrayBuffer without warnings.
For radio cloning:
- Choose
Radio makeandRadio modelfrom dropdowns (loaded from CHIRP sources). - Click
Connect(baud is prefilled when available from selected driver). - Click
Download Radioto read channels into the table. - Edit values and click
Upload Radioto write back.
You can read or write a real-radio codeplug from the command line using the same
runtime bridge (web/python/runtime_bridge.py) and local CHIRP source loading
path used by the Node tests. This is the intended agent-facing CLI for scripted
radio access.
From the repository root:
npm run radio:read -- --port /dev/ttyUSB0 --module uv5r --class BaofengUV5R --format json --output /tmp/uv5r.json
npm run radio:write -- --port /dev/ttyUSB0 --module uv5r --class BaofengUV5R --format json --input /tmp/uv5r.jsonOptional flags:
--baud 9600to override the driver's default baud.--chirp-dir /path/to/chirp(orWEBCHIRP_CHIRP_DIR=/path/to/chirp) to load CHIRP sources from a custom directory.--serial-timeout-s 2.0to override serial read timeout used by the runtime bridge.
Supported formats:
--format json: read/write a JSON object containingrows,headers,csvText,settings, and binaryimageBase64.--format csv: read/write CHIRP-normalized CSV text.--format img: read/write CHIRP.imgclone files.
The flow is:
- Open serial on the selected port.
radio:readrunsdownload_selected_radio(module, class)and caches the clone image in runtime before writing the requested output format.radio:writereads JSON/CSV/IMG input and uploads it throughupload_selected_radio(...);.imginput is first loaded through CHIRP image detection so the cached image and selected driver stay aligned.- Disconnect serial.
The older live smoke test still exists:
npm run test:hw -- --port /dev/ttyUSB0 --module uv5r --class BaofengUV5RUse radio:read / radio:write for deterministic agent workflows and
format-specific codeplug files.
- Entry point / wiring:
web/app.jsconnects the UI controller, runtime RPC client, and serial bridge. - UI controller:
web/js/ui.js(channel table, settings editor, clipboard, status/debug panels). - Serial bridge:
web/js/serial.js(native Web Serial) with WebUSB chip drivers inweb/js/ftdi-webusb.jsandweb/js/pl2303-webusb.js. - Main-thread runtime RPC client + Pyodide bootstrap:
web/js/runtime-rpc.js(runs on the main thread — there is no Web Worker). - Python source providers:
web/js/python-sources.mjs. - Versioned Python runtime code:
web/python/runtime_bridge.py. - Browser runtime loads CHIRP source files into Pyodide from jsDelivr (revision-pinned).
- Command-line runtime can load CHIRP source files from a local directory:
WEBCHIRP_CHIRP_DIR=/path/to/chirp npm run test:channels
- Core CHIRP files preloaded into Pyodide (see
CORE_CHIRP_RELATIVE_FILESinweb/js/python-sources.mjs):chirp/__init__.py,chirp/errors.py,chirp/util.py,chirp/memmap.pychirp/chirp_common.py,chirp/directory.py,chirp/settings.pychirp/pyPEG.py,chirp/bitwise_grammar.py,chirp/bitwise.pychirp/drivers/generic_csv.py,chirp/drivers/h777.py- Any other driver module is fetched on demand when its radio is selected.
The file-backed workflows (CSV, .img, settings) run unmodified CHIRP Python
logic in the browser and are the most exercised paths.
Live browser serial executes the selected CHIRP clone-mode driver
(sync_in/sync_out) through a generalized pyserial-like bridge, and has been
verified end-to-end (e.g. Baofeng UV-5R). Compatibility with any given radio
still depends on that driver's expectations and on browser transport limits, so
treat an untested make/model as unverified.
sequenceDiagram
autonumber
participant U as User
participant UI as ui.js
participant RPC as runtime-rpc.js
participant SRC as python-sources.mjs
participant PY as runtime_bridge.py<br/>(Pyodide)
participant S as serial.js<br/>(Web Serial / WebUSB)
participant R as Radio
Note over UI,S: app.js wires the UI controller, main-thread RPC client, and serial bridge
U->>UI: Open page
UI->>RPC: listRadios()
alt Static catalog matches pinned CHIRP revision
RPC->>RPC: Load web/radio-catalog.json
else Missing / stale
RPC->>SRC: listDriverModules() + seedPyodideRuntime()
SRC-->>RPC: Driver module list + runtime bridge source
RPC->>PY: list_registered_radios(...)
PY-->>RPC: radios[]
end
RPC-->>UI: Populate make/model dropdowns
U->>UI: Select make/model, click Connect
UI->>RPC: serialConnect(baudRate)
RPC->>PY: webserial_connect(baud)
PY->>RPC: serial_open(...)
RPC->>S: handleSerialRpc("open")
S-->>R: Open serial port (Web Serial or WebUSB)
S-->>RPC: connected
RPC-->>UI: connected/status
U->>UI: Click Download Radio
UI->>RPC: downloadSelectedRadio({module, className})
RPC->>PY: ensure_radio_module(module)
RPC->>PY: download_selected_radio(module, className)
PY->>RPC: serial_prepare_clone(...)
RPC->>S: prepareClone(DTR/RTS, settle)
S-->>R: Set control lines + settle
S-->>RPC: prepared
loop sync_in() serial exchange
PY->>RPC: serial_write_bytes / serial_read_bytes
RPC->>S: writeBytes / readBytes
S-->>R: TX/RX bytes
R-->>S: TX/RX bytes
S-->>RPC: bytes
RPC-->>PY: bytes
end
PY->>PY: Cache image in LAST_IMAGE_BY_DRIVER
PY-->>RPC: rows + headers + settings
RPC-->>UI: Populate editable Channels table + Settings editor
U->>UI: Edit channels/settings, click Upload Radio
UI->>RPC: validateRowsForUpload({rows, module, className})
RPC->>PY: validate_rows_for_upload(...)
PY-->>RPC: valid + issues
alt Preflight invalid
RPC-->>UI: Block upload + highlight invalid cells
else Preflight valid
UI->>RPC: uploadSelectedRadio({module, className, rows, settings})
RPC->>PY: ensure_radio_module(module)
RPC->>PY: upload_selected_radio(module, className, rows, settings)
alt Cached image exists
PY->>PY: Apply edited rows + settings to cached image
PY->>RPC: serial_prepare_clone(...)
RPC->>S: prepareClone(...)
loop sync_out() serial exchange
PY->>RPC: serial_write_bytes / serial_read_bytes
RPC->>S: writeBytes / readBytes
S-->>R: TX/RX bytes
R-->>S: TX/RX bytes
S-->>RPC: bytes
RPC-->>PY: bytes
end
PY->>PY: Refresh cached image
PY-->>RPC: uploaded=true
RPC-->>UI: Show upload success
else No cached image
PY-->>RPC: Error: download required first
RPC-->>UI: Show clear failure in Debug Output
end
end
Note over UI,PY: The RPC client logs full stack traces to Debug Output on runtime errors