feat(firmware): standalone bootloader health-check and soft-reset (closes #299) - #375
Conversation
…set (closes #299) Add IPic32BootloaderDiagnostics with CheckBootloaderHealthAsync and ResetBootloaderAsync, implemented on FirmwareUpdateService, so consumers (e.g. daqifi-desktop's recovery/manual bootloader dialog) can probe or reset a bootloader session without kicking off a full erase/program flash. Both reuse the existing private connect/retry/version/soft-reset plumbing via a new RunBootloaderDiagnosticAsync helper that serializes on the same operation lock and HID transport as the full update flow, rejects reentrancy from an in-flight update, and always releases the HID handle. Unlike an update these do not drive the update state machine (CurrentState stays Idle). Failures throw FirmwareUpdateException with RecoveryGuidance. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
/agentic_review |
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
PR Summary by QodoAdd standalone PIC32 bootloader health check and soft reset
AI Description
Diagram
High-Level Assessment
Files changed (3)
|
Code Review by Qodo
1.
|
…soft reset ResetBootloaderAsync issued the JMP_TO_APP HID write directly, bypassing ExecuteWithStateTimeoutAsync, so the configured JumpingToApplicationTimeout was not applied to the standalone soft-reset step. A blocking write (or one that ignores cancellation) could run unbounded and appear hung to callers. Wrap the write in ExecuteWithStateTimeoutAsync(JumpingToApp) to match the full update flow, preserving the existing JumpingToApp error mapping. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
/agentic_review |
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
Code review by qodo was updated up to the latest commit 4a94caa |
…BootloaderDiagnostics The diagnostic methods also throw ArgumentException (whitespace path), ObjectDisposedException (disposed service), InvalidOperationException (reentrancy / non-idle), and propagate OperationCanceledException — none of which were on the interface contract. Add <exception> tags and clarify <remarks> so consumers handle the right surface. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
Valid — fixed in the latest commit. /agentic_review |
… failed" A health check or soft reset is what a consumer runs *instead of* starting an update, but both routed through CreateFirmwareUpdateException, which hard-codes "Firmware update failed in state 'X' while Y." A recovery dialog would tell a user their firmware update failed when none was ever attempted. Confirmed on a real Nq1: probing with no bootloader present emitted "Firmware update failed in state 'WaitingForBootloader'". CreateFirmwareUpdateException gains a failureSubject parameter defaulting to "Firmware update", so the update flow's wording is unchanged (pinned by a new regression test); the diagnostics pass "Bootloader health check" and "Bootloader soft reset". Also corrects the IPic32BootloaderDiagnostics throw contract, which claimed InvalidOperationException is raised "when another firmware operation is in flight". Only reentrancy from an in-flight operation's own synchronous callback throws — a concurrent call from a separate execution context waits on the shared lock and then proceeds. Documents two further behaviours callers need: the 45s default WaitingForBootloaderTimeout means a probe against a device that is not in bootloader mode blocks that long, and a failed health check does not imply an update would fail, since the check deliberately skips the #298 JMP_TO_APP self-heal the update flow applies. Adds 8 tests: message wording for both diagnostics plus an update-flow regression guard, ResetBootloaderAsync disposed-guard symmetry, cancellation for both methods, callback-reentrancy rejection, and the concurrent-call-waits semantics the docs now promise. Bench-tested end to end on a real Nq1 (fw 3.7.2) — no flash written: FORceBoot -> HID 04D8:003C -> health check returns bootloader version 1.4 in ~18ms -> repeat on the same service and from a fresh instance both succeed (the real proof the HID handle is released at OS level) -> connect-by-path targeting works, bogus path correctly refuses to fall back -> JMP_TO_APP in ~117ms -> device returns to app mode with firmware version and serial number unchanged. Full suite green on net9.0 and net10.0 (1783 tests, 1781 passed, 2 skipped), 0 warnings. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Closes #299.
The problem
FirmwareUpdateServicealready knows how to talk to the PIC32 HID bootloader — wait for it to enumerate, connect over HID, read its version, and issue aJMP_TO_APPsoft reset. But every one of those steps isprivate, reachable only by running a fullUpdateFirmwareAsync.So today, the only way to answer "is this bootloader session healthy?" is to hand over a real
.hexfile and erase/reprogram flash. There is no way to just look.daqifi-desktop#630 needs to ask that question before committing to a flash. Its recovery / manual-bootloader dialog wants to probe a device the moment it's grabbed, and to force a clean USB re-enumeration when the session looks wrong — neither of which should go anywhere near flash contents.
The fix
Two methods on a new narrow interface,
IPic32BootloaderDiagnostics, implemented byFirmwareUpdateService:CheckBootloaderHealthAsync(string? targetDevicePath, CancellationToken)ResetBootloaderAsync(string? targetDevicePath, CancellationToken)JMP_TO_APPsoft reset to force a clean USB re-enumeration.Neither erases nor programs flash. Both reuse the existing private plumbing —
WaitForBootloaderDeviceAsync,ConnectToBootloaderWithRetryAsync,RequestBootloaderVersionAsync, and the bootloader protocol'sCreateJumpToApplicationMessage— so there is no duplicated HID transport handling.A separate interface (rather than new members on
IFirmwareUpdateService) keeps the update interface focused on updates and stays additive: no existing implementer or caller breaks.How it works
A new
RunBootloaderDiagnosticAsynchelper wraps both operations. It:_operationLockand HID transport the full update flow uses, so a diagnostic can never interleave with an in-flight update;CheckWifiFirmwareStatusAsyncprobe, a diagnostic owns the HID connect/version/reset exchange);finally, so a later update or diagnostic starts from a clean transport;CurrentStatestaysIdle, because a health check is not a firmware update.Each step is bounded by the matching per-state timeout (
WaitingForBootloader,Connecting,JumpingToApp), and failures throw the existingFirmwareUpdateExceptioncarryingFailedStateandRecoveryGuidancefor the phase where they occurred.Tests
20 new xUnit tests in
FirmwareUpdateServiceTestscover: healthy version read, connect-by-path targeting, invalid-version failure, no-bootloader timeout, whitespace-path /ObjectDisposedException/ cancellation guards on both methods, theJMP_TO_APPwrite, reset failure,JumpingToApptimeout enforcement on a hung write, service reusability after a diagnostic, failure-message wording, callback-reentrancy rejection, and the concurrent-call-waits contract.Full suite green on net9.0 and net10.0 — 1783 tests, 1781 passed, 2 skipped. Release build is 0-warning on both targets.
Hardware validation
Validated end to end on a real Nyquist (Nq1, firmware 3.7.2) over USB on macOS, driving the new API through the native IOKit HID backend rather than a mocked transport. No flash was erased or programmed — the loop is
SYSTem:FORceBoot→ probe →JMP_TO_APP, none of which touches flash contents.Happy path:
04D8:003C("USB HID Bootloader").CheckBootloaderHealthAsync()returns the live bootloader version1.4in ~20 ms.CheckBootloaderHealthAsync(devicePath)succeeds against the real device path.ResetBootloaderAsync()issuesJMP_TO_APPin ~115 ms; the HID bootloader disappears and the serial port re-enumerates.CurrentStatestaysIdleacross every diagnostic.Failure paths:
FirmwareUpdateExceptionwithFailedState == WaitingForBootloader,TimeoutExceptioninner, populatedRecoveryGuidance, and theWaitingForBootloadertimeout honored exactly (4.0 s configured → 4.0 s elapsed over 16 real HID enumeration polls).targetDevicePathdoes not silently fall back to the first enumerated bootloader — it fails inWaitingForBootloaderand names the requested path in the message.targetDevicePathis rejected up front by both methods, before any polling.CurrentStatestaysIdleafter a failed diagnostic, and the transport is left disconnected.Cancellation and stability:
OperationCanceledException— not at the 30 s state timeout — confirming the token threads through the real IOKit enumeration loop, so callers can bound the wait as the docs advise.JMP_TO_APPcycles all passed, every cycle read bootloader version1.4, and firmware version + serial number stayed unchanged throughout — no HID-handle leak or cumulative wedge across repeated probes (the daqifi-desktop#630BootloaderWatcherusage pattern).Not merging — opened for review.
🤖 Generated with Claude Code