feat(firmware-upload): add data model, APJ reader and bootloader codec - #2027
feat(firmware-upload): add data model, APJ reader and bootloader codec#2027iacker wants to merge 10 commits into
Conversation
Coverage Report for CI Build 33909389085Coverage at 89.404% (no base build to compare)Details
Uncovered ChangesNo uncovered changes found. Coverage RegressionsNo coverage regressions found. Coverage Stats
💛 - Coveralls |
|
I helped out a bit and did two backend commits. |
13498f4 to
9979e66
Compare
There was a problem hiding this comment.
Warning
Copilot couldn't run its full agentic review because it didn't start before the timeout. Make sure your repository has a runner available, or add a copilot-code-review.yml file specifying one with the runs-on attribute. See the docs for more details.
Pull request overview
Adds the initial firmware-upload feature foundation (APJ parsing + bootloader protocol codec + upload workflow) to support issue #2017 without introducing UI/serial-MAVLink coupling in the domain model.
Changes:
- Introduces a pure firmware-upload domain model (APJ parsing, compatibility rules, state machine, typed errors).
- Adds a bootloader adapter/client implementing the ArduPilot/PX4 serial bootloader protocol and a FlightController facade entrypoint.
- Adds extensive unit tests (APJ parsing, protocol encoding/decoding, fake bootloader transport, reconnection flow) and an architecture document.
Reviewed changes
Copilot reviewed 8 out of 8 changed files in this pull request and generated 7 comments.
Show a summary per file
| File | Description |
|---|---|
| tests/test_data_model_firmware_upload.py | New unit tests for APJ parsing, compatibility checks, state machine, and codec behavior with a fake bootloader. |
| tests/test_backend_flightcontroller_bootloader.py | New unit tests for the bootloader backend/client (short reads, rev2 vs rev5, ext flash, retries, facade integration). |
| ardupilot_methodic_configurator/data_model_firmware_upload.py | New domain model: types, parsing, bounds checks, padding, CRC, compatibility policy, and upload state transitions. |
| ardupilot_methodic_configurator/backend_flightcontroller_protocols.py | Adds active_baudrate to the connection protocol for reconnecting after flashing. |
| ardupilot_methodic_configurator/backend_flightcontroller_connection.py | Tracks and exposes active_baudrate across connect/retry flows. |
| ardupilot_methodic_configurator/backend_flightcontroller_bootloader.py | New backend module: APJ file reading limit, bootloader packet codec, BootloaderClient, retrying backend adapter. |
| ardupilot_methodic_configurator/backend_flightcontroller.py | Adds upload_apj_firmware() facade method coordinating bootloader entry, flashing, and reconnection. |
| ARCHITECTURE_firmware_upload.md | New architecture/design doc for firmware upload flow and layering. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
| if image.metadata.extf_image_size: | ||
| stage = UploadStage.ERASING | ||
| self._report(progress_callback, stage, 0, 1) | ||
| self._erase_external(image.metadata.extf_image_size) | ||
| self._report(progress_callback, stage, 1, 1) | ||
| stage = UploadStage.PROGRAMMING | ||
| chunks = program_chunks(image.extf_image) | ||
| for index, chunk in enumerate(chunks, start=1): | ||
| self._command(encode_extf_prog_multi(chunk)) | ||
| self._report(progress_callback, stage, index, len(chunks)) |
| - `backend_flight_controller_firmware_upload.py` is the I/O adapter. It owns serial | ||
| ports, MAVLink bootloader-entry/reboot commands, the ArduPilot bootloader protocol, | ||
| firmware file reading, and progress events. | ||
| - `data_model_firmware_upload.py` is the business/domain model. It owns firmware | ||
| metadata, board compatibility, validation, workflow state, and user-facing error | ||
| classifications. It does not open files, access serial ports, use Tkinter, or talk | ||
| directly to the flight controller. | ||
| - `frontend_firmware_upload.py` is the GUI. It owns file selection, confirmation, | ||
| progress presentation, cancellation, and translated user messages. It delegates | ||
| validation and upload operations to the model and backend. |
| network_prefixes = ("udp:", "udpin:", "udpout:", "tcp:", "tcpin:", "tcpout:", "ws:", "wss:") | ||
| device = self.comport_device | ||
| if self.master is None or self.comport is None or not device or device.lower().startswith(network_prefixes): | ||
| msg = _("firmware upload requires an active direct serial flight-controller connection") | ||
| raise FirmwareFileError(msg) |
| def program_chunks(image: bytes) -> list[bytes]: | ||
| return [image[offset : offset + PROG_MULTI_MAX] for offset in range(0, len(image), PROG_MULTI_MAX)] |
| for _unused in range(len(self.image), flash_size - 1, 4): | ||
| state = crc32(b"\xff\xff\xff\xff", state) |
| if len(encoded) > MAX_ENCODED_BLOB_SIZE: | ||
| msg = _("APJ {key} exceeds {limit} encoded bytes").format(key=key, limit=MAX_ENCODED_BLOB_SIZE) | ||
| raise FirmwareFileError(msg) | ||
| compressed = base64.b64decode(encoded, validate=True) |
| except (KeyError, TypeError, ValueError, zlib.error) as exc: | ||
| msg = _("APJ {key} is not valid base64+zlib data: {error}").format(key=key, error=exc) | ||
| raise FirmwareFileError(msg) from exc |
9979e66 to
2ac9d9b
Compare
310caad to
ab04b0a
Compare
There was a problem hiding this comment.
🟡 Changes recommended
Unresolved critical and moderate findings affect firmware authenticity, device identity, timeout and recovery handling, metadata validation, and workflow consistency.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Review details
Suppressed comments (4)
ARCHITECTURE_firmware_upload.md:55
- The diagram advertises BIN input, but
load_apj()explicitly rejects every non-.apjfile and the firmware-formats section says raw BIN is not supported. Label this as APJ input so the architecture reflects the implemented scope.
BACKEND --> FILE[Local firmware file\nAPJ/BIN input]
ARCHITECTURE_firmware_upload.md:227
- This state sequence contradicts the integration workflow below, which requests final confirmation after bootloader identification, and the backend emits that latter order. Update this sequence so the documented state machine matches the implemented data flow.
`idle → inspecting → awaiting_confirmation → entering_bootloader → identifying →
erasing → programming → verifying → rebooting → reconnecting → completed`
ardupilot_methodic_configurator/backend_flightcontroller_bootloader.py:614
- A successful
abort_before_erase()sendsREBOOT, but the loop then retries opening the bootloader without issuing another bootloader-entry command. On real hardware the first successful abort has already returned the board to the application, so identification retries cannot work; on the final attempt the code also reports that a power cycle is required even after a successful reboot. Keep the board in bootloader mode between retries and perform recovery only when retries are exhausted.
if not client.abort_before_erase():
client.close()
msg = _("cannot reboot the held bootloader; power-cycle the flight controller before reconnecting")
raise FirmwareBootloaderRecoveryError(msg) from exc
client.close()
ardupilot_methodic_configurator/backend_flightcontroller_bootloader.py:161
- When both USB location and serial number were captured, this
oradmits a device that matches only one attribute. If the original controller is absent and another device occupies the same USB location with a different serial number, it becomes the sole match and can be opened for flashing. Require every populated identity field to match.
matches = [
port.device
for port in serial.tools.list_ports.comports()
if (identity.location and getattr(port, "location", "") == identity.location)
or (identity.serial_number and getattr(port, "serial_number", "") == identity.serial_number)
]
- Files reviewed: 8/8 changed files
- Comments generated: 8
- Review effort level: Balanced
| if sys_platform.startswith("linux"): | ||
| try: | ||
| current = Path(device).resolve(strict=True) | ||
| for by_path in Path("/dev/serial/by-path").iterdir(): | ||
| if by_path.resolve(strict=True) == current: |
| def firmware_content_sha256(image: bytes, extf_image: bytes = b"") -> str: | ||
| """Hash both raw APJ payload regions with unambiguous length prefixes.""" | ||
| digest = hashlib.sha256() | ||
| for region in (image, extf_image): | ||
| digest.update(struct.pack("<Q", len(region))) |
| while len(received) < size: | ||
| chunk = self._transport.read(size - len(received)) | ||
| if not chunk: | ||
| if self._clock() < deadline: | ||
| continue | ||
| msg = _("timeout waiting for {expected} bootloader bytes; received {actual}").format( | ||
| expected=size, actual=len(received) | ||
| ) | ||
| raise BootloaderProtocolError(msg) | ||
| received.extend(chunk) |
| stage = UploadStage.AWAITING_CONFIRMATION | ||
| self._report(progress_callback, stage, 0, 1) |
| except FirmwareUploadError as exc: | ||
| if stage in {UploadStage.IDENTIFYING, UploadStage.AWAITING_CONFIRMATION}: | ||
| self.abort_before_erase() | ||
| if isinstance(exc, BootloaderProtocolError): | ||
| exc.stage = stage.value | ||
| raise |
| self._device = device | ||
| self._device_resolver = device_resolver | ||
| self._device_identity = device_identity | ||
| self._baudrate = baudrate | ||
| self._timeout = timeout | ||
| self._enter_bootloader = enter_bootloader | ||
| self._serial_factory = serial_factory |
| try: | ||
| board_id = int(desc["board_id"]) | ||
| image_size = int(desc["image_size"]) | ||
| extf_image_size = int(desc.get("extf_image_size", 0)) | ||
| except (KeyError, TypeError, ValueError) as exc: | ||
| msg = _("APJ descriptor is missing or has invalid metadata: {error}").format(error=exc) | ||
| raise FirmwareFileError(msg) from exc |
| def open_serial_transport(device: str, baudrate: int, timeout: float) -> BootloaderTransport: | ||
| """Open the real serial transport with explicit read/write timeouts.""" | ||
| return cast("BootloaderTransport", serial.Serial(device, baudrate, timeout=timeout, write_timeout=timeout, exclusive=True)) |
Previous review (2026-09-07)Automated review note — AI-generated (Claude), validated against the live diff. Please sanity-check before acting. Reviewed at head The protocol layer is careful work — all 27 opcodes and response codes match Verdict: REQUEST CHANGESBUG —
|
Previous review (2026-09-07)Automated review note — AI-generated (Claude), validated against the live diff. Please sanity-check before acting. Re-reviewed at head Previous round: four of six findings are properly fixed and pinned — including BUG A, the one that would have broken every external-flash upload. The verdict stays REQUEST CHANGES, but for different reasons than last time: two of the fixes do not work in the production wiring, and I proved both end-to-end rather than by reading. CI is also newly red on two jobs that pass on master. Previous round
Verdict: REQUEST CHANGES1. BUG —
|
3106554 to
7f26f78
Compare
Previous review (2026-09-08)Automated review note — AI-generated (Claude), validated against the live diff (Claude + Codex cross-checked). Please sanity-check before acting. Re-reviewed at head Both blocking bugs from the last round are genuinely fixed, and I proved both by execution.
93 firmware-upload tests at head, up from 88. Verdict is still REQUEST CHANGES, but for a new regression and two things the fixes did not reach — not for lack of progress. 1. BUG —
|
7f26f78 to
a211c3f
Compare
Previous review (2026-09-08)Automated review note — AI-generated (Claude), validated against the live diff (Claude + Codex cross-checked). Please sanity-check before acting. Re-reviewed at head Full report: https://uav.tridgell.net/DevCallReviews/followups/2026_09_09_0533/devcall_pr_reviews.html#prMethodicConfigurator-2027 Every fix from the last round survived the rewrite, and I proved each one by mutation rather than by reading.The force-push was a rewrite, not a re-split (2 commits, rebased onto current master), which is exactly where a fix goes missing quietly. None did. Nine mutations against a 204-test baseline, all nine caught:
That is the item I asked for most insistently last round, and it is done. Per-finding:
Feature tests: 101, up from 93. Verdict stays REQUEST CHANGES, for one new regression. 1. BUG — the
|
|
Fixed the reconnect loop in 4470778. connect() returns an error string instead of raising, so the loop now retries on a non-empty return until the deadline. The new test fails on the previous head. I also dropped the unread interface field and the redundant deadline check in _read_exact. The coverage gate is red on master too, same 88 percent. |
Previous review (2026-09-09)Automated review note — AI-generated (Claude), validated against the live diff (Claude + Codex cross-checked). Please sanity-check before acting. Re-reviewed at head Last round's blocking regression is genuinely fixed — I proved it by A/B against the old head
Replaying my previous repro: old head And the removed read timeout — the change I most expected to have to object to — is behaviour-preserving. The loop-top check runs on every iteration including the one right after a partial read, so the deleted lines only removed a raise the next iteration performs anyway. Five constructed transports driven through the real The blocker: the tool can flash a different flight controller than the one selectedThis is the carried-over identity issue, and it is no longer theoretical — two reproductions now open, erase and program the wrong fake board. (a) (b) Both live at I am raising this to a blocker rather than carrying it as an open issue again, and I want to be explicit about why, since I did not do so last round: for a tool whose job is to flash flight controllers, "opens the wrong board" is the one failure mode that has to be closed before merge, and it now has two reproduced mechanisms rather than one argued one. The rest of this PR is in good shape. Also worth fixing
Notes
Checked and clean: 27 protocol constants re-derived from CI: every real job green — all four Verdict: REQUEST CHANGES — on the identity binding alone. Everything this round changed is an improvement, and the reconnect fix is solid. |
Previous review (2026-09-09)Automated review note — AI-generated (Claude), validated against the live diff (Claude + Codex cross-checked). Please sanity-check before acting. Re-reviewed at head The identity blocker is closed. I replayed both wrong-board reproductions against the old head and confirmed they are rejected at this one, then reverted each half of the fix and watched its own regression test go red. REQUEST CHANGES → COMMENT. Previous round — triage
105 tests pass across the three touched files; 176 across the wider firmware set. ISSUE — the new USB binding is silently skipped for a symlinked device path
So the strong USB-serial binding that closes the blocker is discarded for exactly the user who took the trouble to address the board by its stable ID. It degrades to the weaker topology binding rather than to nothing, so this is not a live wrong-board bug — but it is the one input where the new guarantee quietly does not apply, and it is reachable from NOTE — the gate that enforces all of this has no regression guardDeleting the Correcting myself: I also flagged the Smaller notes
Checked and cleanHoisting CI: 17 pass / 1 fail — Verdict: COMMENT — no blockers. The two carried-over items are hardware-settled questions, which makes the unticked "Tested on flight controller hardware" box the last real gate on this PR. |
2ca0d87 to
433ff9f
Compare
amilcarlucas
left a comment
There was a problem hiding this comment.
This looks good, thanks
Previous review (2026-09-09)Automated review note — AI-generated (Claude), validated against the live diff (Claude + Codex cross-checked). Please sanity-check before acting. Re-reviewed at head Two of the previous round's findings are resolved and I verified both by mutation. But the commit's macOS dual-CDC fix cannot work on macOS, and the test covering it asserts a port shape pyserial never produces there — so COMMENT → REQUEST CHANGES. Previous round — triage
ISSUE —
|
433ff9f to
1b2662f
Compare
Previous review (2026-09-09)Automated review note — AI-generated (Claude), validated against the live diff. Please sanity-check before acting. Re-reviewed at head Full report: https://uav.tridgell.net/DevCallReviews/2026_09_10_AIReview/devcall_pr_reviews.html#prMethodicConfigurator-2027 Verdict: REQUEST CHANGES → COMMENT. You resolved every actionable item from the last round, and I proved each by fetching both heads into worktrees and executing them side by side rather than by reading the diff. Previous round — triage
The
|
Previous review (2026-09-09)Automated review note — AI-generated (Claude), validated against the live diff. Please sanity-check before acting. Re-reviewed at head Full report: https://uav.tridgell.net/DevCallReviews/followups/2026_09_10_0530/devcall_pr_reviews.html#prMethodicConfigurator-2027 Every one of the eleven open items from my last comment is resolved, and I proved each coverage claim by mutation rather than by reading the tests. That is a thorough response and the engineering is good. Despite that I am moving the verdict COMMENT → REQUEST CHANGES, and I want to be clear it is not because you regressed anything. Two things drive it: a one-word typo in a pylint pragma that is currently breaking CI, and a reconnect defect that predates this push and that my earlier rounds missed. The two things to fix1. def parse_apj(...) -> FirmwareImage: # pylint: too-many-localsIt is missing def parse_apj(...) -> FirmwareImage: # pylint: disable=too-many-locals2. The match requires the captured I executed this against your real resolver with realistic pyserial values and Note This is not something this push introduced — I ran the same case at Also new, lower stakes
Previous round — all resolved, each proved by mutationBaseline at head: 236 passed in 7.4 s for the six firmware-upload test files, up from 186. Full non-GUI suite 3838 passed, no collateral damage.
CI — six red, and only one is yoursThe previous round's reading ("the only failure is the pre-existing coverage gate") no longer applies; that gate never ran.
So the pragma fix should turn three of the six green, and the other three are a runner package-index problem a re-run will clear. One thing from the thread, not from meCopilot's comment that Checked and cleanThe deletions are surgical: |
51ac9c2 to
ce70b95
Compare
Previous review (2026-09-09)Automated review note — AI-generated (Claude), validated against the live diff. Please sanity-check before acting. Re-reviewed at head Verdict: REQUEST CHANGES — one new blocker. Everything from the previous four rounds is closed, and the protocol layer I now consider verified rather than plausible. Resolved since
|
| port | at fa46be448c |
at ce70b955c5 |
|---|---|---|
/dev/ttyACM0 (:1.0) |
SUCCESS, reconnected | FirmwareReconnectError ← regression |
/dev/ttyACM1 (:1.2) |
FirmwareBootloaderRecoveryError | FirmwareReconnectError ← improved, flash now happens |
ttyACM0 is the port users normally connect on (OTG1 / SERIAL0), so the previously-working path is the one that broke. Windows is affected identically — list_ports_windows.py:366-369 appends ':{}.{}', giving 1-4:x.0 / 1-4:x.2. macOS is unaffected by this change (both its CDC ports already share one location; that ambiguity is the pre-existing, deliberate fail-closed).
Blast radius, measured on ArduPilot master rather than estimated: 87 hwdefs declare OTG2 in SERIAL_ORDER directly, 134 resolve to it after the include chain, and 131 of those ship a bootloader — so roughly 130 application boards, including Pixhawk6X, CUAV-V6X, ARKV6X and mRoPixracerPro. (A second reviewer using ArduPilot's own hwdef parser got 86 and 133; treat the number as ~130 rather than exact.)
This is an availability bug, not a safety one — the physical USB port still cannot match a different board, so no wrong board can be opened.
Suggested fix — prefer an exact location match among the physical-location matches, inserted before the interface tie-break at :188:
if len(matches) > 1 and identity.location:
exact = [port for port in matches if str(getattr(port, "location", "") or "") == identity.location]
if len(exact) == 1:
matches = exactThat keeps the bootloader case working (no exact match there, so the single physical match wins) and restores the reconnect. Both reviewers ran it independently: all four dual-CDC cases resolve correctly with Linux and Windows metadata, the end-to-end facade runs return SUCCESS, they fail again when the patch is removed, and the existing 169 focused tests pass with and without it.
Notes
- The test gap that hid it —
tests/test_bootloader_identity.py:58-68pins the new behaviour with only a single bootloader port present. Your suite does already cover multiple ports from one device, in the macOS tests at:89; the specifically missing case is two application-mode ports with an emptyinterface(the Linux/Windows shape). One test with two ports at1-2.3:1.0/1-2.3:1.2, same serial,interface=None, asserting the captured port is returned, closes it. - The head commit message describes code that no longer exists —
ce70b955c5mentions "before using a Linux by-path fallback" and "unverifiable Linux by-path identities". That fallback was deleted two heads ago. Worth fixing before the squash, since the message outlives the thread. - (re-raised) Sleep injection still not pinned — deleting
sleep=self._sleepfrom:689leaves all 169 tests green, andbackend_flightcontroller.py:419, 431, 440still call the module-globaltime_sleep. Cosmetic; the only one of nine mutations that survived. - (re-raised) The interface tie-break is inert on real hardware and does not say so —
:185-187explains the semantics but never states that the field isNoneon Linux (every ArduPilot hwdef CDC descriptor hasiInterface=0) and unset on Windows, so the branch cannot fire on real ArduPilot hardware. One sentence. - (re-raised) PR body is stale — it says "107 tests"; the five firmware-upload test files now collect 170. "Tested on flight controller hardware" is still unticked, which matters here because the finding above is a hardware-only failure mode.
What was checked and came out clean
All 23 opcode/status/INFO bytes and PROG_MULTI_MAX = READ_MULTI_MAX = 252 match Tools/scripts/uploader.py:209-254 exactly, and your tests pin all 23 as literals plus 10 full wire frames as literal byte strings — known-good bytes, not encoder/decoder round-tripping, which is the right shape for this. CRC checked numerically twice: bootloader_crc32 vs uploader.crc32 over 500 random (data, state) pairs including empty input, 65 537 bytes and chained state — 0 mismatches; the second reviewer repeated it with 1 000 randomised comparisons plus 100 APJ/internal/external CRC comparisons, also clean. Against a real CubeOrange arducopter.apj (1 497 213 B): padded image byte-identical to uploader.py's (1 644 436 B), crc(1966080) = 0x81365DF8 and crc(2097152) = 0x8125DA3B identical, and 15× faster. Your _pad4 pads with 0xFF, which is what the erased-flash CRC needs — upstream's bytes(0xFF) actually appends 255 zero bytes per iteration, so you are the more correct of the two. <I throughout, and mutating either _encode_external_size or decode_uint32 to >I is caught. identify() and upload ordering, rev-2-vs-rev-3 reboot ACK, ERASE_TIMEOUT = 20.0, EXTF_CRC_TIMEOUT = 10.0, the 2.0 s default and exclusive=True all match upstream. 20 malformed-APJ cases each raise FirmwareFileError with zero unhandled exceptions, and a 1 GiB zlib bomb is rejected in 0.14 s. Nine mutations, eight caught. 3 074 passed / 49 skipped across all backend, data-model and bootloader tests. Copilot's _erase_external size point is a non-issue (uploader.py:962 passes extf_image_size too).
Signed-off-by: Billard <82095453+iacker@users.noreply.github.com>
Move APJ file reading and bootloader protocol handling into a dedicated flight-controller adapter with an injected transport interface. Validate decoded APJ payload sizes exactly, bound decompression, handle malformed board revisions consistently, check padded payload capacity, and support the ArduPilot 33-to-9 board compatibility mapping. Implement revision-2 read-back verification, revision-3+ CRC verification, external-flash erase/program/CRC, serial-open retries, erase/CRC timeouts, safe pre-erase cancellation, and guaranteed transport cleanup. Integrate APJ flashing with the FlightController facade: enter bootloader via MAVLink, require a direct serial connection, release and reopen the serial port, reconnect after flashing, and invalidate cached parameters. Add focused tests for protocol revisions, external flash, retries, cancellation, compatibility, parsing validation, and facade lifecycle.
connect() signals failure with a non-empty string, not an exception, so the reconnect loop returned after one attempt whenever the device resolved at once. Also drop the unread SerialDeviceIdentity.interface field and the redundant mid-loop deadline check in _read_exact. Signed-off-by: Billard <82095453+iacker@users.noreply.github.com>
Retry bootloader discovery for up to 15 seconds after reboot and preserve the captured device binding across serial re-enumeration. Resolve USB serial, location, and macOS CDC interface identity before using a Linux by-path fallback, rejecting ambiguous or mismatched controllers. Also bound empty serial reads, validate flash-size alignment, normalize APJ JSON parsing errors, and add regression coverage for timeout and dual-CDC cases. Validate external CRCs and protocol constants, enforce revision-specific verification, and make bootloader timing deterministic through dependency injection. Reject unsafe full-erase requests and unverifiable Linux by-path identities. Harden APJ parsing against trailing data and invalid metadata, and use the optimized CRC implementation. Improve reconnect diagnostics for verified flashes that require manual reconnection, and add regression coverage for the affected behaviors. Add more BDD tests
Prefer an exact interface-qualified USB location when multiple application CDC ports share the same physical device and serial number. Retain physical location matching as the bootloader fallback, since its sole CDC port may use a different interface suffix. Add a regression test for Linux/Windows dual-CDC metadata with unset interface fields, and document why the interface tie-break is usually unavailable on those platforms.
ce70b95 to
184211e
Compare
Verify that bootloader discovery forwards the backend's injected sleep function to BootloaderClient, keeping empty nonblocking reads deterministic and avoiding module-global sleeps in this path.
Previous review (2026-09-10)Automated review note — AI-generated (Claude), independently cross-checked by a second model and re-verified against the live diff. Please sanity-check before acting. Re-reviewed at head Verdict: REQUEST CHANGES → COMMENT. The blocker is fixed, and this is the cleanest round on this PR so far: the patch is exactly what was suggested, it works on Linux and Windows, on both ports, and it is pinned by a test that fails when you revert it. Nothing here blocks merge. Worth stating up front, because it changes how to read the diff: the PR was rebased, so most of the commit range is base movement. Resolved since
|
| scenario | port | at 54fec1a441 |
with :189-192 reverted |
|---|---|---|---|
Linux 1-2.3:1.0/1-2.3:1.2 |
/dev/ttyACM0 |
SUCCESS | FirmwareReconnectError |
| Linux | /dev/ttyACM1 |
SUCCESS | FirmwareReconnectError |
Windows 1-4:x.0/1-4:x.2 |
COM7 |
SUCCESS | FirmwareReconnectError |
| Windows | COM8 |
SUCCESS | FirmwareReconnectError |
The bootloader half still works: identity 1-2.3:1.2 with a single bootloader CDC at 1-2.3:1.0 resolves to /dev/ttyACM0 — no exact match, so the sole physical match wins, exactly as :185-188 claims.
The test gap that hid it → tests/test_bootloader_identity.py:71-85. Deleting :189-192 gives 1 failed, 171 passed, and the failure is precisely that test.
Sleep injection → :698 now passes sleep=self._sleep; removing it fails tests/test_backend_flightcontroller_bootloader.py:673. That was the one mutation of nine that survived last round.
The interface tie-break now says it is inert on real hardware → :196. Accurate: every ArduPilot hwdef CDC descriptor has iInterface=0.
Notes — nothing blocking
- A failure at or after
ERASINGgives a raw protocol string and no recovery guidance.:594-609setsbootloader_rebooted/ attempts an abort only forIDENTIFYINGandAWAITING_CONFIRMATION, sobackend_flightcontroller.py:473-483produces no recovery message forERASING/PROGRAMMING/VERIFYING/REBOOTING. Not reconnecting is right — the board really is still in the bootloader — but a lost erase ACK, a CRC mismatch and a lost reboot ACK all surface as a bareBootloaderProtocolError: timeout waiting for 2 bootloader bytes. The case that matters is the bootloader answering REBOOT withINSYNC+FAILED: ArduPilot'sTools/AP_Bootloader/bl_protocol.cpp:1147-1194doesgoto cmd_failwhenflash_write_flush()or the deferred first-word write fails, and because ArduPilot deliberately defersRESERVE_LEAD_WORDS, flash is then programmed except the vector table — the board will not boot, and the app says only "bootloader reports OPERATION FAILED". It is recoverable (RTC_BOOT_HOLDsetstimeout = 0,Tools/AP_Bootloader/AP_Bootloader.cpp:112-113, so the bootloader stays held), but nothing tells the user that. Suggest reusing your existing "power-cycle the flight controller before reconnecting" wording for any stage ≥ERASING, and saying the flash is incomplete. abort_before_erase()reports success without reading the answer.:366-372returnsTrueif theencode_reboot()write succeeded, so a bootloader replyingINSYNC+INVALID/FAILEDstill setsbootloader_rebooted = Trueand the user is told the board rebooted when it may still be held. Your docstring does say "best-effort", and in this contextcmd_failis unlikely (nothing erased, no deferred first word), so it is the same diagnostics gap as above rather than a flashing hazard.- The 15 s discovery budget is a sleep budget, not a wall clock.
:79-81givesBOOTLOADER_OPEN_RETRIES = 31, but the per-attemptidentify()cost is not counted. With a transport that opens and never answers — a board enumerated but not actually in the bootloader — measured elapsed is 78.1 s against a constant that says 15.0 (77 s analytically at the defaulttimeout=2.0). The loop terminates, cannot spin, and polls cancellation about every 2.5 s, so this is naming rather than a hang. Relatedlytests/test_backend_flightcontroller_bootloader.py:1682asserts on attempts rather than intervals, so dropping the+ 1leaves all 172 tests green even though the budget falls to 14.5 s — the+ 1is right, it just isn't pinned. close()in afinallycan mask the real error.close()(:363-364) is unguarded and called fromupload()'sfinally(:610-611) and:705/:710; a transport whoseclose()raises replaces aBootloaderProtocolErrorwith a bareSerialExceptionthat has no.stage, bypassing the facade's mapping. Caveat: I could not demonstrate a real trigger — pyserial'sserialposix.pyclose()cannot realistically raise.contextlib.suppressis a one-liner if you want it closed anyway.- (re-raised) A commit message still describes deleted code.
by-pathhas zero occurrences in any.py/.mdat this head, but86639df117's body still says "before using a Linux by-path fallback" and "unverifiable Linux by-path identities". The head commit is clean now, so my previous wording no longer applies — but the text survives in the history and in the PR body. - (partly re-raised) PR body. "107 tests" → "172 tests" is correct; I reproduced exactly 172. Still wrong: "with a captured Linux by-path fallback only when no USB metadata is available" describes code that does not exist. Still unticked: "Manual testing performed" and "Tested on flight controller hardware" — which matters here, because the finding just closed was a hardware-only failure mode on roughly 130 application boards.
- (re-raised, cosmetic)
backend_flightcontroller.pystill calls the module-globaltime_sleepat:321,:338,:419,:431,:440.
Two things the cold reviewer raised that I am not passing on as findings
The independent pass reported the macOS dual-CDC reconnect failure and the lost-reboot-ACK path (backend_flightcontroller.py:413) as bugs. Both are real behaviours, neither is a defect introduced here: the macOS case is your documented deliberate fail-closed, and for the lost ACK, ArduPilot sends the COMMAND_ACK before rebooting (libraries/GCS_MAVLink/GCS_Common.cpp:3696, :3708 — "send ack before we reboot"), so it needs a dropped frame rather than normal operation. Flagging them so you know they were considered and set aside.
Checked and clean
reconnect_after_bootloader (backend_flightcontroller.py:422-441) terminates and cannot spin; connect() reports failure by return value, so the loop genuinely spends its budget — your account of what c99a58014d fixed is correct. _wait_for_bootloader now calls abort_before_erase() only on the last attempt (:703), closing the older Copilot point about rebooting the board and then retrying against it. I had a finding about a leaked open serial port on a non-protocol exception and dropped it before writing it: _write, _read_exact and _reset_input_buffer all translate OSError/SerialException into BootloaderProtocolError, and decode_sync/decode_uint32 raise the same, so the except BootloaderProtocolError in _wait_for_bootloader catches everything and client.close() always runs. Parameter-cache invalidation holds on every error path (disconnect() at :360-362). _erase_external cannot loop forever — the deadline resets only on a strictly increasing percentage.
CI: the one red check is Publish Tests Results, and its failing step is Check coverage — coverage report --fail-under=89 against TOTAL 20863 2149 6324 628 88%. master fails identically at the same 88%, as does every recent master run of pytest.yml, so it is not yours. This corrects what I told you last round, where I described this check as reporting success before an unrelated downstream step failed; it is a repo-wide coverage gate. All four pytest matrix jobs are green (ubuntu 3.10/3.14, macOS 3.14, windows 3.14), plus pylint, mypy, pyright, ruff, CodeQL and the Docker build.
6f5c3d4 to
8bf6bc4
Compare
Require a successful reboot acknowledgement before reporting a safe pre-erase abort. Add recovery guidance for failures after erase begins, including incomplete-flash and power-cycle instructions. Bound bootloader discovery by a wall-clock deadline and propagate injected clock and sleep functions through the bootloader workflow. Prevent transport close failures from masking the original upload error. Update ARCHITECTURE_firmware_upload.md to document the recovery and timing guarantees.
8bf6bc4 to
eb720ec
Compare
Previous review (2026-09-10)Automated review note — AI-generated (Claude), independently cross-checked by a second model and re-verified against the live diff. Please sanity-check before acting. Re-reviewed at head Four of the five notes from last round are genuinely closed, and closed the way they were suggested — the 77 s discovery budget is now a real 16 s wall clock, post-erase failures carry power-cycle guidance, You pushed again while I was reviewing ( I'm still moving COMMENT → REQUEST CHANGES, for the other one, which is untouched at this head: a one-line argument that collapses bootloader discovery to a single attempt, undoing the very property Resolved
BUG —
|
| elapsed | open attempts | |
|---|---|---|
54fec1a441 |
77.01 s | 31 |
| this PR | 16.07 s | 1 |
same, only deadline=deadline dropped from :743 |
16.50 s | 6 |
So the wall-clock bound survives without it — attempt_timeout = min(self._timeout, remaining) at :733 already provides it.
This matters because each attempt re-runs self._device_resolver(...) at :773, re-resolving the USB identity to whatever node the bootloader occupies now. That is the entire point of the loop during re-enumeration, and taking longer than one attempt's timeout to re-enumerate is the normal case, not an edge case. Demonstrated end-to-end with a board whose bootloader CDC resolves only after 3 s:
- at this head: FAILED after 15.1 s, opens=1 —
FirmwareBootloaderRecoveryError: cannot reboot the held bootloader; power-cycle... - with the one-line change: SUCCESS after 5.1 s, opens=3, and the 15 s bound still held at 16.50 s.
This is the reason for the verdict. It directly undoes what 86639df117 set out to add.
Already fixed by your eb720ecc87 push — credit where due
I had a second BUG written up here and your mid-review push closed it, so I'm recording it as resolved rather than asking for it. At 8bf6bc422b, abort_before_erase() required an INSYNC reply to REBOOT, which a revision-2 bootloader never sends — so declining the confirmation dialog on a rev-2 board turned FirmwareUploadCancelledError into FirmwareBootloaderRecoveryError: cannot reboot the held bootloader with bootloader_rebooted=False, while the board had in fact rebooted. Proven with your own fixture. eb720ecc87 adds expect_ack at :371 and passes expect_ack=info is None or info.protocol_revision != 2 at both upload() call sites (:611, :628), with a new test test_client_accepts_no_ack_reboot_when_pre_erase_abort_uses_protocol_v2. That's exactly the right shape. One call site was missed — see the first item below.
Also worth fixing
- The timed-out abort is handed a deadline that has already expired — and this is the one
abort_before_erase()call site that missed theexpect_ackfix.:748,abort_before_erase(deadline=deadline). On the discovery-timeout pathdeadlineis by definition in the past, so_read_exact's first check fires before any read — the traced transport events are exactly['write:REBOOT', 'close'], zero read attempts. So the abort always returnsFalsethere and the user is unconditionally told "cannot reboot the held bootloader", even when the reboot went out and was honoured. Note that on this pathidentify()never succeeded, so the revision is unknown, andexpect_ack=info is None or ...resolves unknown to "require the ACK" — the stricter choice. Fixing the expired deadline makes that question live again for rev-2 boards here. Give the abort its own small budget. - The recovery-message handler can crash on an exception with empty
args.:624and:639(unchanged at this head):exc.args = (f"{exc.args[0]}; {recovery_message}", *exc.args[1:]).:639sits in a bareexcept Exception, soexc.args[0]raisesIndexErrorwhenargsis empty — reproduced with a transport whosewrite()raisesRuntimeError()duringPROG_MULTI, yieldingIndexError: tuple index out of range, i.e. the handler destroys the diagnostic it exists to improve. Caveat: I could not find a production path that produces an empty-args exception, so real reachability needs a third-party transport,MemoryErrororStopIteration. Guard is one token:exc.args[0] if exc.args else "".
Second, measurable half of the same point: mutating.argsdoes not changestr()forOSErrorsubclasses —OSError(5, "Input/output error")still prints[Errno 5] Input/output errorafter the rewrite — andserial.SerialExceptionsubclassesOSError, so any un-translated one reaching:639loses the advice entirely. Building a new exception, or storing the advice on an attribute the facade reads, is more robust than rewritingargs.
NOTE — the upload's per-command timeout is inherited from the leftover discovery budget
:733 and :738-742. attempt_timeout is used both to open the port and as BootloaderClient._timeout for the entire erase/program/verify/reboot sequence, and the programming loop calls _command(encode_prog_multi(chunk)) with no explicit timeout. Measured with an injected clock and a port enumerating only at t = 14.8 s, the client's timeout for the whole upload is 0.1 s. With the shipped defaults the floor is ~0.4–0.5 s, so this is unlikely to bite in practice, and ERASE_TIMEOUT/EXTF_CRC_TIMEOUT are passed explicitly and unaffected — but a user-supplied bootloader_timeout is silently reduced by an unrelated budget at exactly the moment the board is being slow. Two different timeouts are being conflated.
Mutation testing — five genuine coverage gaps
Measured at 8bf6bc422b; your mid-review push doesn't touch any of them. Every production hunk reverted in the checkout, full 243-test PR suite re-run. Caught: the close() suppression, the abort ACK, the FirmwareUploadError recovery branch, the backend clock injection, retry_would_exhaust_budget. Survived with nothing failing: the generic-exception recovery branch (the one containing the IndexError above); the retry-sleep clamp; _try_open_transport(timeout) at :734 — its test's assert all(timeout <= 2.0 ...) is vacuous, since self._timeout is 2.0 there; and the entire facade sleep-injection fix, both the sleep= pass-through and all five self._sleep call sites. In fairness, reverting the whole old discovery loop together is caught by test_backend_bootloader_discovery_uses_a_wall_clock_budget, so the new test does pin the original 77 s bug.
Still open from before
- (re-raised)
86639df117still describes "a Linux by-path fallback" and "unverifiable Linux by-path identities";by-pathhas zero occurrences in any.py/.mdat this head. - (re-raised) The PR body still promises that by-path fallback, says "172 tests" where I measure 175, and leaves Manual testing performed and Tested on flight controller hardware unticked. Those two boxes matter more this round, not less — this delta changes the pre-erase abort and the discovery loop, and both bugs above are hardware-shaped.
Checked and cleared
_read_exact's deadline is checked at the top of every loop iteration, so Copilot's inline note about partial reads resetting it is stale — a dribbling transport can't extend it. _erase_external still can't loop forever. The except BootloaderProtocolError in _wait_for_bootloader still catches everything the IO helpers raise, so close() always runs and no port leaks. str() on FirmwareUploadError does follow the mutated args, so the recovery text reaches the facade for the types that matter. The dual-CDC identity fix from last round and its regression test are untouched and still pass, and .interface remains correctly documented as inert on ArduPilot hardware. Nothing in the delta touches APJ parsing, the trusted-digest path, or the stable-identity gate. Locally: ruff check clean, ruff format --check clean, 802 passed / 49 skipped across every firmware and bootloader test file (only failures are tkinter $DISPLAY errors from a headless box).
CI at eb720ecc87: 15 check-runs reconciled against total_count — 6 passing, 0 failing, 0 cancelled, 9 pending; the run was still in flight, so I also ran the linters locally at the reviewed head. Re-verified rather than assumed: the last six pytest.yml runs on master all conclude failure, and in the most recent completed one all four pytest matrix jobs pass with only Publish Tests Results failing — the --fail-under=89 coverage gate is repo-wide and pre-existing, not yours.
Verdict: REQUEST CHANGES
Use per-attempt identification deadlines while preserving the configured command timeout for upload operations. Bound pre-erase reboot recovery with its own timeout and preserve recovery guidance for empty-argument and OSError exceptions. Add regression tests for retry-delay clamping, injected facade sleep callbacks, transport timeout restoration, and empty-message recovery errors.
Inject the facade clock for deterministic reconnect timing and move bootloader entry and reconnection logic into dedicated collaborators. Document the bootloader backend as an internal transport primitive with policy checks owned by the public facade, and update regression tests accordingly.
ea87e41 to
fbfc7a4
Compare
|
Automated review note — AI-generated (Claude), validated against the live diff (Claude + Codex cross-checked). Please sanity-check before acting. Re-reviewed at head Last round's BUG is fixed, and so are the other three code findings. One new problem: this push turns pylint red. The verdict moves REQUEST CHANGES → COMMENT, but the pylint failure needs fixing before merge. Previous findings
Must fix — pylint regression (CI red)
Worth fixing
Note (no change needed)
Checked and clearedThe refactor preserves behaviour:
Locally: 248 PR tests pass, 555 passed / 49 skipped across 15 related test files, and CI at Verdict: COMMENT (fix the pylint failure before merge) |
When bootloader discovery times out, the protocol revision is unknown, so an ACK failure cannot prove that reboot failed. Report that the reboot could not be confirmed while retaining the power-cycle guidance. Also restore the Pylint suppression removed during collaborator extraction, clarify the architecture documentation, and add deterministic coverage for discovery deadlines, abort timing, timeout propagation, recovery messages, and reset delays.
Description
First step of #2017, covering steps 1 and 2 of the implementation sequence. Adds the firmware-upload model and production bootloader integration:
Constants and image padding follow ArduPilot
Tools/scripts/uploader.py.AI assistance was used. I reviewed the changes and ran the tests below.
Checklist
git commit --signoff)Testing