Skip to content

fix(firmware): a running upload can be cancelled, and it says what it is doing - #256

Merged
tylerkron merged 7 commits into
mainfrom
fix/issue-241-firmware-cancel
Sep 6, 2026
Merged

tylerkron merged 7 commits into
mainfrom
fix/issue-241-firmware-cancel

Conversation

@tylerkron

@tylerkron tylerkron commented Sep 5, 2026 •

Copy link
Copy Markdown
Contributor

closes #241

Not merging — for review.

What was wrong

A firmware upload could not be stopped, and while it ran the app would not say what it was doing.

Both halves were absences in XAML, which is why nothing caught them: views here carry no x:DataType, so bindings resolve by reflection and a member nothing binds is indistinguishable from a member nothing has.

  • No way out of a stalled flash. FirmwareDialog.axaml raises a modal scrim over the whole bootloader dialog — including its own Cancel button — for as long as IsFirmwareUploading is true. Under it sat a progress bar and two labels and nothing else. FirmwareDialogViewModel.CancelUploadFirmwareCommand was implemented, had a CanExecute wired through [NotifyCanExecuteChangedFor], and was bound by no view. A flash that hung meant killing the app.
  • No way out of a stalled flash on the device pane either. Same story for DaqifiViewModel.CancelFirmwareUploadCommand, which both the desktop drawer and the mobile pane could have shown and neither did.
  • A narration written to nobody. FirmwareUpdateStatusText was assigned 18 times and read zero times, in C# or XAML — while IFirmwareUpdateHost documents it as "Status line shown beneath the firmware progress bars". During a multi-minute flash the only signal a user had was a percentage.

I re-derived all three claims rather than taking the issue's word for them. All three held; the 18 write sites are 15 in FirmwareUpdateCoordinator and 3 in DaqifiViewModel, and the interface declares the property { set; } only, so it is structurally unreadable through the seam.

How it is fixed

The cancel is pure wiring — three bindings, no new C#, no new command, no new state:

view control
FirmwareDialog.axaml "Cancel Upload" inside the scrim, so it is reachable exactly when the scrim traps you
DevicesPanePrototype.axaml "CANCEL UPDATE" in the desktop device drawer
DevicesMobileView.axaml the same on mobile

Each command's existing CanExecute is IsFirmwareUploading, so it is enabled for precisely as long as an upload is running. #239 (merged earlier today) is what makes this safe to bind: before it, the WiFi-only path cancelled a different CancellationTokenSource than the flash received, so a Cancel button would have armed a control that lies.

The status text is rendered, not deleted — and its dead writes are deleted. I checked the "already shown elsewhere" half of the question rather than only the "unreachable" half. The 16 messages that survive narrate stages (Downloading latest firmware package..., Waiting for WiFi module to enter update mode..., Failed to enter WiFi update mode. Reconnect the device and try again.) and appear nowhere else in the UI: the panes show a percentage, and the outcome dialogs speak only after the run is over. That is a lost capability, not a duplicated one.

Both the status line and the cancel button are gated on IsFirmwareUploading. That single decision is what makes the whole thing honest — the line is unambiguously what the running flash is doing, and it can never be left showing a finished run's last message next to a failure dialog that contradicts it. Outcomes stay where they already are: the success/failure dialogs, HasErrorOccured, IsUploadComplete.

It also creates an invariant the writes must satisfy — no status write reaches a hidden line — and four of them did not:

stateDiagram-v2
    [*] --> Idle
    Idle --> Running: BeginUpload() — CTS created, IsFirmwareUploading = true
    Running --> Canceling: CancelUpload() — "Canceling firmware update..."
    Canceling --> Idle: EndUpload() — CTS dropped, flag false
    Running --> Idle: EndUpload() — success or failure

    note right of Running
        Status line VISIBLE and Cancel ENABLED
        (both bind IsFirmwareUploading)
        All 16 remaining writes happen here
    end note

    note left of Idle
        Status line hidden.
        A write here reaches nobody —
        which is what the two deleted
        DaqifiViewModel writes were.
    end note
Loading

