Skip to content

Wake a bricked Rockchip board over USB (RV1106 / Luckfox Pico) - #125

Merged
widgetii merged 8 commits into
masterfrom
feat/rockchip-maskrom-recovery
Aug 20, 2026
Merged

Wake a bricked Rockchip board over USB (RV1106 / Luckfox Pico)#125
widgetii merged 8 commits into
masterfrom
feat/rockchip-maskrom-recovery

Conversation

@widgetii

@widgetii widgetii commented Aug 18, 2026

Copy link
Copy Markdown
Member

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: burn only. 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-cycle

Chip-selected like every other command; -p is simply not consulted. --power-cycle reuses the existing DEFIB_POWER_TYPE factory, so the rack pod drives it unchanged. Both loader forms exist because the blobs Rockchip publishes for RV1106 carry no container header — exactly what rkdeveloptool db refuses to load (#105).

Shape

src/defib/rockusb/ is a sibling of protocol/, not a member. Every protocol there is a UART boot-ROM dialect over the byte-stream Transport ABC; these two stages — vendor control transfers, then a Mass-Storage-shaped bulk protocol — are not byte streams.

SoCProfile gains a RECOVERY discriminator defaulting to uart, so all 111 existing profiles are untouched; a validator requires the DDR/SPL bytecode back whenever it is uart, so a UART profile still cannot quietly lose it.

Verified on hardware — a Luckfox Pico Max (RV1106G3)

Found maskrom device 2207:110c
  ddr : 22634 bytes in 6 chunks -> 0x0471  OK
  plug: 52742 bytes in 13 chunks -> 0x0472  OK
re-enumerated mode=loader
  READ_FLASH_ID      534e414e44   ("SNAND")
  READ_LBA sector 0  residue=0

READ_LBA at sector 0 returned the env partition — CRC 0x7b64e37c, mtdparts, sys_bootargs and 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:

  • CDB length declares 6 or 10, never the 16 bytes it occupies. Given 16 the usbplug ignores the wrapper: no error, just a timeout.
  • bcdUSB cannot tell the stages apart — both report 0x0200, so xrock's heuristic calls a live usbplug MaskROM. String descriptors are the real discriminator.
  • Vendor id alone is not a recovery device — a booted Luckfox presents 2207:0019 (RNDIS+ADB) and looked like MaskROM too.
  • Stale input wedges everything — an abandoned attempt's unread status wrapper is read as the next command's data phase. Draining on open also recovers a wedged device without a power cycle.
  • Bulk IN buffers must be a multiple of wMaxPacketSize; SET_CONFIGURATION resets data toggles under a running usbplug; residue is honoured only on the LBA path.

Also corrected from hardware: rootfs is 210M, not the 80M in Luckfox's published docs.

What is deliberately not here

install for 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's rootfs.ubi has no single partition to go in, and the nor tarball's rootfs.squashfs written raw would destroy the UBI while sys_bootargs still asks for it. Fixing that means rewriting sys_bootargs while preserving rk_dma_heap_cma=66M. That is a design question and gets its own PR.

WRITE_LBA has 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 rv1106 therefore 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. pyusb is an optional rockchip extra, imported lazily.

🤖 Generated with Claude Code

widgetii and others added 3 commits August 18, 2026 19:20
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>
@qodo-free-for-open-source-projects

Copy link
Copy Markdown

PR Summary by Qodo

Add native Rockchip MaskROM USB recovery for RV1106

✨ Enhancement 🧪 Tests ⚙️ Configuration changes 🕐 40+ Minutes

Grey Divider

AI Description

• Adds native MaskROM and rockusb recovery for RV1106 boards over USB.
• Routes burn and install through profile-selected USB loading, flashing, and verification.
• Preserves UART behavior while adding extensive framing, profile, and partition tests.
Diagram

sequenceDiagram
    actor U as Operator
    participant C as CLI
    participant P as SoC Profile
    participant F as Firmware Archive
    participant R as USB Recovery
    participant D as PyUSB Device
    participant B as RV1106 Board
    participant S as SPI Flash
    U->>C: burn or install
    C->>P: select USB mode and LBAs
    opt install
        C->>F: extract and map images
    end
    C->>R: provide loader blobs
    R->>D: send MaskROM control chunks
    D->>B: start DDR and usbplug
    B-->>D: re-enumerate in loader mode
    R->>D: issue CBW commands
    D->>S: write or verify sectors
Loading
High-Level Assessment

The following are alternative approaches to this PR:

1. Shell out to rkdeveloptool
  • ➕ Reduces the amount of custom USB protocol implementation.
  • ➕ Uses a widely deployed Rockchip recovery utility with existing hardware exposure.
  • ➖ Adds a platform-specific executable and process-management dependency.
  • ➖ Its download-boot command rejects the headerless RV1106 blobs this PR supports.
  • ➖ GPL licensing and output parsing complicate integration and distribution.
2. Extend the UART protocol abstraction
  • ➕ Reuses the existing protocol registry and recovery-session structure.
  • ➕ Provides one nominal recovery interface for all supported SoCs.
  • ➖ Vendor control transfers and CBW/CSW exchanges are not byte streams.
  • ➖ Forcing USB stages through Transport would leak USB semantics into UART abstractions.
  • ➖ Registration would imply interchangeability that the hardware does not provide.

Recommendation: Keep the dedicated rockusb sibling package. It preserves the UART Transport abstraction, supports both RKBOOT containers and headerless RV1106 blobs, and isolates wire framing into testable pure functions. Before enabling additional Rockchip profiles or encrypted loaders, validate control-transfer termination, re-enumeration, RC4 ordering, and partition LBAs against physical hardware.

Files changed (19) +2131 / -19

Enhancement (11) +1482 / -18
app.pyRoute burn and install through USB recovery +311/-8

Route burn and install through USB recovery

• Adds loader, enumeration timeout, and verification options for USB-recovery chips. Implements power-cycling, loader resolution, firmware-to-partition mapping, sector writes, read-back verification, reset handling, and human or JSON reporting while retaining the UART path.

src/defib/cli/app.py

loader.pyExpose profile-selected recovery mode +13/-0

Expose profile-selected recovery mode

• Adds a recovery-mode helper that reads profile configuration and defaults unprofiled chip families to UART. This preserves routing for existing V500 and CV6xx devices.

src/defib/profiles/loader.py

schema.pySupport distinct UART and USB profile requirements +92/-10

Support distinct UART and USB profile requirements

• Adds the recovery discriminator, optional Rockchip loader names, and partition LBAs. Makes UART bytecode fields structurally optional but validates them for UART profiles and guards UART-only properties from USB use.

src/defib/profiles/schema.py

events.pyAdd USB recovery progress stages +4/-0

Add USB recovery progress stages

• Adds progress stages for usbplug uploads and flash writes so USB operations can use the existing event model.

src/defib/recovery/events.py

__init__.pyExpose the Rockchip USB recovery API +59/-0

Expose the Rockchip USB recovery API

• Introduces the standalone rockusb package and exports its loader, codec, MaskROM, and bulk-protocol primitives. Documents why the subsystem does not implement the UART-oriented Transport abstraction.

src/defib/rockusb/init.py

codec.pyImplement Rockchip CRC-16 and RC4 codecs +58/-0

Implement Rockchip CRC-16 and RC4 codecs

• Implements the MaskROM CRC-16/CCITT-FALSE variant and fixed-key RC4 transformation. Reuses only the existing polynomial lookup table while preserving Rockchip-specific seed and finalization behavior.

src/defib/rockusb/codec.py

device.pyImplement PyUSB enumeration and transfer plumbing +338/-0

Implement PyUSB enumeration and transfer plumbing

• Adds lazy PyUSB loading, MaskROM versus loader classification, polling, endpoint discovery, interface lifecycle management, control transfers, and CBW/data/CSW bulk exchanges. Converts USB and status failures into Rockusb-specific errors.

src/defib/rockusb/device.py

loader.pyParse RKBOOT containers and raw loader blobs +153/-0

Parse RKBOOT containers and raw loader blobs

• Extracts DDR and usbplug entries, delays, and RC4 flags from RKBOOT containers with bounds validation. Also wraps headerless vendor blobs and parses entry trailers independently of ambiguous C enum widths.

src/defib/rockusb/loader.py

maskrom.pyFrame MaskROM loader control transfers +86/-0

Frame MaskROM loader control transfers

• Builds CRC-protected, optionally encrypted 4096-byte upload chunks for DDR and usbplug stages. Handles Rockchip's boundary padding and short-packet termination quirks.

src/defib/rockusb/maskrom.py

protocol.pyImplement rockusb CBW and CSW framing +172/-0

Implement rockusb CBW and CSW framing

• Defines Rockchip opcodes, reset modes, sector sizing, command wrapper construction, and status parsing. Handles mixed wrapper/CDB endianness and splits large LBA ranges into conservative transfers.

src/defib/rockusb/protocol.py

recovery.pyCoordinate end-to-end Rockchip USB recovery +196/-0

Coordinate end-to-end Rockchip USB recovery

• Uploads DDR and usbplug stages, waits for loader-mode re-enumeration, and performs chunked flash reads and writes. Supports progress reporting, flash probing, verification reads, and reset behavior.

src/defib/rockusb/recovery.py

Bug fix (1) +4 / -1
flashdump.pyHandle profiles without UART load addresses +4/-1

Handle profiles without UART load addresses

• Uses the guarded 'uboot_address' property when deriving RAM staging addresses. USB profiles now fall back to the existing chip-prefix lookup instead of indexing absent address data.

src/defib/flashdump.py

Tests (5) +623 / -0
test_profiles_usb_recovery.pyTest USB profile routing and partition mapping +136/-0

Test USB profile routing and partition mapping

• Covers recovery defaults, UART field validation, USB-only properties, RV1106 loader metadata, fixed partition offsets, image mapping, and explicit rejection of ambiguous UBI firmware.

tests/test_profiles_usb_recovery.py

test_rockusb_codec.pyValidate Rockchip wire codecs +95/-0

Validate Rockchip wire codecs

• Cross-checks CRC output against an independent bitwise implementation and the standard check vector. Pins RC4 behavior with published vectors, the Rockchip key, and encryption/checksum ordering.

tests/test_rockusb_codec.py

test_rockusb_loader.pyTest RKBOOT and raw loader handling +139/-0

Test RKBOOT and raw loader handling

• Exercises blob, name, delay, and RC4 flag extraction across multiple entry strides. Covers alternate magic values, malformed offsets and sizes, headerless input guidance, and raw blob wrapping.

tests/test_rockusb_loader.py

test_rockusb_maskrom.pyTest MaskROM chunk boundary behavior +108/-0

Test MaskROM chunk boundary behavior

• Pins chunk sizing, big-endian CRC placement, optional encryption, deterministic output, and the 4094/4095-byte boundary quirks that control transfer termination requires.

tests/test_rockusb_maskrom.py

test_rockusb_protocol.pyTest rockusb command and status framing +145/-0

Test rockusb command and status framing

• Verifies CBW and CSW sizes, signatures, tags, direction flags, mixed endianness, error handling, and contiguous splitting of large sector ranges.

tests/test_rockusb_protocol.py

Other (2) +22 / -0
pyproject.tomlAdd optional PyUSB support for Rockchip recovery +7/-0

Add optional PyUSB support for Rockchip recovery

• Adds a 'rockchip' extra containing PyUSB. Configures mypy to tolerate PyUSB's missing type stubs while keeping the dependency optional.

pyproject.toml

rv1106.jsonDefine the RV1106 USB recovery profile +15/-0

Define the RV1106 USB recovery profile

• Adds an RV1106 profile declaring USB recovery, expected vendor loader filenames, and Luckfox partition starting LBAs. Pins idblock at the boot ROM-required LBA 512 offset.

src/defib/profiles/data/rv1106.json

@qodo-free-for-open-source-projects

qodo-free-for-open-source-projects Bot commented Aug 18, 2026

Copy link
Copy Markdown

Code Review by Qodo

🐞 Bugs (0) 📘 Rule violations (0) 📎 Requirement gaps (0) 🎨 UX issues (0) 🔗 Cross-repo conflicts (0) 📜 Skill insights (0)

Grey Divider


Action required

1. Advertises unsupported flash command ✓ Resolved 🐞 Bug ≡ Correctness
Description
After a successful USB burn, the CLI tells the user to run defib install -c rv1106, but the newly
added USB branch of _install_async() always prints that USB install is unsupported and exits 1.
Thus the success message directs every recovered RV1106 user to a command guaranteed to fail.
Code

src/defib/cli/app.py[R3804-3807]

+        console.print(
+            "\n[green bold]Device is awake.[/green bold] Flash it with: "
+            f"defib install -c {chip} --firmware <image>"
+        )
Evidence
The added completion text names defib install, while the added install guard identifies USB
recovery chips and unconditionally raises a non-zero Typer exit after saying install is not
supported.

src/defib/cli/app.py[3804-3807]
src/defib/cli/app.py[2235-2244]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
The successful USB `burn` message recommends `defib install`, although the USB install branch deliberately exits as unsupported.
## Issue Context
Users reach this message after the recovery operation succeeded, so it must not prescribe a command that is guaranteed to fail.
## Fix Focus Areas
- src/defib/cli/app.py[3804-3807]
- src/defib/cli/app.py[2235-2244]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


2. Foreign SoC images accepted ✓ Resolved 🐞 Bug ≡ Correctness
Description
_map_usb_images() strips the final filename suffix without checking it against chip, so an
RV1106 install accepts complete images named for another SoC and writes them into the RV1106
partitions. Passing the wrong OpenIPC tarball can therefore install an incompatible kernel/rootfs
and leave the board unbootable.
Code

src/defib/cli/app.py[R3850-3852]

+    for name in names:
+        stem = name.rsplit(".", 1)[0] if "." in name else name
+        partition = _USB_IMAGE_PARTITIONS.get(stem)
Evidence
The mapping comment defines the removed component as the SoC suffix, but _map_usb_images receives
no selected chip and accepts a member solely from the remaining stem; the resulting target is later
written directly at the selected profile's LBA.

src/defib/cli/app.py[3815-3822]
src/defib/cli/app.py[3837-3868]
src/defib/cli/app.py[3965-3976]
src/defib/cli/app.py[3987-3995]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
USB firmware mapping accepts image files for any SoC because it removes the final suffix without validating it against the selected chip.
## Issue Context
OpenIPC image names carry the SoC after the final dot, and this installer writes mapped payloads directly to the selected chip's partition layout. Reject foreign suffixes and ambiguous duplicate assignments while preserving explicitly supported unsuffixed names if required.
## Fix Focus Areas
- src/defib/cli/app.py[3837-3871]
- src/defib/cli/app.py[3897-3900]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


3. Short bulk writes pass ✓ Resolved 🐞 Bug ☼ Reliability
Description
RockusbDevice.command() ignores the byte counts returned for CBW and payload bulk-OUT writes, then
accepts a successful CSW. A host-side short payload transfer can consequently be treated as a
completed flash write.
Code

src/defib/rockusb/device.py[R371-378]

+        try:
+            self._ep_out.write(cbw, self._timeout_ms)
+            payload = b""
+            if direction_in:
+                payload = bytes(self._ep_in.read(read_length, self._timeout_ms))
+            elif data_out:
+                self._ep_out.write(data_out, self._timeout_ms)
+            csw = bytes(self._ep_in.read(CSW_LENGTH, self._timeout_ms))
Evidence
The new command loop discards both _ep_out.write() return values, while write_image() relies on
this method for each flash block transfer. The later CSW validation only examines the received
status wrapper.

src/defib/rockusb/device.py[371-400]
src/defib/rockusb/recovery.py[150-158]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
The rockusb command path does not check how many bytes PyUSB reports as written for the command wrapper or optional data payload. It must reject short writes before reading the CSW so incomplete WRITE_LBA transfers cannot be reported successful.
## Issue Context
The recovery write path supplies whole sector payloads to this command method. Existing residue checks only inspect the device's CSW and do not validate the host endpoint write result.
## Fix Focus Areas
- src/defib/rockusb/device.py[371-378]
- src/defib/rockusb/device.py[384-400]
- src/defib/rockusb/recovery.py[150-158]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


View high (6)
4. Wrong board may be flashed ✓ Resolved 🐞 Bug ≡ Correctness
Description
find_device() returns the first Rockchip VID match, so a rack with multiple Rockchip boards can
upload to or flash a board other than the one just power-cycled. Re-enumeration also searches for
any loader-mode device, allowing the recovery session to switch boards mid-operation.
Code

src/defib/rockusb/device.py[R102-103]

+    for dev in usb.core.find(**kwargs):
+        return FoundDevice(
Evidence
Discovery returns immediately for the first VID match, while both the CLI's initial open and
download_boot() call the unfiltered waiter. No bus, port path, serial, or prior-device identity is
carried into the re-enumeration search.

src/defib/rockusb/device.py[94-110]
src/defib/rockusb/device.py[135-141]
src/defib/cli/app.py[3724-3729]
src/defib/rockusb/recovery.py[88-95]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
USB discovery selects the first Rockchip device and can flash the wrong board.
## Issue Context
Preserve a stable device identity across initial enumeration and loader-mode re-enumeration, and expose enough selection information for multi-board racks.
## Fix Focus Areas
- src/defib/rockusb/device.py[94-141]
- src/defib/rockusb/recovery.py[88-95]
- src/defib/cli/app.py[3699-3735]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


5. Images exceed partition bounds ✓ Resolved 🐞 Bug ≡ Correctness
Description
_install_usb_async() passes each unbounded tar payload to write_image using only its partition's
starting LBA, without checking its padded sector length against the next partition or device
capacity. An oversized boot or U-Boot image can therefore overwrite adjacent partitions or run
beyond flash while the install still reports success.
Code

src/defib/cli/app.py[R3894-3896]

+            await recovery.write_image(
+                lba, data, on_progress=_usb_progress_printer(output)
+            )
Evidence
The RV1106 profile defines partition starting LBAs but no lengths, and the installer reads each full
tar member and forwards it to write_image without a maximum size. Because write_image pads the
payload and writes every resulting sector without boundary or capacity checks, no layer prevents a
member from crossing the next partition start or exceeding the device.

src/defib/profiles/data/rv1106.json[6-13]
src/defib/cli/app.py[3888-3896]
src/defib/rockusb/recovery.py[127-151]
src/defib/profiles/data/rv1106.json[6-14]
src/defib/cli/app.py[3875-3880]
src/defib/cli/app.py[2318-2326]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
Rockchip USB image writes are not constrained to partition or flash boundaries. A payload larger than the space between its partition's starting LBA and the next partition can overwrite adjacent partitions, and the final payload can exceed device capacity.
## Issue Context
Account for 512-byte padding, and encode or derive safe partition extents before calling `write_image`, which intentionally writes every padded sector supplied to it. Validate each payload against its available partition extent and validate the final partition against device capacity before performing any write.
## Fix Focus Areas
- src/defib/cli/app.py[3859-3899]
- src/defib/profiles/schema.py[90-96]
- src/defib/profiles/data/rv1106.json[6-14]
- src/defib/rockusb/recovery.py[127-151]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


6. Idblock is written too early ✓ Resolved 🐞 Bug ☼ Reliability
Description
The installer writes images in tar member order, so idblock.img can be committed before U-Boot,
kernel, and rootfs. If a later write fails or power is lost, flash may contain a valid IDB that
prevents automatic MaskROM entry while the remaining boot contents are incomplete, defeating this
recovery path.
Code

src/defib/cli/app.py[3888]

+        for name, partition, lba, data in payloads:
Evidence
Tar members are retained in archive order, _map_usb_images() preserves that order, and the write
loop consumes it unchanged. The recovery module states that absence of a valid IDB is what makes an
erased or half-written board automatically enter MaskROM, so committing IDB before all dependent
images removes that failure-safe state.

src/defib/cli/app.py[3866-3880]
src/defib/cli/app.py[3888-3904]
src/defib/rockusb/recovery.py[3-14]
tests/test_profiles_usb_recovery.py[93-96]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
USB installation can make the device boot-attemptable before all other images are safely written.
## Issue Context
Order all non-IDB writes first and write `idblock` only after they succeed and, when requested, verify. Keep the IDB-last invariant independent of tar member ordering.
## Fix Focus Areas
- src/defib/cli/app.py[3866-3904]
- src/defib/rockusb/recovery.py[127-161]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


7. PoE port is discarded ✓ Resolved 🐞 Bug ≡ Correctness
Description
The USB path always calls power_cycle("") and never receives poe_port_override, so RouterOS
recovery addresses an empty interface instead of the requested Ethernet port. Consequently
--power-cycle --poe-port etherN fails for USB-recovery chips even though the same CLI options work
on the UART paths.
Code

src/defib/cli/app.py[3720]

+            await controller.power_cycle("")
Evidence
Both public commands accept and pass poe_port_override only as far as their dispatch functions,
but USB dispatch omits it and _open_usb_target() supplies an empty string. RouterOS serializes
that argument directly as the .id of the interface to power-cycle.

src/defib/cli/app.py[24-42]
src/defib/cli/app.py[67-69]
src/defib/cli/app.py[3713-3722]
src/defib/power/routeros.py[343-357]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
USB recovery discards the CLI PoE port and sends an empty RouterOS interface identifier.
## Issue Context
Thread `poe_port_override` into the USB burn/install helpers and retain the existing controller-specific resolution behavior; single-port controllers may continue using an empty value.
## Fix Focus Areas
- src/defib/cli/app.py[67-68]
- src/defib/cli/app.py[2244-2248]
- src/defib/cli/app.py[3699-3722]
- src/defib/power/routeros.py[343-357]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


8. Incomplete transfers report success ✓ Resolved 🐞 Bug ≡ Correctness
Description
RockusbDevice.command() parses the CSW residue but ignores it whenever status is OK. A
device-reported partial read or write is therefore treated as complete, allowing an unverified
install to announce success after data was not transferred.
Code

src/defib/rockusb/device.py[R332-333]

+        _, residue, status = parse_csw(csw, expected_tag=tag)
+        if status != CommandStatus.OK:
Evidence
The command computes an expected transfer length, reads the returned residue, and then checks only
status. Callers count the whole requested chunk as written and concatenate reads without any
independent completeness check unless the optional end-to-end verify is enabled.

src/defib/rockusb/device.py[304-317]
src/defib/rockusb/device.py[319-338]
src/defib/rockusb/recovery.py[141-161]
src/defib/rockusb/recovery.py[163-174]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
Commands accept successful CSWs that explicitly report untransferred data.
## Issue Context
Validate residue and actual IN/OUT byte counts against the requested transfer length before returning success, with any opcode-specific exceptions made explicit.
## Fix Focus Areas
- src/defib/rockusb/device.py[287-338]
- src/defib/rockusb/recovery.py[127-180]
- src/defib/rockusb/protocol.py[124-150]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


9. Accepts incomplete firmware tarballs ✓ Resolved 🐞 Bug ≡ Correctness
Description
The USB installer proceeds whenever it finds any mapped image, so a tarball containing only
rootfs.squashfs.rv1106 is written, reset, and reported as a successful install without the
required boot image. The existing install path explicitly rejects firmware unless it contains both
kernel and rootfs, and the USB tests define the supported NOR artifact as the
zboot.img/rootfs.squashfs pair.
Code

src/defib/cli/app.py[R3869-3873]

+        targets = _map_usb_images(names, partitions)
+        if not targets:
+            raise typer.BadParameter(
+                f"nothing in {tar_path.name} maps to a partition "
+                f"(saw: {', '.join(names) or 'no files'})"
Evidence
The changed code rejects only an entirely unmapped archive, then writes every independently mapped
member. Existing installer behavior and the new test fixture establish that a complete firmware
needs both images.

src/defib/cli/app.py[3817-3836]
src/defib/cli/app.py[3866-3874]
src/defib/cli/app.py[3888-3904]
src/defib/cli/app.py[2293-2295]
tests/test_profiles_usb_recovery.py[108-115]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
The Rockchip USB installer accepts a partially populated firmware tarball whenever at least one member maps to a partition. It can consequently overwrite only rootfs (or only boot) and report a successful complete installation.
## Issue Context
The supported RV1106 NOR firmware consists of both `zboot.img.<soc>` and `rootfs.squashfs.<soc>`.
## Fix Focus Areas
- src/defib/cli/app.py[3866-3874]
- src/defib/cli/app.py[3888-3904]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools



Remediation recommended

10. USB install breaks JSON ✓ Resolved 🐞 Bug ◔ Observability
Description
The USB-recovery rejection in _install_async() always prints Rich text, even when --output json
was requested. Automation expecting JSON receives a non-JSON error and cannot parse the command
result consistently.
Code

src/defib/cli/app.py[R2240-2244]

+        console.print(
+            f"[red]{chip} recovers over USB, and `install` does not support "
+            "that yet — use `defib burn` to bring the board up.[/red]"
+        )
+        raise typer.Exit(1)
Evidence
The install command exposes --output, but this newly added branch unconditionally uses
console.print; the USB failure helper elsewhere in the same module establishes that JSON mode
emits a structured error object.

src/defib/cli/app.py[2114-2127]
src/defib/cli/app.py[2235-2244]
src/defib/cli/app.py[3746-3759]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
Unsupported USB installs always emit Rich text, violating `--output json`.
## Issue Context
Use the existing USB error-output convention so JSON callers receive an error event before the nonzero exit.
## Fix Focus Areas
- src/defib/cli/app.py[2235-2244]
- src/defib/cli/app.py[3746-3759]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


11. Profile errors become UART ✓ Resolved 🐞 Bug ≡ Correctness
Description
recovery_mode() catches every ValueError from load_profile(), including unknown variants,
alias-depth failures, JSON decode errors, and Pydantic validation errors, and silently classifies
the chip as UART. For inputs such as rv1106:unknown, both burn and install consequently enter
the serial workflow instead of reporting the invalid USB profile selection.
Code

src/defib/profiles/loader.py[R117-120]

+    try:
+        return load_profile(chip_name, profiles_dir).recovery
+    except (FileNotFoundError, ValueError):
+        return "uart"
Evidence
load_profile() explicitly raises ValueError for an unknown variant and validates profile
contents before returning, while the new helper catches that broad base class. The two newly
modified command paths use this helper as the discriminator, so a USB chip with an invalid variant
is misrouted.

src/defib/profiles/loader.py[39-59]
src/defib/profiles/loader.py[79-93]
src/defib/profiles/loader.py[110-120]
src/defib/cli/app.py[69-75]
src/defib/cli/app.py[2235-2244]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
Recovery-mode detection suppresses real profile and variant errors by treating all `ValueError`s as a missing UART profile.
## Issue Context
Only profile absence is the documented reason to default to UART; malformed profiles and invalid variants should propagate to the caller.
## Fix Focus Areas
- src/defib/profiles/loader.py[110-120]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


12. Missing loaders bypass JSON errors ✓ Resolved 🐞 Bug ☼ Reliability
Description
When neither --loader nor both --ddr/--usbplug are supplied, _resolve_usb_loader() raises
typer.BadParameter, but _burn_usb_async() catches only RockusbError, LoaderFormatError, and
OSError. Therefore defib burn -c rv1106 --output json emits Typer's parameter error instead of
this flow's JSON error event.
Code

src/defib/cli/app.py[R3787-3795]

+    try:
+        blobs = _resolve_usb_loader(chip, ddr, usbplug, loader)
+        recovery = await _open_usb_target(
+            blobs, power_cycle, output, wait, poe_port_override, usb_path,
+            recovery_ids,
+        )
+        flash_id = await recovery.read_flash_id()
+    except (RockusbError, LoaderFormatError, OSError) as e:
+        _usb_fail(output, str(e))
Evidence
The loader resolver raises typer.BadParameter for the no-loader case, whereas the surrounding new
exception handler excludes that type and _usb_fail() is the code which produces the JSON error
object.

src/defib/cli/app.py[3678-3691]
src/defib/cli/app.py[3787-3795]
src/defib/cli/app.py[3746-3759]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
The required-loader validation throws `typer.BadParameter`, which is not handled by the USB error reporter in JSON mode.
## Issue Context
USB loader resolution is intentionally inside this handler so USB failures produce structured output; the missing-arguments case needs the same treatment.
## Fix Focus Areas
- src/defib/cli/app.py[3678-3691]
- src/defib/cli/app.py[3787-3795]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


View medium (9)
13. USB interface is never released ✓ Resolved 🐞 Bug ☼ Reliability
Description
_open_usb_target() opens and claims a RockusbDevice, but _burn_usb_async() never closes the
final loader-mode handle after the flash-ID probe or when probing or upload fails. Repeated
in-process recovery or CLI calls can therefore retain claimed interfaces, libusb resources, and
detached kernel drivers until process teardown, preventing later attempts from opening the board
cleanly.
Code

src/defib/cli/app.py[R3793-3803]

+        flash_id = await recovery.read_flash_id()
+    except (RockusbError, LoaderFormatError, OSError) as e:
+        _usb_fail(output, str(e))
+        return
+
+    if output == "json":
+        print(json_mod.dumps({
+            "event": "done", "success": True, "flash_id": flash_id.hex(),
+        }))
+    elif output != "quiet":
+        console.print(f"  Flash ID: {flash_id.hex()}")
Evidence
The target-opening helper explicitly calls device.open() and returns only its RockchipRecovery
wrapper, while the burn caller reads the flash ID and returns without invoking cleanup on either
success or failure. RockusbDevice.close() performs the required interface release, USB resource
disposal, and kernel-driver reattachment; re-enumeration closes only the obsolete MaskROM handle,
not the final loader-mode device.

src/defib/cli/app.py[3727-3743]
src/defib/cli/app.py[3793-3803]
src/defib/rockusb/recovery.py[54-55]
src/defib/rockusb/device.py[359-367]
src/defib/cli/app.py[3787-3807]
src/defib/rockusb/recovery.py[96-110]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
The USB burn path opens and claims a `RockusbDevice` but never releases the final loader-mode device on success or failure, leaking its claimed interface and libusb resources.
## Issue Context
`RockusbDevice.close()` releases the claimed interface, disposes USB resources, and restores a detached kernel driver. Expose this cleanup through `RockchipRecovery` or retain the device as a context-managed resource, and ensure the final post-re-enumeration device is closed in a `finally` block.
## Fix Focus Areas
- src/defib/cli/app.py[3727-3743]
- src/defib/cli/app.py[3787-3807]
- src/defib/rockusb/device.py[359-367]
- src/defib/rockusb/recovery.py[51-56]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


14. Short uploads count complete ✓ Resolved 🐞 Bug ☼ Reliability
Description
_upload() discards the byte count returned by control_write() and advances progress by the full
chunk length, even when the USB control transfer wrote fewer bytes. A truncated DDR/usbplug upload
is thus treated as complete and only surfaces later as a misleading re-enumeration timeout.
Code

src/defib/rockusb/recovery.py[R121-126]

+        for chunk in chunks:
+            await asyncio.to_thread(self._device.control_write, code, chunk)
+            sent += len(chunk)
+            _emit(
+                on_progress,
+                ProgressEvent(stage, sent, len(blob), f"{name} -> {code:#06x}"),
Evidence
The device layer returns written from the control transfer, but the recovery layer ignores it and
increments sent using the requested chunk length. Bulk CBW and data writes similarly discard their
returned counts.

src/defib/rockusb/device.py[308-324]
src/defib/rockusb/recovery.py[119-127]
src/defib/rockusb/device.py[371-378]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
MaskROM upload progress assumes each control transfer writes its entire chunk and ignores the returned transfer count.
## Issue Context
`RockusbDevice.control_write()` already returns the number of bytes written. Compare it with the payload length and raise `RockusbUsbError` immediately on a short write; apply equivalent checks to bulk OUT writes where appropriate.
## Fix Focus Areas
- src/defib/rockusb/device.py[308-324]
- src/defib/rockusb/device.py[371-378]
- src/defib/rockusb/recovery.py[119-127]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


15. Reset failures report success ✓ Resolved 🐞 Bug ≡ Correctness
Description
RockchipRecovery.reset() suppresses every RockusbUsbError, including failures sending the reset
command or explicit failed status responses rather than only the expected disconnect while reading
its acknowledgement. _install_usb_async() then emits a successful completion even if the device
never reset and remains in loader mode.
Code

src/defib/rockusb/recovery.py[R196-203]

+        try:
+            await asyncio.to_thread(
+                self._device.command, Opcode.RESET_DEVICE, subcode=int(subcode)
+            )
+        except RockusbUsbError as e:
+            # The device is entitled to drop off the bus before it acknowledges
+            # its own reset, so a failed status read here is expected.
+            logger.debug("reset ack not received (device already gone): %s", e)
Evidence
device.command() wraps failures from CBW transmission, data/status reads, bad command status,
residue, and framing in the same USB error hierarchy. reset() catches that entire hierarchy, after
which the install path unconditionally emits its done/success output.

src/defib/rockusb/device.py[371-399]
src/defib/rockusb/recovery.py[189-203]
src/defib/cli/app.py[4004-4014]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
The reset method treats every USB command failure as an expected post-reset disconnect, masking failures that happen before the device accepted the reset.
## Issue Context
Differentiate a disconnect while awaiting the reset CSW from CBW-send, command-status, and other protocol failures. Propagate failures that do not prove the reset was issued so the installer cannot report a false success.
## Fix Focus Areas
- src/defib/rockusb/recovery.py[189-203]
- src/defib/rockusb/device.py[371-399]
- src/defib/cli/app.py[4004-4014]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


16. Archive errors bypass JSON ✓ Resolved 🐞 Bug ◔ Observability
Description
_install_usb_async() opens, parses, and validates the firmware tarball before entering the handler
that calls _usb_fail(), and that handler does not catch tarfile.TarError; consequently, missing,
malformed, truncated, unreadable, or validation-failing archives can produce Typer text or an
uncaught exception instead of the USB path's structured JSON error event.
Code

src/defib/cli/app.py[R3972-3976]

+    tar_path = Path(firmware_path)
+    if not tar_path.exists():
+        raise typer.BadParameter(f"firmware not found: {firmware_path}")
+
+    payloads = _read_usb_payloads(tar_path, partitions)
Evidence
_usb_fail() is the only helper that emits the USB JSON error object, but _read_usb_payloads()
directly opens and validates the tar archive before the install try block begins at line 3978.
Because the subsequent handler catches only Rockusb, loader, and OS errors—not archive and
validation failures such as tarfile.TarError—these failures cannot reach _usb_fail().

src/defib/cli/app.py[3754-3767]
src/defib/cli/app.py[3889-3946]
src/defib/cli/app.py[3972-4007]
src/defib/cli/app.py[3894-3900]
src/defib/cli/app.py[3972-3982]
src/defib/cli/app.py[4004-4007]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
Firmware archive existence checks, loading, parsing, and validation occur outside the USB command's structured error handler. Malformed or otherwise invalid archives can therefore bypass `_usb_fail()`, preventing JSON mode from emitting a structured error event.
## Issue Context
Move archive existence checks, tar parsing, and payload validation into the guarded flow. Translate expected `typer.BadParameter`, `tarfile.TarError`, decoding, and I/O failures through `_usb_fail()` when JSON output is requested, while retaining normal CLI diagnostics for human-readable output; `_read_usb_payloads()` currently calls `tarfile.open()` directly, and archive-format failures are not among the exceptions caught by the current handler.
## Fix Focus Areas
- src/defib/cli/app.py[3889-3946]
- src/defib/cli/app.py[3972-4007]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


17. Claim failure leaves driver detached ✓ Resolved 🐞 Bug ☼ Reliability
Description
open() detaches an active kernel driver before claiming the interface, but a claim failure raises
without restoring that driver. A failed recovery attempt can leave the interface detached and
unavailable to the kernel driver that previously owned it.
Code

src/defib/rockusb/device.py[R277-293]

+        number = self._interface.bInterfaceNumber
+        try:
+            if dev.is_kernel_driver_active(number):
+                dev.detach_kernel_driver(number)
+                self._detached_interface = number
+        except (NotImplementedError, usb.core.USBError):
+            # Not all backends/platforms implement this; only Linux binds a
+            # kernel driver here in the first place.
+            pass
+
+        try:
+            usb.util.claim_interface(dev, number)
+        except usb.core.USBError as e:
+            raise RockusbUsbError(
+                f"cannot claim interface {number} on {self._found}: {e} "
+                "(need a udev rule for 2207:* or root)"
+            ) from e
Evidence
The added code records and detaches the active driver, then raises directly from the
claim_interface exception handler. Reattachment exists only in close(), which is not reached
when open() fails before construction completes.

src/defib/rockusb/device.py[277-293]
src/defib/rockusb/device.py[295-304]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
If claiming the selected USB interface fails after its active kernel driver was detached, `open()` raises immediately and never reattaches the driver.
## Issue Context
The detached interface number is already retained in `_detached_interface`, and normal teardown attempts to restore it. Ensure the claim-failure path performs equivalent restoration, ideally with cleanup that cannot mask the original claim error.
## Fix Focus Areas
- src/defib/rockusb/device.py[277-293]
- src/defib/rockusb/device.py[295-304]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


18. Wrong interface remains attached ✓ Resolved 🐞 Bug ☼ Reliability
Description
RockusbDevice.open() detaches a kernel driver only from interface 0, but it may discover and claim
the bulk endpoints on another interface. When that selected interface has a kernel driver, claiming
it fails even though endpoint discovery succeeded.
Code

src/defib/rockusb/device.py[R191-193]

+            if dev.is_kernel_driver_active(0):
+                dev.detach_kernel_driver(0)
+                self._detached = True
Evidence
The code hard-codes interface 0 for detach and reattach, then scans every configured interface and
claims the interface containing the endpoint pair. These can be different interface numbers.

src/defib/rockusb/device.py[190-197]
src/defib/rockusb/device.py[206-240]
src/defib/rockusb/device.py[247-255]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
Kernel-driver handling targets interface zero rather than the interface selected for bulk transfers.
## Issue Context
Discover the interface first, detach and claim that interface number, and track the detached number so close reattaches the same interface.
## Fix Focus Areas
- src/defib/rockusb/device.py[181-245]
- src/defib/rockusb/device.py[247-256]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


19. Empty loaders pass validation ✓ Resolved 🐞 Bug ≡ Correctness
Description
parse_loader() accepts an RKBOOT container with zero DDR or usbplug entries and returns empty
required-stage lists. Recovery then silently skips that upload and waits for a re-enumeration that
cannot have been initiated, turning a malformed loader into a misleading device timeout.
Code

src/defib/rockusb/loader.py[R148-150]

+    return LoaderBlobs(
+        ddr=_parse_entries(data, off471, size471, n471),
+        usbplug=_parse_entries(data, off472, size472, n472),
Evidence
Zero table counts make _parse_entries() return an empty list, and the parser returns those lists
unchanged. download_boot() only uploads inside loops over those lists and then unconditionally
closes the MaskROM handle and waits for loader mode.

src/defib/rockusb/loader.py[89-118]
src/defib/rockusb/loader.py[142-153]
src/defib/rockusb/recovery.py[74-95]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
RKBOOT parsing accepts containers that omit required DDR-init or usbplug entries.
## Issue Context
Reject empty 471 or 472 tables with `LoaderFormatError` before returning `LoaderBlobs`; apply equivalent nonempty validation to raw blobs.
## Fix Focus Areas
- src/defib/rockusb/loader.py[59-69]
- src/defib/rockusb/loader.py[121-153]
- src/defib/rockusb/recovery.py[74-95]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


20. Loader errors bypass JSON output ✓ Resolved 🐞 Bug ◔ Observability
Description
Both USB commands resolve and parse loader files before entering their RockusbError handling
blocks, so missing files and malformed RKBOOT containers escape as uncaught exceptions. In `--output
json` mode this produces no structured error event and can emit a traceback instead of the promised
machine-readable result.
Code

src/defib/cli/app.py[R3773-3776]

+    blobs = _resolve_usb_loader(chip, ddr, usbplug, loader)
+
+    try:
+        recovery = await _open_usb_target(blobs, power_cycle, output, wait)
Evidence
_resolve_usb_loader() performs read_bytes() and parse_loader(), which raises
LoaderFormatError, but burn invokes it before its try block. Install likewise resolves the loader
and profile before its try block, while _usb_fail() is only reached for errors caught later.

src/defib/cli/app.py[3667-3696]
src/defib/cli/app.py[3740-3779]
src/defib/cli/app.py[3855-3883]
src/defib/rockusb/loader.py[121-133]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
Loader file and format failures occur outside USB CLI error handling.
## Issue Context
Catch filesystem, loader-format, profile-validation, power-controller, and USB failures at the command boundary and route them through the same human/JSON failure formatter without masking programmer errors.
## Fix Focus Areas
- src/defib/cli/app.py[3667-3696]
- src/defib/cli/app.py[3740-3779]
- src/defib/cli/app.py[3855-3907]
- src/defib/rockusb/loader.py[121-153]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


21. Skips firmware checksum validation ✓ Resolved 🐞 Bug ☼ Reliability
Description
The USB path explicitly excludes .md5sum members but never validates the corresponding extracted
image bytes, unlike the existing installer. A corrupted tarball can therefore be flashed and
reported successful, including when --verify is used, because verification compares flash against
the same corrupted input.
Code

src/defib/cli/app.py[R3866-3869]

+    with tarfile.open(tar_path) as tar:
+        members = [m for m in tar.getmembers() if m.isfile()]
+        names = [m.name for m in members if not m.name.endswith(".md5sum")]
+        targets = _map_usb_images(names, partitions)
Evidence
The new archive scan filters checksum files and reads image bytes with no digest comparison. The
previous installer contains the checksum validation that this new route bypasses, while USB
--verify compares only readback to the supplied data.

src/defib/cli/app.py[3866-3880]
src/defib/cli/app.py[3894-3902]
src/defib/cli/app.py[2297-2316]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
The Rockchip USB install flow ignores checksum members rather than validating them. It can write corrupted image contents and optional readback only confirms that those same corrupted bytes reached flash.
## Issue Context
The established UART installer checks `.md5sum` members before flashing.
## Fix Focus Areas
- src/defib/cli/app.py[3866-3880]
- src/defib/cli/app.py[3894-3902]
- src/defib/cli/app.py[2297-2316]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools



Informational

22. Progress exceeds one hundred percent ✓ Resolved 🐞 Bug ◔ Observability
Description
_upload() uses the byte count of framed control-transfer chunks as bytes_sent while retaining
the unframed blob length as bytes_total; every upload includes a CRC and some also include a
terminator packet. JSON progress consequently reports values above 100% (for example, a 3-byte blob
reports 5 of 3 bytes).
Code

src/defib/rockusb/recovery.py[R121-130]

+        chunks = build_maskrom_chunks(blob, use_rc4=use_rc4)
+        sent = 0
+        for chunk in chunks:
+            # Count what the wire took, not what we handed it. control_write
+            # already rejects a short transfer; crediting the full chunk here
+            # anyway would make the progress bar lie about it.
+            sent += await asyncio.to_thread(self._device.control_write, code, chunk)
+            _emit(
+                on_progress,
+                ProgressEvent(stage, sent, len(blob), f"{name} -> {code:#06x}"),
Evidence
The recovery code adds each returned control-transfer count and publishes it against len(blob),
while the framing code appends two CRC bytes and conditionally a further terminator byte; the JSON
callback serializes the resulting percentage directly.

src/defib/rockusb/recovery.py[121-130]
src/defib/rockusb/maskrom.py[63-85]
src/defib/cli/app.py[3651-3657]
tests/test_rockusb_maskrom.py[27-30]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
MaskROM progress counts CRC and terminator transfer bytes in `sent` but not in `total`, producing invalid percentages.
## Issue Context
Either track original payload bytes separately or set the progress total to the sum of framed chunk sizes.
## Fix Focus Areas
- src/defib/rockusb/recovery.py[121-130]
- src/defib/rockusb/maskrom.py[63-85]
- src/defib/cli/app.py[3651-3657]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


Grey Divider

Comment thread src/defib/rockusb/device.py Outdated
Comment thread src/defib/cli/app.py Outdated
Comment thread src/defib/cli/app.py Outdated
Comment thread src/defib/cli/app.py Outdated
Comment thread src/defib/rockusb/device.py
Comment thread src/defib/rockusb/device.py Outdated
Comment thread src/defib/rockusb/loader.py
Comment thread src/defib/cli/app.py Outdated
Comment thread src/defib/cli/app.py Outdated
Comment thread src/defib/cli/app.py Outdated
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>
@widgetii

Copy link
Copy Markdown
Member Author

Addressed all ten in 6d1164d. Every one was real — thanks, several were things no test I'd written would have caught.

# Finding Fix
1 Wrong board may be flashed Identify boards by USB port path, the one identity that survives re-enumeration. Refuse to guess when several are attached; pin the post-upload wait to the port the board was found on. New --usb-path.
2 Images exceed partition bounds PARTITIONS now carries an extent, not just a start LBA. Oversized images are refused by name and size.
3 Idblock written too early Written last, whatever order the tarball lists.
4 PoE port discarded poe_port_override threaded through both USB commands.
5 Incomplete transfers report success CSW residue is now checked, and short IN reads too.
6 Accepts incomplete tarballs Requires the kernel/rootfs pair, matching the UART installer.
7 Wrong interface remains attached Detach and reattach the interface actually claimed.
8 Empty loaders pass validation Containers declaring no DDR or usbplug entries are rejected; raw_blobs() rejects empty images.
9 Loader errors bypass JSON output Loader resolution moved inside the handler.
10 Skips firmware checksum validation Shipped .md5sums are verified before anything is written.

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. --verify cannot substitute for the checksums, because it compares flash against the same bytes that were sent — a corrupt download verifies perfectly. The shipped .md5sums are the only thing that catches it. That guard immediately paid for itself: it rejected one of my own synthetic test tarballs that carried a placeholder digest.

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.

@widgetii

Copy link
Copy Markdown
Member Author

/review

Comment thread src/defib/cli/app.py Outdated
Comment thread src/defib/rockusb/recovery.py Outdated
Comment thread src/defib/rockusb/recovery.py Outdated
Comment thread src/defib/cli/app.py Outdated
Comment thread src/defib/rockusb/device.py
Comment thread src/defib/rockusb/device.py
@qodo-free-for-open-source-projects

Copy link
Copy Markdown

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>
@widgetii

Copy link
Copy Markdown
Member Author

Second pass addressed in 83ae4aa — all six new findings, all real.

# Finding Fix
1 Foreign SoC images accepted The SoC suffix is now checked against the chip instead of discarded.
2 Short bulk writes pass Bulk command-wrapper and payload writes must move every byte.
9 Short uploads count complete MaskROM control transfers must too, and progress counts what the wire took.
10 Reset failures report success Only the missing status wrapper is tolerated, and only on request.
11 Archive errors bypass JSON Tarball open/validate moved inside the structured-error handler.
12 Claim failure leaves driver detached The kernel driver is restored before the error propagates.

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 reset() wasn't careless — a board genuinely is entitled to drop off the bus while acknowledging its own reset. But that's true of the status read, not of the command. Sending failed, or an explicit failure status, meant the board never reset and the install still said it had. The tolerance is now scoped to the status wrapper alone and has to be opted into.

#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 hi3516ev300-suffixed tarball is refused naming both SoCs; a corrupt archive and an incomplete one both emit {"event": "error", ...} in JSON mode rather than a traceback; short writes and reset send-failures raise where they happen.

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>
@widgetii

Copy link
Copy Markdown
Member Author

Now verified on real hardware — a Luckfox Pico Max (RV1106G3)

7c2b122. The protocol layer had full green tests and typed clean, and still did not move a single byte of flash. Seven bugs, none reachable without a board on the bench.

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 bcdUSB, and I 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. The real difference is string descriptors: the boot ROM ships a bare one, the usbplug names itself RockChip / USB-MSC. The OUT endpoint also moves 0x02 → 0x01 between stages, which vindicates reading endpoints from the descriptor.

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 made a healthy running board 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. Its unread 13-byte status wrapper gets read as the next command's data phase — into a 5-byte buffer that is [Errno 75] Overflow, and everything after desynchronises. Drain on open, the same way the serial transports open by reading until the line goes quiet. This also made a wedged device recoverable without a power cycle, which I confirmed by accident and then on purpose.

Also: bulk IN buffers must be a multiple of wMaxPacketSize; SET_CONFIGURATION on an already-configured device resets the data toggles under a running usbplug; and residue is honoured only on the LBA path — other opcodes report their transfer length byte-swapped, i.e. "none of it arrived", while the data plainly did. xrock never inspects residue at all, which is presumably why that is undocumented.

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.

Evidence

Found maskrom device 2207:110c
  ddr : 22634 bytes in 6 chunks -> 0x0471  OK
  plug: 52742 bytes in 13 chunks -> 0x0472  OK
re-enumerated mode=loader
  TEST_UNIT_READY    OK
  READ_FLASH_ID      534e414e44   ("SNAND")
  READ_CAPABILITY    7f07000000000000
  READ_FLASH_INFO    00fc070000010400280001
  READ_LBA sector 0  residue=0

READ_LBA at sector 0 returned the env partition — CRC 0x7b64e37c, mtdparts, sys_bootargs and 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. So the LBA space maps exactly onto the mtd layout and the profile's offsets are now confirmed, not assumed.

751 tests, ruff and mypy clean. The generic rv1106_usbplug_v1.09.bin was sufficient — the w25n01xx variant was never needed, and the DDR v1.10/v1.15 mismatch turned out not to matter.

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>
@widgetii widgetii changed the title Recover Rockchip boards over USB (RV1106 / Luckfox Pico) Wake a bricked Rockchip board over USB (RV1106 / Luckfox Pico) Aug 19, 2026
@widgetii

Copy link
Copy Markdown
Member Author

/review

Comment thread src/defib/cli/app.py Outdated
Comment thread src/defib/profiles/loader.py
Comment thread src/defib/cli/app.py
Comment thread src/defib/cli/app.py
Comment thread src/defib/cli/app.py
Comment thread src/defib/rockusb/recovery.py Outdated
@qodo-free-for-open-source-projects

Copy link
Copy Markdown

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>
@widgetii

Copy link
Copy Markdown
Member Author

Addressed in 504886f. Six findings, and four were introduced by the two commits immediately before them — which is the case for re-reviewing after a rewrite rather than assuming the earlier pass still applies.

# Finding Fix
1 Advertises unsupported flash command burn now says the board is awake and in loader mode
10 USB install breaks JSON Rejection routed through the structured-error path
11 Profile errors become UART Defaults to UART only when the chip has no profile; real problems surface
12 Missing loaders bypass JSON typer.BadParameter now caught alongside the rest
13 USB interface never released close() in a finally
22 Progress exceeds 100% Both ends measure framed bytes

#1 was the one worth catching. 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. I broke it and didn't notice because I was looking at the removal, not what still referred to it.

#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 except ValueError to keep the V500/CV6xx families working — they have no JSON profile — but caught far more than that case. rv1106:typo silently became a UART chip and then failed complaining about a serial port. Narrowed to FileNotFoundError, which is the case the fallback was actually for.

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 {"event": "error"} in JSON.

@widgetii
widgetii merged commit 52760b2 into master Aug 20, 2026
13 checks passed
@widgetii
widgetii deleted the feat/rockchip-maskrom-recovery branch August 20, 2026 05:20
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.

1 participant