The device boots Rad Pro 3.1.1 again. Getting there meant erasing it first, discovering the USB updater physically cannot install the version I wanted, and finishing the job over SWD with a Raspberry Pi Pico. The interesting part isn’t the happy ending — it’s the chain of contradicting facts that explained each dead end.
Starting point
A FNIRSI GC-01 Geiger counter running an old Rad Pro (2.x), CH32F103R8 variant — the WCH clone of an STM32F103, 64 KiB flash, 20 KiB RAM. I wanted the current release, 3.1.1, English. The advertised method is the USB bootloader: power off, plug in, short-press power, a GC01 BOOT drive appears, copy the firmware .bin onto it, done. No programmer required.
On Linux it is not that simple, and the firmware I wanted couldn’t go on that way at all. Both of those took a while to establish.
First mistake: the copy that doesn’t stick, and the power press that erases
Copying the .bin to the bootloader drive did nothing. Copying it twice did nothing. The community lore is “copy it twice and it reboots” — true on Windows, unreliable on Linux because the bootloader watches raw sector writes and the kernel’s FAT driver delivers them in an order it doesn’t expect.
The trigger that actually works: a buffered mount and a single flush on umount.
sudo mount -t vfat -o rw,noatime /dev/sda /mnt/gc01
sudo cp firmware.bin /mnt/gc01/
sudo cp firmware.bin /mnt/gc01/
sudo umount /mnt/gc01 # the flush burst is what the bootloader captures
The catch: GNOME’s auto-mount adds the flush option, which dribbles writes out immediately instead of releasing them as one burst — so the bootloader never assembles a complete image and quietly resets to its ready state. So does a sync mount. You need the opposite of what feels safe.
Worse, my own impatience: after copying, I pressed the power button to “help it along”. That reset the chip mid-write, and the bootloader had already erased the application region to begin programming. Result: blank app, dark screen. The bootloader was fine; the app was gone.
That turned an update into a recovery.
The status file tells you what the bootloader thinks
This bootloader reports state through the filename of an empty file on its FAT volume. Reading it without disturbing anything is a raw block read, no mount:
Readme.TXT— valid app presentNOAPP.TXT— application region emptyERRAPP.TXT— app present but failed validation
After the erase I had NOAPP.TXT. After writing 3.1.1 cleanly I got ERRAPP.TXT — which is the fact that broke the whole plan open.
Why 3.1.1 cannot be installed over USB
The firmware file is fine. It’s the signed release image, padded to 48 KiB, with a correct STM32 CRC. I verified the CRC myself rather than assume it.
The bootloader validates that CRC at a fixed offset: FIRMWARE_SIZE − 4. And FIRMWARE_SIZE changed between releases as the firmware grew:
| App version | CRC footer offset |
|---|---|
| 2.0.x | 0x9FFC |
| 2.1.x | 0xA3FC |
| 3.1.x | 0xA7FC |
My device still had a 2.1.x-era bootloader. It looked for the CRC at 0xA3FC; in the 3.1.1 image that offset holds code, not the checksum, so validation failed → ERRAPP. Flash 2.1.1 and the same bootloader returns Readme.TXT and boots it. The bootloader isn’t broken; it’s matched to a different firmware layout.
The conclusion follows directly: installing 3.x requires writing the patched bootloader too — the one that knows the 3.x offset. The USB drive can only rewrite the app region (0x08004000). The bootloader lives at 0x08000000 and is only reachable over SWD. No amount of USB cleverness gets around that.
So: programmer it is. I didn’t have an ST-Link, but I had a Raspberry Pi Pico, which is just as good.
Pico as the SWD programmer
A stock Pico flashed with Raspberry Pi’s debugprobe firmware is a CMSIS-DAP adapter. Drag debugprobe_on_pico.uf2 onto its BOOTSEL drive and it re-enumerates as 2e8a:000c. OpenOCD speaks to it natively.
Wiring to the GC-01’s JP1 pads — three wires plus power:
- Pico
GP2→SWCLK - Pico
GP3→SWDIO - Pico
GND→GND
The CH32 needs its non-standard IDCODE declared, or OpenOCD refuses the target:
set CPUTAPID 0x2ba01477
With that, it connects: Cortex-M3 r2p1, device id = 0x20000410, flash size = 64 KiB. Correct chip, talking.
Three CH32 gotchas that each cost a run
No reset pin on JP1. OpenOCD’s default reset halt fails with Unable to reset target. reset_config none separate and don’t rely on a reset line that isn’t wired.
A blank chip is in CPU lockup. With no valid vector table the core takes a hard fault on every reset and escalates to lockup. OpenOCD can halt it but can’t run its SRAM-based flash-write routine from that state — the loader needs the core to execute. mass_erase worked (a direct flash-register op, no code execution); the write failed with failed to get read pointer and the target visibly re-syncing. The fix is a core-only reset over the debug unit:
cortex_m reset_config vectreset
reset halt
That breaks the lockup and leaves the core cleanly halted before it can fault, so the flash loader runs. I also dropped OpenOCD’s program helper (it auto-resets and fails) in favour of flash write_image + verify_image, then power-cycle by hand.
Power is soft-latched. A blank chip can’t hold its own power rail on, and the device’s USB doesn’t power the rail either — confirmed by cannot read IDR whenever I tried to flash on USB power alone. The Pico’s 3V3 can power it, but browned out mid-write (another target re-sync). What held: the battery, with the power button physically held down through the whole flash.
The write that stuck
openocd -f interface/cmsis-dap.cfg \
-c "transport select swd" -c "adapter speed 1000" \
-c "set CPUTAPID 0x2ba01477" -f target/stm32f1x.cfg \
-c "reset_config none separate" -c "init" \
-c "cortex_m reset_config vectreset" -c "reset halt" \
-c "stm32f1x mass_erase 0" \
-c "flash write_image erase patched-bootloader.bin 0x08000000" \
-c "flash write_image erase radpro-3.1.1-en.bin 0x08004000" \
-c "verify_image patched-bootloader.bin 0x08000000" \
-c "verify_image radpro-3.1.1-en.bin 0x08004000" \
-c "shutdown"
Two verified ... OK lines. Reset pinhole, hold OK — and the screen came up on Rad Pro 3.1.1.
Confirming it from the host
Running Rad Pro enumerates as 0483:5740, a CDC serial port, with an ASCII request/response protocol at 115200:
GET deviceId -> OK FNIRSI GC-01 (CH32F103);Rad Pro 3.1.1/en;...
GET deviceBatteryVoltage -> OK 4.817
GET tubeRate -> OK 18.630
18.6 CPM is ordinary background, about 0.15 µSv/h. The screen being dark earlier was never a display fault — it was a blank chip, then a layout mismatch. Software can’t break a display; it can only fail to drive one.
What the USB port is actually worth
The version number was never the point. The payoff is that the probe is now a queryable sensor over a documented protocol, not a closed handheld that shows a number to nobody but me.
- A few lines of Python poll
GET tubeRate/GET tubePulseCountand publish into Home Assistant as a sensor, sitting next to grid carbon intensity and solar export. Background radiation becomes just another series I can chart and alarm on. - Rad Pro can stream directly to the public radiation maps — gmcmap.com, radmon.org, safecast.org, opensensemap.org — so a counter on a windowsill contributes to a shared dataset instead of blinking in isolation.
- It’s plain CDC serial, so anything that opens a port can ingest it: a Pi logging to InfluxDB, an ESP32 bridging it to MQTT, a Lambda behind a USB host forwarding to wherever. No SDK, no app, no account.
A Geiger counter that only renders to its own LCD is a gadget. One that exposes CPM and dose rate over USB is infrastructure you can build on and share. That — not “3.1.1” — is what justified the SWD detour.
What I’d tell my earlier self
- The bootloader’s job is checksum-then-jump. A dark screen with a healthy charge LED and a responsive bootloader is an app problem, not a hardware one. Diagnose the app, not the panel.
- Don’t “help” a flash with a button press. The bootloader erases before it writes; interrupting that is how you turn an update into a recovery.
- On Linux, the trigger is a buffered
umount, never async/flushmount. - A version bump can move the validation offset. If a known-good signed image returns
ERRAPP, suspect the bootloader/layout pairing before the file. - A £4 Pico is an SWD programmer. There’s no such thing as “I don’t have an ST-Link” if there’s a Pico in the drawer.
Rad Pro is excellent open-source firmware — the whole thing is recoverable precisely because the bootloader and the source are open: github.com/Gissio/radpro. The unit was never bricked in the sense that matters. As long as SWD can attach, nothing on this chip is final.