CancelUpload is the one write a run does not make: it gates on the cancellation source, not on the flag, and must keep doing so (a flag with no source announcing a cancellation it cannot deliver is #234). Its announcement is separately gated on the flag, so the two conditions parting in the non-atomic window cannot put text on a hidden line. That one came from Qodo; see Verification.

So the C# change is net-subtractive in write sites, 18 to 16:

  • UploadFirmwareAsync now calls BeginUpload() before "Preparing firmware update..." (a moved line). That message is the one that tells a user the click registered, and it was being written to a hidden control. Everything after BeginUpload() also moved inside the existing try — see the wedge under Verification.
  • "Preparing WiFi firmware update..." moves from DaqifiViewModel into UpdateWifiModuleOnlyAsync, which is where the flag and the token are actually owned.
  • Deleted: the WiFi probe's status write in DaqifiViewModel.TryGetLanChipInfoAsync (the probe hard-returns when IsFirmwareUploading is true, so this can never be visible; the coordinator's identical message covers the during-a-flash case that can), and "WiFi firmware update canceled." in its cancel catch (the coordinator's finally has already cleared the flag by then).

DaqifiViewModel no longer writes the property at all. It now has exactly one writer, the coordinator, through the host seam.

Verification

No live firmware upload was exercised, and could not be: unattended firmware flashing is on this environment's destructive denylist, so no PIC32 or WINC image was written to a board. Everything below is a unit test or a headless render of the real UI. What that leaves unverified is narrow but real: that Core's flasher honours the token promptly mid-transfer, which is a property of Core and of #239, not of this diff.

New tests: 8. Daqifi.Avalonia.Tests/Device/Firmware/FirmwareUploadAffordanceTests.cs. Suite is 512 → 520, all green. Six of the eight were run against origin/main before the fix was written, and all six failed. The other two exist because of defects this branch itself introduced — one found by self-review, one by Qodo — and each fails against the commit that introduced it. Both are described below.

Two things came out of running them unfixed that I did not have going in:

  • The_combined_update_... failed with the whole disconnected-device run producing exactly one status write, "Preparing firmware update...", with the flag False — i.e. the message was not merely unrendered, it was the only one that path ever produced.
  • The_wifi_only_update_... failed with "Downloading WiFi firmware package..." as the first write the coordinator made, confirming the view-model's "Preparing..." never reached the coordinator's run at all.

A wedge I introduced and then removed, worth stating plainly. Moving BeginUpload() above the first status write raised IsFirmwareUploading while three statements were still outside the try. The status setter is bound, so a faulting binding handler throws through it — and a throw there would have left the flag raised with no finally to lower it: the pane stuck "uploading" behind a Cancel button with nothing left to cancel. Everything after BeginUpload() now sits inside the try, in both entry points. A_throw_from_the_first_status_write_still_ends_the_run pins it, and against the intermediate commit it fails with the exception escaping UploadFirmwareAsync entirely, which is exactly the wedge. Found by self-review, not by a reviewer.

A second one, from Qodo, and it was right. Making the status line visible only while the flag is up turned a previously-harmless disagreement into a real one: CancelUpload gates on the cancellation source, so a Cancel that has already read a live source and arrives after the run's completion has lowered the flag would write "Canceling firmware update..." into a hidden control — where the next run would reveal it. The announcement is now separately gated on the flag; the Cancel() itself is not, because a run whose flag has come down must still be stopped. A_cancel_arriving_after_the_flag_is_down_writes_nothing forces that window open through the download seam, and without the gate fails with exactly the predicted write, ("Canceling firmware update...", WasUploading=False).

The XAML facts are deliberately textual — they assert the binding exists in the view and that the member it names is public and readable on the view model. That pair is what a reflection binding needs and is the pair nothing else in the build checks.

Answering "what does a cancel do today": the issue's phrasing left three possibilities. It is no control — not a control that does nothing, and not one that cancels the wrong thing. Both commands and both CanExecute predicates were correct on arrival; #239's five tests already pin the token routing, and I confirmed they still pass unchanged. Before-start, after-completion and gone-away are all covered there and were not weakened.

A permanent visual gate, because this class of break is invisible to the build. tools/parity-audit gained dialog-firmware-uploading, which boots the real app headless, constructs the real FirmwareDialogViewModel, and asserts the Cancel button by AutomationId (IsVisible=True Bounds=185, 242, 130, 30 Text="Cancel Upload"). Its download service is stubbed so the capture never opens a socket. Five determinism runs agreed 25/25 byte-identical, and the same capture reproduced the committed manifest on all 24 existing hashes byte for byte — so this change moved nothing that was already gated, and the recording is purely additive. expected= in the macOS CI step and the README are updated in the same commit, as that README asks.

What was NOT rendered, stated plainly. Only the bootloader dialog is in that gate. The two device-pane blocks are not: the firmware section lives inside the device settings drawer, which the harness never opens (it sweeps the shell's four drawers, not the pane's), and DevicesMobileView has no captured screen at all. Seeding a selected device to reach either is more than this change should carry, and the harness explicitly forbids starting real discovery to get one. Those three bindings rest on the textual + reflection pair above, and on the 24 unchanged baseline hashes showing they cost no layout when hidden. If a reviewer wants that drawer gated, it is a good separate ticket.

Build gate, measured against my own merge-base (origin/main, same worktree, same -t:Rebuild discipline, third_party/oxyplot-avalonia/obj cleared on both sides):

base branch
desktop 191 191
iOS Release iossimulator-arm64 192 192

These are MSBuild all-project totals, not first-party-only (~20 are third_party AVP1002). I also diffed the warning sets, not just the totals: nothing added, nothing removed on either head. packages.lock.json unchanged after every restore. The Android head is not buildable on this machine (workload absent) — CI covers it, and did: the first push went green on all six checks, including Desktop head on macOS, which is where the new baseline hash was reproduced on a runner that is not my laptop.

The instrument that filed the issue now reports it clean. python3 tools/parity-audit/coverage.py --bindings ../daqifi-desktop returns sections B and C populated as before, with no firmware member in either — all three of the issue's rows are gone.

On the shape of the diff

It is net-additive overall (+643/−25), and that deserves a word. The additions are: one test file (432 lines, over half of it fakes), one capture scenario plus its offline stub (76), and XAML bindings whose comment blocks are longer than the controls they explain. The part that changes app behaviour is 5 files and +110/−13, of which the C# is one moved line, one moved message, two deletions, one three-line guard, and an indentation change from widening a try. No upload-state manager, no progress abstraction, no status-sink interface — one command per button, and a property that was already there.

What the fix looks like

The bootloader dialog mid-upload: the scrim over the whole dialog, with the new Cancel Upload button under the progress bar. Rendered by the parity-audit harness from the real dialog and view model.

… is doing

Issue #241. Both cancel commands were implemented, correct and bound by no
view; FirmwareUpdateStatusText was written 18 times and read zero times.

- FirmwareDialog.axaml: the upload scrim covers the dialog's own Cancel
  button, so a stalled bootloader flash could only be escaped by killing the
  app. Adds the only binding of CancelUploadFirmwareCommand.
- DevicesPanePrototype.axaml / DevicesMobileView.axaml: bind
  CancelFirmwareUploadCommand and FirmwareUpdateStatusText, both gated on
  IsFirmwareUploading so the line is unambiguously the running flash.
- Coordinator: open the run before the first status write, and move the
  WiFi-only "Preparing..." message in from the view model, so both land while
  the line is on screen.
- DaqifiViewModel: delete its three status writes. All three ran with
  IsFirmwareUploading false and could never have been displayed.
…l button

The scrim's Cancel is reachable only through a reflection binding in AXAML, so
nothing in the build can see it break — the exact gap CaptureDialogs() was added
for (#213). dialog-firmware-uploading renders it through the real dialog and the
real view-model and asserts the button by AutomationId.

Five determinism runs agreed 25/25, and the same capture reproduced the previous
manifest on all 24 existing hashes byte for byte, so the recording is additive
and this change moved nothing that was already gated.
@tylerkron

Copy link
Copy Markdown
Contributor Author

/agentic_review

@qodo-code-review

Copy link
Copy Markdown

PR Summary by Qodo

Make firmware uploads cancellable and show live status

🐞 Bug fix 🧪 Tests 📝 Documentation ⚙️ Configuration changes 🕐 40+ Minutes

Grey Divider

AI Description

• Exposes existing cancellation commands in bootloader, desktop, and mobile firmware interfaces.
• Shows live firmware stage status only while uploads are active.
• Adds binding invariants and visual parity coverage for the upload scrim.
Diagram

graph TD
  U["User"] --> DP["Desktop Pane"] --> SVM["Shell ViewModel"] --> CO["Update Coordinator"]
  U --> MP["Mobile Pane"] --> SVM
  U --> FD["Firmware Dialog"] --> DVM["Dialog ViewModel"]
  CO -- "live status" --> SVM
  PH["Parity Harness"] -- "captures scrim" --> FD
Loading
High-Level Assessment

The following are alternative approaches to this PR:

1. Adopt compiled Avalonia bindings
  • ➕ Detects missing or renamed view-model members during compilation.
  • ➕ Reduces reliance on textual source assertions for XAML wiring.
  • ➖ Requires adding and maintaining x:DataType across a broader view hierarchy.
  • ➖ Does not replace visual verification that controls remain reachable and rendered.
  • ➖ Substantially expands scope beyond the firmware defect.

Recommendation: The PR's targeted wiring is the best immediate fix because it reuses existing cancellation commands and upload state without duplicating lifecycle logic. Keep the textual binding tests and parity capture for this reflection-bound UI; consider compiled bindings separately as a broader reliability improvement.

Files changed (10) +531 / -13

Bug fix (5) +85 / -6
FirmwareUpdateCoordinator.csPublish preparation status after opening upload runs +10/-1

Publish preparation status after opening upload runs

• Starts combined uploads before publishing their first visible status message. The coordinator now also owns the WiFi-only preparation message so all displayed status writes occur during an active run.

Daqifi.Avalonia/Daqifi.Desktop/Device/Firmware/FirmwareUpdateCoordinator.cs

FirmwareDialog.axamlAdd cancellation inside the firmware upload scrim +17/-2

Add cancellation inside the firmware upload scrim

• Adds a Cancel Upload button bound to the existing cancellation command within the modal upload overlay, keeping cancellation reachable while the underlying dialog is blocked.

Daqifi.Avalonia/Daqifi.Desktop/View/FirmwareDialog.axaml

DevicesPanePrototype.axamlShow desktop firmware status and cancellation +33/-0

Show desktop firmware status and cancellation

• Adds live stage text and a cancellation button for combined and WiFi-only firmware uploads. Both controls are visible only while an upload is active.

Daqifi.Avalonia/Daqifi.Desktop/View/Prototype/DevicesPanePrototype.axaml

DaqifiViewModel.csRemove status writes outside active firmware runs +11/-3

Remove status writes outside active firmware runs

• Deletes preparation, probe, and completion-path messages that were written while the status control was hidden. Status narration is left to the coordinator during active runs.

Daqifi.Avalonia/Daqifi.Desktop/ViewModels/DaqifiViewModel.cs

DevicesMobileView.axamlShow mobile firmware status and cancellation +14/-0

Show mobile firmware status and cancellation

• Adds active-upload stage text and a full-width cancellation button to the mobile device pane, matching desktop behavior.

Daqifi.Avalonia/Views/Mobile/DevicesMobileView.axaml

Tests (3) +430 / -0
FirmwareUploadAffordanceTests.csTest firmware cancellation and status affordances +353/-0

Test firmware cancellation and status affordances

• Adds coordinator tests proving initial status messages are written during active uploads. It also verifies desktop, mobile, and dialog XAML bindings and confirms the shell no longer writes hidden status messages.

Daqifi.Avalonia.Tests/Device/Firmware/FirmwareUploadAffordanceTests.cs

Program.csCapture the active firmware upload dialog +76/-0

Capture the active firmware upload dialog

• Adds an offline, deterministic firmware-dialog scenario with the upload scrim active. The capture inspection requires the new Cancel Upload control to render with its automation identifier.

tools/parity-audit/AvaloniaCapture/Program.cs

macos-arm64.sha256Add the firmware upload dialog baseline +1/-0

Add the firmware upload dialog baseline

• Records the macOS ARM64 hash for the new deterministic firmware-uploading dialog screenshot.

tools/parity-audit/baselines/macos-arm64.sha256

Documentation (1) +11 / -3
README.mdDocument the firmware dialog parity capture +11/-3

Document the firmware dialog parity capture

• Documents the twenty-fifth capture, its purpose, baseline provenance, and the increased dialog-screen count.

tools/parity-audit/README.md

Other (1) +5 / -4
build.ymlRaise the visual capture gate to 25 screens +5/-4

Raise the visual capture gate to 25 screens

• Updates CI documentation and the determinism screen-count assertion for the new firmware-uploading dialog capture.

.github/workflows/build.yml

@qodo-code-review

qodo-code-review Bot commented Sep 5, 2026 •

Copy link
Copy Markdown

Code Review by Qodo

🐞 Bugs (0) 📘 Rule violations (0) 📜 Skill insights (0)

Grey Divider


Remediation recommended

1. Status write can outlive upload ✓ Resolved 🐞 Bug ☼ Reliability
Description
The test comment claims every status write occurs while an upload is active, but CancelUpload can
write its cancellation message after EndUpload clears IsFirmwareUploading. This makes the
documented invariant false and can cause the newly gated status text to be updated while hidden.
Code

Daqifi.Avalonia.Tests/Device/Firmware/FirmwareUploadAffordanceTests.cs[R34-36]

+/// every status write happens inside a run. Four writes did not satisfy it — one was fixed by opening
+/// the run earlier, one moved out of <c>DaqifiViewModel</c> into the coordinator, and two could never
+/// satisfy it from where they stood and were deleted (see <c>DaqifiViewModel</c>).
Relevance

●●● Strong

PR #239 accepted the matching CancelUpload/EndUpload race; the comment’s invariant is demonstrably
false.

PR-#239

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
CancelUpload writes the cancellation status after reading the cancellation-source field but does
not recheck the active-upload flag. EndUpload can clear the source and set IsFirmwareUploading
false between those operations, contradicting the newly documented invariant.

Daqifi.Avalonia.Tests/Device/Firmware/FirmwareUploadAffordanceTests.cs[34-36]
Daqifi.Avalonia/Daqifi.Desktop/Device/Firmware/FirmwareUpdateCoordinator.cs[333-345]
Daqifi.Avalonia/Daqifi.Desktop/Device/Firmware/FirmwareUpdateCoordinator.cs[374-377]

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 test comment states that every `FirmwareUpdateStatusText` write occurs while an upload is active, but cancellation can race with upload completion. `CancelUpload` may retain a non-null cancellation source, then `EndUpload` clears `IsFirmwareUploading`, after which `CancelUpload` writes the cancellation status.

## Issue Context
The status text is now rendered only while `IsFirmwareUploading` is true, so the lifecycle invariant should either be enforced or documented more narrowly. Add a regression test covering cancellation racing with upload completion.

## Fix Focus Areas
- Daqifi.Avalonia/Daqifi.Desktop/Device/Firmware/FirmwareUpdateCoordinator.cs[333-345]
- Daqifi.Avalonia/Daqifi.Desktop/Device/Firmware/FirmwareUpdateCoordinator.cs[374-377]
- Daqifi.Avalonia.Tests/Device/Firmware/FirmwareUploadAffordanceTests.cs[34-36]

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


Grey Divider

Context sources
Review mode: ⚖️ Balanced: The push changes runtime firmware-cancellation behavior around a race between cancellation and upload-state visibility, making it a real concurrency-sensitive change despite its localized scope.

Grey Divider

Tip of the day
💡 Did you know, you can show, collapse, or hide each part of a finding: code, evidence, and all

More tips ↗ | Customize Qodo ↗ | Qodo docs ↗

Grey Divider

Qodo Logo

Self-review of the previous commit. Moving BeginUpload() above the first status
write put the uploading flag up before three statements that were still outside
the try — so a throw from any of them (the status setter is bound, and a faulting
binding handler throws through it) would leave IsFirmwareUploading raised with no
finally to lower it: the pane stuck "uploading" behind a Cancel button with
nothing left to cancel.

Everything after BeginUpload now sits inside the try, in both entry points.
Against the previous commit the new test fails with the exception escaping
UploadFirmwareAsync entirely, which is the wedge.
@tylerkron

Copy link
Copy Markdown
Contributor Author

/agentic_review

@qodo-code-review

Copy link
Copy Markdown

Code review by qodo was updated up to the latest commit 50cbc67

…ount

The ledger's rows 1 and 2 still read as open gaps and its "deliberately not fixed
here" note still blocks on #239, which merged this morning. Struck both rows with
what actually changed; the 2026-09-04 measurement counts above them are left as
recorded, since the file is a dated measurement.

Also: the test file's class comment said two writes were moved and two deleted.
One of the two was not moved — the run is opened earlier so the write it already
made lands inside. Four writes failed the invariant, in three different ways.
@tylerkron

Copy link
Copy Markdown
Contributor Author

/agentic_review

@qodo-code-review

Copy link
Copy Markdown

Code review by qodo was updated up to the latest commit e2d49c2

…dy hidden

Qodo, round 3. CancelUpload gates on the cancellation source rather than on
IsFirmwareUploading, and must keep doing so — a flag with no source announcing a
cancellation it cannot deliver is issue #234. But now that the status line is
rendered only while the flag is up, those two conditions part in the one window
that is not atomic: a Cancel that has already read a live source, arriving after
the run's completion has lowered the flag, wrote "Canceling firmware update..."
into a hidden control, where the next run would reveal it.

The announcement is now separately gated on the flag. The cancel itself is not:
a run whose flag has come down must still be stopped.

A_cancel_arriving_after_the_flag_is_down_writes_nothing forces that window
through the download seam. Without the gate it fails with exactly the recorded
write ("Canceling firmware update...", WasUploading=False).
@tylerkron

Copy link
Copy Markdown
Contributor Author

/agentic_review

@qodo-code-review

Copy link
Copy Markdown

Code review by qodo was updated up to the latest commit 49be77c

@tylerkron

Copy link
Copy Markdown
Contributor Author

Shepherd verification — head 49be77c

The gap the author disclosed: both device panes DO render

The PR states plainly that only the bootloader dialog is inside the capture gate, and that the two device-pane blocks rest on textual + reflection assertions. Since views here carry no x:DataType, that is the one place a mistyped or moved member renders blank at runtime while every build stays green — so I booted the real app headless and looked at both panes rather than reasoning about them.

A temporary, since-reverted probe in tools/parity-audit/AvaloniaCapture staged the real state — IsFirmwareUploading = true, UploadFirmwareProgress = 42, a coordinator message on the wire — seeded a USB device tile, and entered the drawer through the pane's own OpenSettingsCommand. Desktop was captured inside the real MainWindow; mobile hosts DevicesMobileView over the same DevicesPaneViewModel. DAQIFI_DATA_DIR was isolated throughout. Read-back from the realized visual tree, separately from the pixels:

pane control rendered
desktop drawer CANCEL UPDATE IsEffectivelyVisible=True IsEnabled=True Bounds=110x23, Command non-null
desktop drawer status line IsEffectivelyVisible=True Bounds=375x14, Text="Waiting for WiFi module to enter update mode..."
mobile pane CANCEL UPDATE IsEffectivelyVisible=True IsEnabled=True Bounds=293x44, Command non-null
mobile pane status line IsEffectivelyVisible=True Bounds=293x16, same text

Both bindings resolve against the compiled view models — a reflection binding that renders is exactly that proof. CancelFirmwareUpload / CancelUploadFirmware were searched as bare substrings across every .cs and .axaml, which covers the [RelayCommand]-generated …Command spelling and every binding form by construction: each has exactly one binding site, and every theme resource the new controls use (PillButton, DarkSecondaryButton, TextSecondary) was already in use in the same file.

The two captures are attached below (desktop drawer first, then mobile). The harness edit is reverted; git status is clean.

The new capture gate is deterministic here

Five capture runs, byte-compared: 25/25 identical on all five, and run 1 reproduced all 25 committed hashes in baselines/macos-arm64.sha256, including the 24 that predate this PR. The manifest diff is +1 / −0 lines — no unexplained second baseline. Recorded on the same Mac as the author's, so the independent-host leg is CI's Desktop head on macOS job, which is green on this SHA. Note that a small edge-column delta in desktop-7/-8/-9 would be #201, not a regression from this PR; none appeared.

Every number in the body re-derived

  • 18 → 16 write sites — hand-counted. origin/main: 15 in FirmwareUpdateCoordinator + 3 in DaqifiViewModel = 18. This branch: 16 + 0 = 16. Exact.
  • 512 → 520 — from CI's own logs, not the author's word: run on 4b37a22 reports Passed: 512, run 33991618437 on 49be77c reports Passed: 520. Locally 520/520 too. The new file holds 6 [Fact] + 1 [Theory] × 2 [InlineData] = 8 cases, so +8 reconciles exactly.
  • "6 of 8 fail against origin/main" — I copied the test file onto a throwaway branch at 4b37a22 and ran it: 8 of 8 fail, not 6. The body's claim is conservative rather than inflated (it says six were run pre-fix and all six failed; it never claims the other two pass on main). Worth stating in the stronger form.
  • Warnings 191/191 desktop, 192/192 iOS Release — confirmed from CI's published MSBuild summaries on both heads (4b37a22 vs 49be77c), and the warning sets diff to zero: the only differences are three warnings whose line numbers shifted by the diff's own insertions. These are all-project totals, ~20 of them third_party AVP1002 — not a first-party count. Reproduced locally: desktop, iOS restore, iOS Release iossimulator-arm64 all green; packages.lock.json untouched.
  • Zero declarations removed. Every deleted C# line is a statement, so the fix(layering): a view-model was hand-writing SCPI bytes at the transport #189 family (a member main gained an override for) does not apply.

Merge state

git merge-base origin/main HEAD is 4b37a22 — this branch's base is today's main tip, so it already carries #239, #240, #244, #245, #246 and #247, and git log origin/main --not HEAD is empty. The merge result is bit-identical to 49be77c, which CI built green across all six real jobs and which I rebuilt locally (desktop + iOS Release) with the suite at 520/520.

Qodo

Clean on the current head. The Code Review by Qodo comment reads "Review updated until commit 49be77c ⚖️ Balanced" (a real review, not a Skipped doc-only round) with Bugs (0) / Rule violations (0) / Skill insights (0); its one Medium finding is struck through and resolved. GraphQL reviewThreads shows 1 qodo-authored thread, 0 unresolved — filter validated against #196, which yields 3 qodo-authored threads by the same substring match. Re-checked both surfaces 15 minutes after the round landed: the summary comment was byte-identical and the thread count unchanged.


Qodo-clean, CI green — ready for review at 49be77c281d941b69c20e37cacdd563077f39afd.

What remains unverified, precisely: no live firmware upload was exercised — unattended flashing is on this environment's destructive denylist, so no PIC32 or WINC image was written to a board and no device was rebooted. That leaves untested whether Core's flasher honours the token promptly mid-transfer, which is a property of Core and of #239 rather than of this diff. Everything above is a unit test, a headless render of the real UI, or a reading of CI's own output.

Desktop device settings drawer mid-upload: the FIRMWARE section shows the progress bar at 42 percent, the live status line reading Waiting for WiFi module to enter update mode, and the new CANCEL UPDATE button beneath it.

Mobile devices pane mid-upload: the same live status line and full-width CANCEL UPDATE button below UPDATE FIRMWARE.

tylerkron added a commit that referenced this pull request Sep 5, 2026
…ched hunk

The fix moves exactly two of the manifest's screens, mobile-landscape-3-storage
and mobile-portrait-3-storage -- the two orientations of the one view that
changed. Every other screen is byte-for-byte unchanged against the pre-fix
capture, and that pre-fix capture matched the then-committed manifest on every
screen it lists, so the clipped empty state is what was shipping rather than an
artefact of the harness. Five --determinism runs agreed after the change and
--check-captured passes against the new manifest.

Both the manifest lines and the README paragraph are placed to miss the other
open PRs writing to these files: three of them are at the head and foot of the
README's baselines list, so this entry goes between the #251 and #213
paragraphs, and the manifest lines touched are neither the one #256 inserts
after nor the ones #258 and #263 rewrite. The paragraph states no screen count,
because #256 changes it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@tylerkron
tylerkron added this pull request to the merge queue Sep 6, 2026
Merged via the queue into main with commit a2d2814 Sep 6, 2026
7 checks passed
@tylerkron
tylerkron deleted the fix/issue-241-firmware-cancel branch September 6, 2026 00:48
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.

Firmware upload cannot be cancelled from any view, and its status text is written 18 times and shown nowhere

1 participant