Wake a bricked Rockchip board over USB (RV1106 / Luckfox Pico) - #125
Conversation
Rockchip's boot ROM has no UART download path at all — recovery is
USB-only. That matters most for the case a test rig actually hits: an
erased or half-written flash leaves no valid IDB, so the boot ROM
abandons flash and enters MaskROM by itself at power-up. No button, no
strap, no serial. Cutting power is enough to make the board
recoverable, which is what makes unattended recovery possible.
This does not go through the protocol/ registry or the Transport ABC.
Every protocol there is a UART boot-ROM dialect over a byte stream;
these two stages (vendor control transfers, then a Mass-Storage-shaped
CBW/CSW bulk protocol) are not byte streams, and forcing the fit would
have been a leaky abstraction. New sibling package instead, with no
entry point.
Framing is kept in pure functions so the fiddly parts are testable with
no hardware and no libusb:
- the 4096-byte chunk quirks, where a payload ending 4095 mod 4096
needs a pad byte before the CRC (else the CRC straddles a chunk
boundary) and one ending 4094 needs a trailing short packet to
close the transfer
- the mixed endianness, where the command wrapper is little-endian
but the address and count inside its CDB are big-endian
- RKBOOT entry parsing, read backwards from the entry stride because
emType is a C enum of ambiguous width and guessing wrong silently
shifts every later offset
The CRC is pinned to the standard CCITT-FALSE check value and
cross-checked against an independent bitwise implementation, so the
seed and bit order are proven rather than merely self-consistent.
pyusb is an optional extra, imported lazily, so installs without it
keep working for every UART SoC.
Written from xboot/xrock (MIT) and rkflashtool's rkcrc.h (BSD-2).
rkdeveloptool is GPL-2 and was read only to understand behaviour, never
copied.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Every profile so far has been HiSilicon UART bytecode, so a chip whose boot ROM only answers on USB could not be described at all. Add a RECOVERY discriminator that defaults to "uart", leaving all 111 existing profiles untouched. The four bytecode fields become optional to make room for that, which on its own would let a UART profile quietly lose its bytecode and only fail as a confusing NoneType deep inside a burn. A validator requires them back whenever RECOVERY is "uart", and the UART-only properties now raise a plain "rv1106 recovers over usb, not UART" instead of returning None. recovery_mode() falls back to "uart" when a chip has no profile at all, which is what keeps the V500 and CV6xx families working — their chip lists live in their protocol modules, not in JSON. Ship rv1106 only. The partition LBAs come from Luckfox's published layout for the Pico Pro/Max; idblock stays at 0x40000, the offset the boot ROM looks for the IDB at, because moving it bricks the board in a way no button recovers. rv1103 is deliberately absent — the Pico Plus/Mini layout was never verified, and a fabricated one would be worse than none. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The first cut of this put Rockchip behind a `defib rockchip` sub-app, which was wrong: the whole CLI is chip-selected — `-c hi3516ev300`, `-c gk7205v200` — and no vendor name appears anywhere in it. A vendor namespace pushed an internal problem (USB not fitting the serial-shaped verbs) onto the user's fingers. Fix the dispatch instead. defib burn -c rv1106 --ddr <ddr.bin> --usbplug <plug.bin> --power-cycle defib install -c rv1106 --firmware openipc.rv1106-nor-lite.tgz --verify burn stops once the usbplug is running — the USB equivalent of uploading U-Boot into RAM — and install writes images to the partitions the profile declares. -p is simply not consulted for these chips. Both loader forms exist because the blobs Rockchip publishes for RV1106 carry no container header, which is precisely what `rkdeveloptool db` refuses to load; --ddr/--usbplug takes them as-is and --loader takes an RKBOOT container. install refuses rootfs.ubi rather than guessing at it. That image bundles kernel and rootfs as UBI volumes, so no single partition is the right answer, and picking one would bury a real unresolved question about this board's layout. Error text goes through rich.markup.escape() — Rich was eating the "defib[rockchip]" install hint as a style tag. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
PR Summary by QodoAdd native Rockchip MaskROM USB recovery for RV1106
AI Description
Diagram
High-Level Assessment
Files changed (19)
|
Code Review by Qodo
1.
|
Ten findings, all real. The theme running through most of them is a flash that goes wrong without the operator being told. Wrong-board hazard. Every Rockchip board shares one VID:PID and gets a fresh USB address when the usbplug re-enumerates, so "first match wins" could upload to one board and flash another. Identify boards by their physical port path, which is the one thing that survives re-enumeration; refuse to guess when several are attached, and pin the post-upload wait to the port the board was found on. --usb-path picks one explicitly. Unbounded writes. Partitions carried only a starting LBA, so an oversized image ran straight on into whatever followed. They now carry their extent, and an image that would not fit is refused by name and size. The layout is also asserted to tile without gaps or overlap. Idblock ordering. Images were written in tar order, so the IDB could land before the rest of flash was populated — and the IDB is precisely what stops the boot ROM falling into MaskROM. A failure after that point would leave a board that boots a broken image instead of one that can be re-flashed over USB. It is now written last. Silent partial transfers. A device may move less than it was asked to and still report status OK, reporting the shortfall as residue. That was ignored, so an incomplete write could be announced as a finished install. Incomplete and corrupt firmware. An archive holding only a rootfs was written and called a success; require the kernel and rootfs pair, as the UART installer does. The shipped .md5sums were skipped entirely, and --verify cannot stand in for them because it compares flash against the same bytes that were sent — a corrupt download would verify perfectly. Also: detach and reattach the interface actually claimed rather than assuming interface 0; reject loaders declaring no DDR or usbplug entries, which otherwise upload nothing and time out blaming the board; thread --poe-port through so RouterOS addresses the right port; and resolve loader files inside the error handler so a missing file produces a JSON error event instead of a traceback. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
Addressed all ten in
Two worth drawing out, because they were the sharpest: #3 was self-inflicted. The whole premise of this PR is that an erased flash falls into MaskROM because the boot ROM finds no valid IDB — that's what makes unattended recovery possible. Writing the idblock first traded exactly that away: a failure or power loss afterwards would leave a board booting a broken image rather than one re-flashable over USB. I'd written that reasoning into the PR description and then violated it in the code. #10 has a subtlety worth stating. Verification: 672 → 705 tests (+33), ruff clean, mypy clean apart from the pre-existing pyserial stub gaps. Exercised end-to-end without hardware: a valid tarball reaches the device wait; a rootfs-only one is refused naming the missing kernel; a corrupted digest is caught; a missing loader file now emits a JSON error event instead of a traceback. Still unverified and unchanged from the original description: nothing has touched a board, the RC4 ordering question, and the partition LBAs coming from Luckfox's published layout rather than a board dump. |
|
/review |
|
Code review by qodo was updated up to the latest commit 6d1164d |
Second review pass. All six are variants of the same failure: something did not fully happen, and nothing said so. Foreign SoC images. Member names were matched on their stem and the suffix discarded, so an OpenIPC tarball built for another chip mapped cleanly onto these partitions and installed a kernel this board cannot boot. That suffix is the only thing distinguishing the two, so it is now checked against the chip. Short transfers, three places. pyusb reports how many bytes it actually moved, and all three call sites threw that away: the bulk command wrapper, the bulk payload, and the MaskROM control transfer. A short payload write is a partial flash write wearing the costume of a finished one; a short control transfer is a truncated loader that only surfaces later as a re-enumeration timeout blaming the board. Progress now counts what the wire took rather than what it was handed. Reset failures. reset() swallowed every error, on the reasoning that a board is entitled to vanish while acknowledging its own reset. True of the status read, not of the command itself — a reset that never went out, or came back with an explicit failure, was still reported as a completed install. Only the missing status wrapper is tolerated now, and only when the caller asks for it. Detached driver left behind. A failed interface claim raised without restoring the kernel driver it had just detached, so a failed attempt stranded the interface for whatever owned it. Archive errors in JSON mode. The tarball was opened and validated before the handler that emits structured errors, so a corrupt or incomplete archive produced Typer text or a traceback instead of the error event the JSON contract promises. Human mode keeps Typer's nicer rendering. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
Second pass addressed in
Five of the six are one bug wearing different hats: something didn't fully happen, and nothing said so. pyusb reports how many bytes it actually moved and I was discarding that at all three call sites — bulk wrapper, bulk payload, and the MaskROM control transfer. A short payload write is a partial flash write dressed as a finished one; a short control transfer is a truncated loader that only surfaces later as a re-enumeration timeout blaming the board. #10 is the one I'd defend having got wrong, and it's still instructive. Swallowing errors from #1 was a real gap in reasoning, not an oversight. I stripped the filename suffix to find the image stem and simply never asked what the suffix said. Since OpenIPC names every image for the SoC it was built for, that suffix is the only thing standing between an RV1106 kernel and one that bricks the board — and I was throwing it away as noise. Verification: 705 → 722 tests (+17), ruff clean, mypy clean apart from the pre-existing pyserial stub gaps. Exercised end-to-end without hardware: a Unchanged and still worth a reviewer's eye: nothing has touched a board, the RC4 whole-buffer vs per-chunk question, and the partition LBAs coming from Luckfox's published layout rather than a board dump. |
Everything here was found by putting a Luckfox Pico Max on the bench.
The protocol layer had full green tests and typed clean and still did
not move a single byte of flash.
Command length. The CDB length field declares 6 for simple commands and
10 for those carrying an address — never the 16 bytes the field occupies
on the wire, which is what this sent. A usbplug given 16 ignores the
wrapper outright, so the command never lands and the host waits out its
timeout with nothing to explain it. Confirmed against xrock, which
declares 6 or 10 at every one of its call sites.
Mode detection. xrock separates MaskROM from the running usbplug by the
low bit of bcdUSB, and this copied that. Measured, both stages report
0x0200, so the test calls a live usbplug MaskROM and the caller waits
for a re-enumeration that already happened. What does differ is the
string descriptors: the boot ROM ships a bare one, the usbplug names
itself RockChip / USB-MSC. Only the descriptor index is read, so this
stays cheap.
Device selection. A booted Luckfox presents 2207:0019 — an RNDIS+ADB
gadget sharing the vendor id, and with bcdUSB 0x0200 it looked like
MaskROM too. Matching on vendor id alone meant a healthy running board
was a candidate for having a loader uploaded into it. Profiles now
declare which product ids mean "waiting to be flashed".
Stale input. A recovery tool is routinely pointed at a device some
earlier attempt abandoned mid-transaction. The unread status wrapper it
left behind gets read as the next command's data phase: 13 bytes into a
5-byte buffer is [Errno 75] Overflow, and every command after it
desynchronises. Drain on open, the same way the serial transports open
by reading until the line goes quiet. This also makes a wedged device
recoverable without power-cycling it.
Bulk read sizing. Buffers must be a multiple of the endpoint's max
packet size or a full-packet reply overflows them.
Residue. Honoured on the LBA path and nowhere else — other opcodes
report their transfer length byte-swapped, i.e. "none of it arrived",
while the data plainly did. The check stays where a short transfer
means a partially written flash.
Configuration. Only configure a device that is not configured already;
SET_CONFIGURATION resets the data toggles under a running usbplug.
Partition table. rootfs is 210M, not the 80M Luckfox's docs give and
this shipped with. Read off the board's own U-Boot env and /proc/mtd.
Verified on hardware: MaskROM upload, re-enumeration, TEST_UNIT_READY,
READ_FLASH_ID ("SNAND"), READ_CAPABILITY, READ_FLASH_INFO, and
READ_LBA across env/idblock/uboot/boot — every one byte-identical to a
backup taken independently over SSH through mtd.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Now verified on real hardware — a Luckfox Pico Max (RV1106G3)
Command length. The CDB length field declares 6 for simple commands and 10 for those carrying an address — never the 16 bytes it occupies on the wire, which is what I sent. A usbplug given 16 ignores the wrapper outright: the command never lands, no error, the host just times out. Confirmed against xrock, which declares 6 or 10 at every call site. Mode detection. xrock separates MaskROM from the running usbplug by the low bit of Device selection. A booted Luckfox presents Stale input. A recovery tool is routinely pointed at a device some earlier attempt abandoned mid-transaction. Its unread 13-byte status wrapper gets read as the next command's data phase — into a 5-byte buffer that is Also: bulk IN buffers must be a multiple of Partition table. Evidence
751 tests, ruff and mypy clean. The generic Still not done: no write has been attempted. Read path only. A full verified 256 MB stock backup is in hand before that happens. |
The read path is proven on hardware; writing flash is not, and on the one board this was developed against `install` could not succeed even if every byte landed correctly. That board's rootfs is UBI — mtd6, with `ubi.mtd=6 root=ubi0:rootfs rootfstype=ubifs`. The nand tarball ships rootfs.ubi, which the image mapper refuses by design. The nor tarball ships rootfs.squashfs, which would be written raw into mtd6, destroying the UBI while the U-Boot environment still asks for it. Making that work means rewriting sys_bootargs for a squashfs root while preserving rk_dma_heap_cma=66M, without which the media stack has no buffers at all. That is a design question, not an oversight, and it deserves its own change. Meanwhile WRITE_LBA, the multi-transfer split, and residue-on-write have never run against silicon. The read path had seven bugs that only hardware found, in code that was fully green and typed clean; there is no reason to believe the write path is better off, and the failure mode there is somebody's flash rather than a timeout. So `install -c <usb-recovery-chip>` now says so and exits, instead of offering options that look finished. `burn` is unaffected: it wakes a board over USB and is verified end to end on an RV1106. The image-mapping helpers go with it, along with their tests. The partition table stays — it was read off real hardware, it is what the follow-up will need, and it documents the chip either way. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
/review |
|
Code review by qodo was updated up to the latest commit 854230a |
Six findings, four of them introduced by the two commits just before this one — which is the argument for re-reviewing after a rewrite rather than assuming the earlier pass still applies. Worst of them: `burn` finished by telling the user to run `defib install -c rv1106`, a command the previous commit had just made refuse that chip. Every successfully recovered board would have been pointed straight at a guaranteed failure. It now says what is true — the board is awake and in loader mode. The USB rejection in `install` printed Rich text unconditionally, so `--output json` got prose where automation expects an event. Same for `burn` when the loader arguments are missing: that path raises typer.BadParameter, which the handler did not catch. `recovery_mode()` swallowed every ValueError and called the result UART. So `rv1106:typo` quietly entered the serial workflow and failed later complaining about a serial port, rather than saying the variant does not exist. It now defaults to UART only when the chip has no profile at all — the case that fallback was for — and profile problems surface as themselves. `burn` never released the device. A claimed interface outliving the command makes the next attempt unable to open the board, which is indistinguishable from hardware that has stopped answering; given how much of this session was spent on boards that appeared wedged, that is not a leak worth keeping. And upload progress compared real bytes sent against the unframed blob length, so a 3-byte blob reported 5 of 3. Framing adds a CRC and sometimes a terminator; both ends now measure framed bytes. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
Addressed in
#1 was the one worth catching. #13 connects to something real. Much of this session was spent on boards that appeared wedged between invocations, and a claimed interface outliving the command is indistinguishable from hardware that has stopped answering. It may not be the whole story, but leaving it there while investigating that symptom would have been foolish. #11 is a fair correction of my own fix. I added that 731 tests, ruff and mypy clean. Verified by hand in both output modes: bad variant, missing loader args, and the install rejection all now produce a clean message in human mode and a |
Adds native Rockchip MaskROM/rockusb support, so a board whose SPI NAND was erased or half-written can be brought back to life without leaving defib.
Scope:
burnonly. Writing firmware over USB (install) is deliberately held back — see the bottom.Why USB, and why that is good news
Rockchip's boot ROM has no UART download path at all — recovery is USB-only, so defib's existing bootrom-over-serial approach does not apply.
The upside is the case a test rig actually hits. With no valid IDB, the boot ROM abandons flash and enters MaskROM by itself at power-up — no button, no strap, no serial. Cutting power is enough to make the board recoverable.
defib burn -c rv1106 --ddr rv1106_ddr_924MHz_v1.15.bin \ --usbplug rv1106_usbplug_v1.09.bin --power-cycleChip-selected like every other command;
-pis simply not consulted.--power-cyclereuses the existingDEFIB_POWER_TYPEfactory, so the rack pod drives it unchanged. Both loader forms exist because the blobs Rockchip publishes for RV1106 carry no container header — exactly whatrkdeveloptool dbrefuses to load (#105).Shape
src/defib/rockusb/is a sibling ofprotocol/, not a member. Every protocol there is a UART boot-ROM dialect over the byte-streamTransportABC; these two stages — vendor control transfers, then a Mass-Storage-shaped bulk protocol — are not byte streams.SoCProfilegains aRECOVERYdiscriminator defaulting touart, so all 111 existing profiles are untouched; a validator requires the DDR/SPL bytecode back whenever it isuart, so a UART profile still cannot quietly lose it.Verified on hardware — a Luckfox Pico Max (RV1106G3)
READ_LBAat sector 0 returned the env partition — CRC0x7b64e37c,mtdparts,sys_bootargsand all — byte-identical to a backup taken independently over SSH through mtd. Reading env / idblock / uboot / boot through defib's own API matched that backup at every offset, including 256 KiB multi-sector transfers. Two entirely different paths agreeing on the same bytes.Seven bugs surfaced that no test caught, in code that was green and typed clean throughout:
bcdUSBcannot tell the stages apart — both report 0x0200, so xrock's heuristic calls a live usbplug MaskROM. String descriptors are the real discriminator.2207:0019(RNDIS+ADB) and looked like MaskROM too.wMaxPacketSize;SET_CONFIGURATIONresets data toggles under a running usbplug; residue is honoured only on the LBA path.Also corrected from hardware:
rootfsis 210M, not the 80M in Luckfox's published docs.What is deliberately not here
installfor USB-recovery chips. On this board it could not produce a booting result even with every byte written correctly: rootfs is UBI (ubi.mtd=6), the nand tarball'srootfs.ubihas no single partition to go in, and the nor tarball'srootfs.squashfswritten raw would destroy the UBI whilesys_bootargsstill asks for it. Fixing that means rewritingsys_bootargswhile preservingrk_dma_heap_cma=66M. That is a design question and gets its own PR.WRITE_LBAhas also never run against silicon. Given the read path's seven hardware-only bugs, assuming the write path is cleaner would be optimistic — and there the failure mode is somebody's flash, not a timeout.install -c rv1106therefore says so and exits.Licensing
Written from xboot/xrock (MIT, same as defib) and rkflashtool's
rkcrc.h(BSD-2). rkdeveloptool is GPL-2 and was read only to understand behaviour, never copied.pyusbis an optionalrockchipextra, imported lazily.🤖 Generated with Claude Code