Skip to content

feat(firmware): managed WINC serial-bridge protocol and read-only module inspector (part of #271) - #423

Merged
tylerkron merged 8 commits into
mainfrom
feature/native-winc-flasher
Aug 2, 2026
Merged

feat(firmware): managed WINC serial-bridge protocol and read-only module inspector (part of #271)#423
tylerkron merged 8 commits into
mainfrom
feature/native-winc-flasher

Conversation

@tylerkron

@tylerkron tylerkron commented Aug 1, 2026

Copy link
Copy Markdown
Contributor

Stacked on #419. Review that first; this PR's diff is only the Firmware/Winc/** files plus one small change in WifiModuleUpdater.

Why

WiFi module flashing shells out to Microchip's winc_flash_tool, which is a Windows .cmd/.exe. On Linux and macOS there is nothing to run, so WiFi flashing simply isn't available — a problem for the desktop app's move to Avalonia.

This PR does not fix that. It lands the foundation, and I want to be blunt about the gap up front: WiFi flashing is still Windows-only after this merges.

What actually happened to the plan

The issue says Microchip ships the winc_programmer_uart source in the ATWINC15x0 package. They don't. The Harmony repo (wireless_wifi/utilities/wifi/winc/tools) and the local winc1500-Manual-UART-Firmware-Update package both ship binaries onlywinc_programmer_uart (Linux ELF), winc_programmer_uart.exe, image_tool. I checked the full repo tree through the GitHub API: zero .c files for the programmer. So the "port the open-source tool" route in the issue isn't available.

The good news is there's a better reference. DAQiFi's own firmware implements the bridge (wifi_serial_bridge.c), so the protocol here is derived from the code that actually accepts or rejects these commands, not from a third party's docs.

What lands

Type Job
WincBridgeProtocol wire format — op codes, the 12-byte XOR-checked header, and the mixed endianness
WincSerialBridgeClient one complete bridge exchange per method
WincFlashReader read-only flash and identity register sequences
SystemWincSerialPort System.IO.Ports, so the transport itself is cross-platform
WincModuleInspector handshake, baud negotiation, chip/flash identity, flash read-back
WincFlashToolLocator answers "can this machine flash?" before an update starts

One subtlety worth flagging: the header fields are little-endian but a register value comes back big-endian. That asymmetry is the easiest thing to get wrong, and there are tests pinning both.

What is missing, and why

Erase and program. Writing a WINC means erase and page-program cycles against its SPI flash, and a wrong opcode or address bricks the module with no recovery path outside Microchip's Windows tool. I could not validate that on the bench (see below), so it is absent rather than shipped unexercised. An API that looks complete but has never run is worse than no API: callers select it, and the first execution happens on someone's hardware.

I also dropped the IWincFlasher seam the issue suggests. I built it, then deleted it. Its central FlashAsync was thrown from by both implementations — the native one can't write, and the external tool's run is owned by the WiFi flow (whose prompt handshake and output-based success verification are hard-won and Windows-only, so they can't be moved and re-validated). A two-property interface with one implementation and no consumer is noise. It comes back when there is a native writer and genuinely two things to abstract. The same reasoning renamed NativeWincFlasherWincModuleInspector: it inspects, it doesn't flash, and the name should say so.

Two findings worth carrying out of this work

1. A firmware hang. The bridge's READ_BLOCK handler never decrements its counter nor advances the address:

while (cnt >= WIFI_SERIAL_BRIDGE_CMD_BUFFER_SIZE) {   // cnt never changes
    nm_read_block(pContext->cmdAddr, ...);            // cmdAddr never advances

Any block read of ≥2048 bytes loops forever, re-sending the same chunk. MaxReadBlockSize is capped at 2047 with the reason written down, and tests assert both that oversize requests are rejected before hitting the wire and that a 5 KB flash read never issues a single oversize block. Being surfaced separately as a firmware ticket.

2. The 500000-baud step is ceremonial over USB CDC, which ignores line rate entirely. The exchange still has to happen — the bridge expects it, and completing it proves the command path works — but nobody should expect a throughput gain from it. Documented on the constant rather than left misleading.

Bench evidence

Nyquist 1, FW 3.7.2, /dev/cu.usbmodem1101, over USB CDC. The device was put into real bridge mode and the protocol was exercised against a live WINC.

baseline    SCPI healthy: status=Connected
baseline    LAN chip info via SCPI: id=1377184 fw=19.7.7 build=Mar 30 2022
bridge      WifiBridgeActivator.Activate (SetLanFirmwareUpdateMode + LAN:APPLY)
identity    chipId        = 0x001003A0
identity    flashJedecId  = 0xC21320C2
identity    recognizedWinc= True
identity    negotiatedBaud= 500000
read        4096 bytes @0x000000; first 16: 4E 4D 49 53 18 05 00 00 00 00 0D 00 F4 04 00 00
read        ASCII of first 4: NMIS
verify      second read of the same region identical: True
verify      different region (0x010000) differs: True
exit        soft exit via Deactivate -> SCPI restored: status=Connected

What this proves. The protocol derived from wifi_serial_bridge.c is correct on real hardware:

  • Handshake — the bridge answered the identify op code.
  • Baud negotiation to 500000 — completed and the link kept working.
  • Chip ID 0x001003A0 — byte-for-byte the value the firmware source records as bench-observed. Family 0x10 = halted in download mode, exactly as expected after m2m_wifi_download_mode(). This validates the big-endian register decode, which is the asymmetry most likely to be wrong.
  • Flash read-back — offset 0 begins NMIS, the WINC image signature. Real content, not zeros or noise. Re-reading is byte-identical (deterministic), and a different offset returns different data (the address field genuinely steers the read).
  • Flash JEDEC ID 0xC21320C2 — Macronix (0xC2), type 0x20, capacity 0x13 = 4 Mbit / 512 KB, matching the 4 Mb WINC SPI flash this issue describes.

The bench found a bug the tests could not

ReadFlashJedecId originally returned 0x00000000 from the live module. It wrote DATA_CNT = 0 and DMA_ADDR = 0, so no result bytes were clocked back — while the command still completed and TR_DONE still went high. Every frame was well-formed and the device ACKed every one. Nothing about the protocol layer was wrong; two register values were. No amount of framing testing against a fake could have caught that, because the fake faithfully answered exactly what the device answered.

Corrected against the driver's spi_flash_rdid (DATA_CNT = 4, DMA_ADDR = DUMMY_REGISTER) and re-verified on hardware. A test now pins the register sequence value-by-value rather than only asserting the return.

Correction: bridge mode is not a one-way door

Earlier revisions of this PR stated that entering bridge mode required a physical power cycle to escape, because the CDC is transparent while bridged. That was wrong. WifiBridgeActivator.DeactivateSetUsbTransparencyMode(0) + LAN:APPLY, already in Core — restored SCPI cleanly, twice. The device was left healthy and streaming, never stranded. I had reasoned from the firmware's re-init path without checking Core's own documented deactivation path, which was in a file I had already opened.

Still NOT validated

Erase and program remain unimplemented and unexercised — no write of any kind was performed, and none is possible through this code. That is a deliberate scope boundary, not an oversight: the user authorized a bridge session, not a flash write. Everything above is read-only.

Test suite

4,829 green across net9.0 and net10.0 (+75). Build clean, 0 warnings, TreatWarningsAsErrors on.

🤖 Generated with Claude Code

@tylerkron
tylerkron requested a review from a team as a code owner August 1, 2026 18:59
@tylerkron

Copy link
Copy Markdown
Contributor Author

/agentic_review

@qodo-code-review

Copy link
Copy Markdown

PR Summary by Qodo

Add managed WINC serial-bridge protocol + read-only module inspector

✨ Enhancement 🧪 Tests 🐞 Bug fix 🕐 40+ Minutes

Grey Divider

AI Description

• Implement the WINC UART serial-bridge wire format and exchange client
• Add a cross-platform, read-only WINC module inspector (handshake, baud, identity, flash readback)
• Centralize external Microchip flash-tool discovery and clarify non-Windows failure messaging
Diagram

graph TD
  U["WifiModuleUpdater"] --> L["WincFlashToolLocator"] --> T["Microchip flash tool"]
  I["WincModuleInspector"] --> P["SystemWincSerialPort"] --> C["WincSerialBridgeClient"] --> R["WincFlashReader"] --> D["DAQiFi device (WINC bridge)"]
Loading
High-Level Assessment

The following are alternative approaches to this PR:

1. Wrap vendor binaries (winc_flash_tool / winc_programmer_uart) per-OS
  • ➕ Faster path to cross-platform flashing if Linux/macOS binaries are available
  • ➕ Avoids re-implementing erase/program correctness-critical sequences
  • ➖ Requires distributing and maintaining multiple opaque binaries
  • ➖ Harder to test deterministically; relies on tool output parsing and process control
  • ➖ Does not remove dependency on vendor packaging quirks
2. Extend existing Core serial transport to support mid-session baud renegotiation
  • ➕ Reduces bespoke serial abstractions in the firmware stack
  • ➕ Potentially reuses existing logging, cancellation, and framing utilities
  • ➖ Current Core transport semantics (fixed baud + watchdog) conflict with bridge requirements
  • ➖ Higher blast radius across existing device communication paths
3. Implement erase/program now behind an explicit feature flag
  • ➕ Earlier end-user value (actual flashing) while keeping risk gated
  • ➕ Enables limited bench validation without committing to default behavior
  • ➖ Still high bricking risk if opcode/addressing is wrong
  • ➖ Feature-flagged code paths tend to rot without continuous hardware CI

Recommendation: The PR’s approach (managed protocol + read-only inspector + strong framing tests + a fake bridge that emulates firmware parsing) is the safest foundation given the stated inability to validate erase/program on hardware. If cross-platform flashing is needed sooner, consider a parallel short-term wrapper around available vendor binaries while keeping the managed bridge stack as the long-term replacement—especially since the tests here already pin the highest-risk piece (mixed-endian framing + XOR header acceptance).

Files changed (13) +1991 / -16

Enhancement (8) +990 / -0
IWincSerialPort.csIntroduce minimal serial-port abstraction for bridge protocol needs +42/-0

Introduce minimal serial-port abstraction for bridge protocol needs

• Defines a narrow interface for raw byte I/O plus mid-session baud rate changes and exact-length reads. Enables testability and avoids coupling to existing transports that assume fixed baud and watchdog behavior.

src/Daqifi.Core/Firmware/Winc/IWincSerialPort.cs

SystemWincSerialPort.csAdd System.IO.Ports-based IWincSerialPort implementation +122/-0

Add System.IO.Ports-based IWincSerialPort implementation

• Implements IWincSerialPort over SerialPort with DTR enabled for USB CDC behavior. Adds a ReadExactly loop with deadline-based timeout semantics and non-throwing disposal behavior.

src/Daqifi.Core/Firmware/Winc/SystemWincSerialPort.cs

WincBridgeProtocol.csDefine WINC bridge wire format (opcodes, 12-byte XOR header, endian rules) +183/-0

Define WINC bridge wire format (opcodes, 12-byte XOR header, endian rules)

• Adds constants for identify/start bytes and ACK/NACK responses, command IDs, and max safe block sizes. Implements header construction with checksum slotting (XOR-to-zero) and big-endian register response decoding.

src/Daqifi.Core/Firmware/Winc/WincBridgeProtocol.cs

WincFlashReader.csImplement non-destructive chip/flash identity and SPI flash readback +169/-0

Implement non-destructive chip/flash identity and SPI flash readback

• Adds a read-only flash reader that scripts WINC flash-controller register sequences and reads staged data via the shared memory window. Enforces chunking below the bridge wedge threshold and bounds transfer-done polling to avoid infinite host hangs.

src/Daqifi.Core/Firmware/Winc/WincFlashReader.cs

WincFlashToolLocator.csAdd locator to answer whether Microchip WINC flash tool is available +80/-0

Add locator to answer whether Microchip WINC flash tool is available

• Provides TryResolveToolPath/IsAvailable to locate a tool file by name either directly or under a directory tree. Treats unreadable trees as unavailable and avoids throwing during availability checks.

src/Daqifi.Core/Firmware/Winc/WincFlashToolLocator.cs

WincModuleIdentity.csAdd identity DTO for non-destructive WINC module inspection results +26/-0

Add identity DTO for non-destructive WINC module inspection results

• Introduces a small public model reporting chip ID, flash JEDEC ID, recognition flag, and the negotiated baud rate used for inspection.

src/Daqifi.Core/Firmware/Winc/WincModuleIdentity.cs

WincModuleInspector.csAdd cross-platform read-only inspector (handshake, baud switch, identity, flash read) +171/-0

Add cross-platform read-only inspector (handshake, baud switch, identity, flash read)

• Implements a managed inspector that opens a port, verifies a bridge is listening, negotiates to fast baud, re-handshakes, then reads chip/flash identity or flash bytes. Includes a port factory seam for testing and ensures ports are disposed on failure.

src/Daqifi.Core/Firmware/Winc/WincModuleInspector.cs

WincSerialBridgeClient.csAdd bridge client implementing per-command exchanges and error semantics +197/-0

Add bridge client implementing per-command exchanges and error semantics

• Implements identify handshake, register read/write, block read/write, and baud reconfiguration using the WincBridgeProtocol framing. Enforces safety guards (max read size) and throws distinct IOExceptions for NACK vs out-of-sync verdict bytes.

src/Daqifi.Core/Firmware/Winc/WincSerialBridgeClient.cs

Bug fix (1) +19 / -16
WifiModuleUpdater.csUse shared WINC flash-tool locator and improve non-Windows error messaging +19/-16

Use shared WINC flash-tool locator and improve non-Windows error messaging

• Replaces ad-hoc directory search logic with WincFlashToolLocator for consistent tool resolution and availability checks. Throws clearer FileNotFoundException messaging, including explicit Linux/macOS note that flashing remains Windows-only due to Microchip tooling.

src/Daqifi.Core/Firmware/WifiModuleUpdater.cs

Tests (4) +982 / -0
FakeWincSerialPort.csAdd firmware-accurate fake WINC serial bridge for end-to-end tests +205/-0

Add firmware-accurate fake WINC serial bridge for end-to-end tests

• Introduces an IWincSerialPort fake that re-implements the firmware bridge state machine (opcode -> 12-byte header -> XOR validation -> ACK/NACK -> payload). Captures received headers/payloads and supports failure injection for NACK and missing identity responses.

src/Daqifi.Core.Tests/Firmware/Winc/FakeWincSerialPort.cs

WincBridgeProtocolTests.csPin WINC bridge header framing, checksum, and mixed-endian decoding +192/-0

Pin WINC bridge header framing, checksum, and mixed-endian decoding

• Adds unit tests validating 12-byte header layout, XOR-to-zero checksum rule, and rejection cases (bit flips, wrong lengths, null). Also pins opcode/response constants and verifies register responses decode big-endian, opposite of header fields.

src/Daqifi.Core.Tests/Firmware/Winc/WincBridgeProtocolTests.cs

WincFlasherTests.csTest flash-read sequences, module inspector handshake/identity, and tool locator +281/-0

Test flash-read sequences, module inspector handshake/identity, and tool locator

• Covers WincFlashReader chip-id/JEDEC reads, chunking below the 2048-byte wedge threshold, bounded transfer-done polling, and cancellation. Also tests WincModuleInspector handshake + baud renegotiation flow and WincFlashToolLocator discovery behavior.

src/Daqifi.Core.Tests/Firmware/Winc/WincFlasherTests.cs

WincSerialBridgeClientTests.csExercise bridge client exchanges (handshake, register/block I/O, baud change) +304/-0

Exercise bridge client exchanges (handshake, register/block I/O, baud change)

• Adds tests validating correct start-byte prefixing, header acceptance/NACK failure modes, register big-endian round-trips, block size guards to prevent device wedge, payload send ordering, and baud change ordering + buffer discard behavior.

src/Daqifi.Core.Tests/Firmware/Winc/WincSerialBridgeClientTests.cs

@qodo-code-review

qodo-code-review Bot commented Aug 1, 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. Unused logger breaks build ✓ Resolved 🐞 Bug ≡ Correctness
Description
WincFlashReader assigns a private _logger field that is never read, which triggers CS0414
(“assigned but never used”). Because the project sets TreatWarningsAsErrors=true, this is a
merge-blocking compile failure.
Code

src/Daqifi.Core/Firmware/Winc/WincFlashReader.cs[R52-64]

+    private readonly WincSerialBridgeClient _bridge;
+    private readonly ILogger _logger;
+    private readonly int _transferPollLimit;
+
+    internal WincFlashReader(
+        WincSerialBridgeClient bridge,
+        int transferPollLimit = 1000,
+        ILogger? logger = null)
+    {
+        _bridge = bridge ?? throw new ArgumentNullException(nameof(bridge));
+        _transferPollLimit = transferPollLimit;
+        _logger = logger ?? NullLogger.Instance;
+    }
Relevance

●●● Strong

Merge-blocking: warnings are treated as errors; unused field must be removed/used.

PR-#375

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
The _logger field is only declared and assigned, never referenced; the project is configured to
treat warnings as errors, so this warning becomes a build error.

src/Daqifi.Core/Firmware/Winc/WincFlashReader.cs[52-64]
src/Daqifi.Core/Daqifi.Core.csproj[3-10]

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

### Issue description
`WincFlashReader` has a private readonly `_logger` field that is assigned in the ctor but never used. With `TreatWarningsAsErrors` enabled, this produces a compiler warning elevated to an error and breaks CI.

### Issue Context
Either remove the unused logging plumbing (field + ctor param + `NullLogger` import), or actually use `_logger` (e.g., log transfer polling/timeouts).

### Fix Focus Areas
- src/Daqifi.Core/Firmware/Winc/WincFlashReader.cs[1-64]
- src/Daqifi.Core/Daqifi.Core.csproj[3-10]

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



Remediation recommended

2. JEDEC test ignores ordering ✓ Resolved 🐞 Bug ⚙ Maintainability ⭐ New
Description
ReadFlashJedecId_WritesTheExactRegisterSequenceTheWincDriverUses claims to pin the exact register
write *sequence*, but it collapses writes into a Dictionary and asserts only final values per
address. This can pass even if production code writes registers in the wrong order or writes an
incorrect value first and later overwrites it, reducing the intended regression protection for the
JEDEC-ID path.
Code

src/Daqifi.Core.Tests/Firmware/Winc/WincFlasherTests.cs[R128-149]

+        var writes = RegisterWrites(port);
+
+        Assert.Equal(4u, writes[0x10208]);        // DATA_CNT - 4 result bytes
+        Assert.Equal(0x9Fu, writes[0x1020C]);     // BUF1 - RDID opcode
+        Assert.Equal(0x01u, writes[0x10214]);     // BUF_DIR
+        Assert.Equal(DummyRegister, writes[0x1021C]); // DMA_ADDR - where the result lands
+        Assert.Equal(1u | (1u << 7), writes[0x10204]); // CMD_CNT - 1 command byte, start bit
+    }
+
+    /// <summary>
+    /// Extracts address -> value for every WriteRegister command the client sent.
+    /// </summary>
+    private static Dictionary<uint, uint> RegisterWrites(FakeWincSerialPort port)
+    {
+        var writes = new Dictionary<uint, uint>();
+
+        foreach (var h in port.ReceivedHeaders.Where(h => h[0] == (byte)WincBridgeProtocol.Command.WriteRegister))
+        {
+            var address = ((uint)h[7] << 24) | ((uint)h[6] << 16) | ((uint)h[5] << 8) | h[4];
+            var value = ((uint)h[11] << 24) | ((uint)h[10] << 16) | ((uint)h[9] << 8) | h[8];
+            writes[address] = value;
+        }
Relevance

●●● Strong

Team has accepted strengthening tests to truly verify ordering/sequence, not just end state.

PR-#381

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
The test’s name/comment explicitly claims to validate the *sequence*, but it materializes writes
into a dictionary keyed by address and then does keyed lookups, which cannot validate ordering and
overwrites earlier writes to the same address.

src/Daqifi.Core.Tests/Firmware/Winc/WincFlasherTests.cs[113-152]

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

## Issue description
`ReadFlashJedecId_WritesTheExactRegisterSequenceTheWincDriverUses` says it verifies the *exact register sequence*, but the helper `RegisterWrites` stores writes in a `Dictionary<uint,uint>` and the test then checks only final values by address. This means the test will not fail if the production code reorders the register writes (or writes a wrong value then overwrites it later).

## Issue Context
This test was introduced specifically because the bench caught a value-level issue in the JEDEC-ID read path; the test’s stated intent is to prevent future regressions in the driver-like register scripting. To actually pin the sequence, the test should assert an ordered list of `(address,value)` writes in the expected order, and optionally assert the count.

## Fix Focus Areas
- src/Daqifi.Core.Tests/Firmware/Winc/WincFlasherTests.cs[113-152]

### Suggested implementation approach
- Replace `RegisterWrites(...)` with a helper that returns `List<(uint Address, uint Value)>` in the same order as `port.ReceivedHeaders`.
- In the test, build an `expected` list of `(address,value)` entries in the intended order and `Assert.Equal(expected, actual)`.
- (Optional) If duplicates should be forbidden, assert `actual.Count == expected.Count` and that there are no duplicate addresses.

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


3. Continuation captures inspector ✓ Resolved 🐞 Bug ☼ Reliability
Description
In WincModuleInspector.OpenWithTimeout, the abandoned-open continuation logs via the instance field
_logger, which forces the continuation to capture this. If SerialPort.Open() never completes
(the scenario this code explicitly anticipates), the still-running openTask retains the entire
inspector object graph indefinitely, not just the port/handle it must already retain for cleanup.
Code

src/Daqifi.Core/Firmware/Winc/WincModuleInspector.cs[R224-227]

+                            _logger.LogDebug(
+                                fault,
+                                "Abandoned open of {PortName} faulted after it was given up on.",
+                                portName);
Relevance

●●● Strong

Team has accepted fixes preventing long-lived tasks/events from retaining large object graphs; this
is small, targeted reliability hardening.

PR-#415
PR-#295

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
OpenWithTimeout is explicitly designed to abandon an Open() that can block indefinitely; the
continuation then references _logger (an instance field), which requires capturing the inspector
instance in the continuation closure. If the open never completes, that capture keeps the inspector
reachable indefinitely.

src/Daqifi.Core/Firmware/Winc/WincModuleInspector.cs[185-190]
src/Daqifi.Core/Firmware/Winc/WincModuleInspector.cs[207-246]

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 abandoned-open cleanup continuation in `OpenWithTimeout` references the instance field `_logger`, which causes the continuation delegate to capture the `WincModuleInspector` instance (`this`). When `SerialPort.Open()` hangs indefinitely (explicitly called out in the method remarks), the `openTask` never completes, so the continuation (and its captured `this`) can keep the inspector and its dependencies alive indefinitely.

### Issue Context
This is incremental retention beyond what’s unavoidable (the port/open task). The continuation still needs `port` and `portName`, but it does not need to keep the entire inspector instance alive.

### Fix Focus Areas
- src/Daqifi.Core/Firmware/Winc/WincModuleInspector.cs[198-250]

### Suggested fix
Before registering the continuation, copy instance members needed by the continuation into locals (e.g., `var logger = _logger; var pn = portName;`) and use those locals inside the continuation, or use a `static` continuation with an explicit state tuple/object containing `(port, logger, portName)`.

This prevents capturing `this` while preserving the existing behavior (observing `completed.Exception`, logging best-effort, and always disposing in `finally`).

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


4. Observer can skip disposal ✓ Resolved 🐞 Bug ☼ Reliability
Description
In WincModuleInspector.OpenWithTimeout's abandoned-open continuation, _abandonedOpenFaultObserver is
invoked before the port-disposal try/catch and is not guarded; if the observer throws,
port.Dispose() is skipped and the continuation task faults unobserved. This can leak the serial
handle in the timeout/cancel path and make subsequent port access fail until process exit.
Code

src/Daqifi.Core/Firmware/Winc/WincModuleInspector.cs[R222-229]

+                    if (completed.Exception is { } fault)
+                    {
+                        _logger.LogDebug(
+                            fault,
+                            "Abandoned open of {PortName} faulted after it was given up on.",
+                            portName);
+                        _abandonedOpenFaultObserver?.Invoke(fault);
+                    }
Relevance

●●● Strong

Team recently accepted fixes to observe abandoned task faults and isolate callback exceptions from
breaking cleanup.

PR-#326
PR-#354

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
The continuation calls the observer before entering the disposal try/catch, so an observer exception
bypasses disposal and faults the fire-and-forget continuation task.

src/Daqifi.Core/Firmware/Winc/WincModuleInspector.cs[48-79]
src/Daqifi.Core/Firmware/Winc/WincModuleInspector.cs[214-238]
PR-#326

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

### Issue description
`OpenWithTimeout` schedules a fire-and-forget continuation when `SerialPort.Open()` is abandoned (timeout/cancel). Inside that continuation, `_abandonedOpenFaultObserver?.Invoke(fault)` is called before the `try { port.Dispose(); }` block and is not protected. If the observer throws, the continuation exits early and the port is never disposed.

Because the continuation task is not awaited/observed, any exception thrown by the observer will also become an unobserved task exception.

### Issue Context
- The observer is a test seam, but it is a delegate parameter and therefore not guaranteed not to throw.
- Cleanup should be best-effort and must not be bypassed by diagnostics/test hooks.

### Fix Focus Areas
- src/Daqifi.Core/Firmware/Winc/WincModuleInspector.cs[214-242]

### Suggested fix
1. Wrap `_abandonedOpenFaultObserver?.Invoke(...)` in its own `try/catch`, logging (Debug) if it throws.
2. Ensure `port.Dispose()` runs regardless (e.g., move disposal into an outer `finally` or keep it after the observer but guaranteed via `try/finally`).
3. Keep current behavior of observing `completed.Exception` to avoid `UnobservedTaskException` from the abandoned open task.

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


View more (6)
5. Flaky unobserved-exception test ✓ Resolved 🐞 Bug ☼ Reliability
Description
Inspector_ObservesTheFaultOfAnAbandonedOpen asserts behavior via
TaskScheduler.UnobservedTaskException plus GC.Collect/finalizer timing, which is inherently
nondeterministic and can intermittently fail in CI. The handler also never calls SetObserved, so if
the event does fire (from this or unrelated tasks) the test provides no isolation and may be
affected by other subscribers’ behavior.
Code

src/Daqifi.Core.Tests/Firmware/Winc/WincFlasherTests.cs[R247-287]

+    [Fact]
+    public async Task Inspector_ObservesTheFaultOfAnAbandonedOpen()
+    {
+        // The abandoned open is no longer awaited by anyone, so if it later faults nothing would
+        // observe the exception - a silently swallowed background failure (#377/#394). The
+        // continuation must read it. Verified via the unobserved-exception hook: after forcing a
+        // GC, no unobserved fault should have been raised for our exception.
+        var unobserved = new List<Exception>();
+        void Handler(object? _, UnobservedTaskExceptionEventArgs e)
+        {
+            if (e.Exception?.InnerException is InvalidOperationException { Message: "abandoned-open-fault" })
+            {
+                unobserved.Add(e.Exception);
+            }
+        }
+
+        TaskScheduler.UnobservedTaskException += Handler;
+        try
+        {
+            var port = new FaultingAfterDelayPort(TimeSpan.FromMilliseconds(300));
+            var inspector = new WincModuleInspector(
+                (_, _) => port,
+                baudSettleDelay: TimeSpan.Zero,
+                openTimeout: TimeSpan.FromMilliseconds(50));
+
+            await Assert.ThrowsAsync<TimeoutException>(() => inspector.ReadIdentityAsync("COM1"));
+
+            // Let the abandoned open run to its fault, then force finalization.
+            await Task.Delay(TimeSpan.FromMilliseconds(600));
+            GC.Collect();
+            GC.WaitForPendingFinalizers();
+            GC.Collect();
+
+            Assert.Empty(unobserved);
+            Assert.True(port.WasDisposedByContinuation);
+        }
+        finally
+        {
+            TaskScheduler.UnobservedTaskException -= Handler;
+        }
+    }
Relevance

●●● Strong

Team often fixes CI-flaky timing-based tests, preferring deterministic signaling/timeouts over
sleeps/GC timing hooks.

PR-#104
PR-#226
PR-#364

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
The test subscribes to the global UnobservedTaskException event, records matching exceptions, then
relies on Delay + forced GC/finalizers before asserting none were raised; this pattern is
timing-dependent and the handler does not call SetObserved.

src/Daqifi.Core.Tests/Firmware/Winc/WincFlasherTests.cs[247-287]

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

### Issue description
`Inspector_ObservesTheFaultOfAnAbandonedOpen` relies on process-wide `TaskScheduler.UnobservedTaskException` and GC/finalizer timing to prove an exception was observed. This can be nondeterministic and can interfere with other tests in the same process.

### Issue Context
The test subscribes to `TaskScheduler.UnobservedTaskException`, triggers an abandoned open that later faults, then uses `Task.Delay` + `GC.Collect`/`GC.WaitForPendingFinalizers` and asserts no matching unobserved exception was captured.

### Fix Focus Areas
- src/Daqifi.Core.Tests/Firmware/Winc/WincFlasherTests.cs[247-287]

### Suggested fix approach
1. In the event handler, call `e.SetObserved()` **only for the specific exception you’re tracking** (e.g., the `InvalidOperationException` with message `"abandoned-open-fault"`) so the test can’t destabilize the process if that event fires.
2. Reduce nondeterminism:
  - Prefer a bounded polling loop (up to a small deadline) that repeats GC/finalizer forcing and checks the list, rather than a single fixed `Task.Delay(600ms)`.
  - Use a thread-safe collection (or a lock) for `unobserved` since the event can be raised on a finalizer thread.
3. If feasible, make the assertion more direct/deterministic by asserting the continuation executed and observed the exception (e.g., via an explicit signal/hook in the fake port or a test-only callback), rather than relying primarily on GC-driven `UnobservedTaskException` behavior.

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


6. IsAvailable not total ✓ Resolved 🐞 Bug ☼ Reliability
Description
WincFlashToolLocator.IsAvailable is documented as “total by design,” but it only catches
IOException/UnauthorizedAccessException; other filesystem/path exceptions can escape and crash a
capability probe. This breaks the intended yes/no probing semantics for malformed or unsupported
paths.
Code

src/Daqifi.Core/Firmware/Winc/WincFlashToolLocator.cs[R34-52]

+    /// <summary>
+    /// Whether the flash tool is present for the given firmware path. Total by design — a probe
+    /// answers yes or no, so an unreadable tree reports <c>false</c> rather than throwing.
+    /// </summary>
+    /// <remarks>
+    /// Use <see cref="TryResolveToolPath"/> when a caller is about to act on the answer and needs
+    /// to tell "not there" apart from "could not look".
+    /// </remarks>
+    public bool IsAvailable(string firmwarePath)
+    {
+        try
+        {
+            return TryResolveToolPath(firmwarePath, out _);
+        }
+        catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
+        {
+            return false;
+        }
+    }
Relevance

●●● Strong

Repo favors “probe must not throw”; accepted broad exception handling in presence probes.

PR-#403

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
The implementation’s own comment promises total behavior, but the code only returns false for a
limited set of exceptions, so other exceptions can escape from the probe.

src/Daqifi.Core/Firmware/Winc/WincFlashToolLocator.cs[34-52]

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

### Issue description
`IsAvailable()` claims to be total (never throws), but the catch filter only handles `IOException` and `UnauthorizedAccessException`. Other common path-related exceptions (e.g., invalid path format) can still bubble.

### Issue Context
`IsAvailable` is a probe API typically called from UI/decision logic; it should reliably return true/false for any input without surprising exceptions.

### Fix Focus Areas
- src/Daqifi.Core/Firmware/Winc/WincFlashToolLocator.cs[34-52]

### Suggested fix
Either:
1) Broaden the catch in `IsAvailable` to include expected non-fatal path exceptions (e.g., `ArgumentException`, `NotSupportedException`, `PathTooLongException`) and return `false`, OR
2) Keep the narrow catch but update the XML doc to remove the “total” claim and ensure callers handle exceptions.
Prefer (1) if this is used as a UI probe.

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


7. Unobserved open task fault ✓ Resolved 🐞 Bug ☼ Reliability
Description
WincModuleInspector.OpenWithTimeout abandons the Task.Run(Open) on timeout/cancellation, but the
continuation only disposes the port and never observes the task’s Exception, so a later Open()
failure can surface as an UnobservedTaskException (or be lost for diagnostics). This undermines the
reliability goal of the hard-timeout path under real USB/serial failure conditions.
Code

src/Daqifi.Core/Firmware/Winc/WincModuleInspector.cs[R197-220]

+        var openTask = Task.Run(port.Open, CancellationToken.None);
+
+        try
+        {
+            openTask.WaitAsync(_openTimeout, cancellationToken).GetAwaiter().GetResult();
+        }
+        catch (Exception ex) when (ex is TimeoutException or OperationCanceledException)
+        {
+            ownsPort = false;
+            openTask.ContinueWith(
+                _ =>
+                {
+                    try
+                    {
+                        port.Dispose();
+                    }
+                    catch (Exception)
+                    {
+                        // Best-effort cleanup of a port we already gave up on.
+                    }
+                },
+                CancellationToken.None,
+                TaskContinuationOptions.ExecuteSynchronously,
+                TaskScheduler.Default);
Relevance

●●● Strong

Accepted precedent: abandoned timeout Task.Run must observe task.Exception to avoid
UnobservedTaskException.

PR-#326

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
The timeout/cancel path attaches a continuation that only calls Dispose and never reads
openTask.Exception, leaving any eventual Open() fault unobserved; this matches a previously
accepted bug pattern in the repo around abandoned tasks.

src/Daqifi.Core/Firmware/Winc/WincModuleInspector.cs[197-220]
PR-#326

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

### Issue description
`OpenWithTimeout` abandons `openTask` on timeout/cancellation, but the continuation ignores the antecedent task result/exception. If `port.Open()` eventually faults, that exception remains unobserved.

### Issue Context
This code intentionally abandons a potentially-blocking `SerialPort.Open()` to keep the caller responsive; the abandoned task still may complete later (successfully or faulted).

### Fix Focus Areas
- src/Daqifi.Core/Firmware/Winc/WincModuleInspector.cs[197-220]

### Suggested fix
In the timeout/cancellation catch block, change the continuation to accept `Task t` and explicitly observe `t.Exception` (and optionally log it) before/while disposing the port. Example pattern:
- `openTask.ContinueWith(t => { _ = t.Exception; try { port.Dispose(); } catch { } }, ...)`
Also consider a small race-guard: if `openTask.IsCompleted` after catching timeout, await/propagate its real outcome instead of throwing TimeoutException.

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


8. Tool lookup masks IO errors ✓ Resolved 🐞 Bug ◔ Observability
Description
WincFlashToolLocator converts directory traversal IOException/UnauthorizedAccessException into
a simple false, and ResolveWifiToolPath then throws FileNotFoundException as if the tool were
missing. This can misdiagnose permission/inaccessible-directory problems as “tool not found /
platform unavailable,” making failures harder to debug.
Code

src/Daqifi.Core/Firmware/WifiModuleUpdater.cs[R745-770]

    private string ResolveWifiToolPath(string firmwarePath)
    {
-        if (File.Exists(firmwarePath))
+        if (!File.Exists(firmwarePath) && !Directory.Exists(firmwarePath))
        {
-            return firmwarePath;
+            throw new FileNotFoundException("WiFi firmware path was not found.", firmwarePath);
        }

-        if (Directory.Exists(firmwarePath))
+        // Resolution goes through the shared locator so there is a single answer to
+        // "can this environment flash the WiFi module?" (part of #271).
+        var locator = new WincFlashToolLocator(Options.WifiFlashToolFileName);
+        if (locator.TryResolveToolPath(firmwarePath, out var toolPath))
        {
-            var matches = Directory.GetFiles(
-                firmwarePath,
-                Options.WifiFlashToolFileName,
-                SearchOption.AllDirectories);
-
-            if (matches.Length == 0)
-            {
-                throw new FileNotFoundException(
-                    $"Could not locate '{Options.WifiFlashToolFileName}' under '{firmwarePath}'.");
-            }
-
-            return matches[0];
+            return toolPath;
        }

-        throw new FileNotFoundException("WiFi firmware path was not found.", firmwarePath);
+        // Say why plainly. Microchip's flash tool is a Windows .cmd/.exe, so on Linux and macOS
+        // this is not a misconfigured path — the tool genuinely cannot be there, and the caller
+        // needs to know that rather than reading it as a missing download.
+        var platformNote = OperatingSystem.IsWindows()
+            ? string.Empty
+            : $" On {(OperatingSystem.IsMacOS() ? "macOS" : "this platform")} the WiFi flash tool is " +
+              "unavailable — Microchip ships it as a Windows program. WiFi module flashing is " +
+              "currently Windows-only; see issue #271.";
+
+        throw new FileNotFoundException(
+            $"Could not locate '{Options.WifiFlashToolFileName}' under '{firmwarePath}'.{platformNote}");
Relevance

●●● Strong

Repo prefers preserving IO failure diagnostics; treating IO/access as “missing” was fixed elsewhere.

PR-#403
PR-#274

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
The locator explicitly catches I/O and access exceptions and returns false, and the updater maps
any false result to a tool-missing FileNotFoundException with a platform note, obscuring the
original failure mode.

src/Daqifi.Core/Firmware/Winc/WincFlashToolLocator.cs[63-78]
src/Daqifi.Core/Firmware/WifiModuleUpdater.cs[745-770]

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 locator intentionally swallows `IOException`/`UnauthorizedAccessException` and reports `false`, and the updater turns that into a `FileNotFoundException`. This loses actionable diagnostics when the tree exists but is unreadable.

### Issue Context
If you still want a boolean availability probe API, consider adding a separate resolution method that either:
- returns a richer result (Found / NotFound / Inaccessible + exception), or
- returns `false` for NotFound but rethrows/attaches exceptions for Inaccessible.

### Fix Focus Areas
- src/Daqifi.Core/Firmware/Winc/WincFlashToolLocator.cs[63-78]
- src/Daqifi.Core/Firmware/WifiModuleUpdater.cs[745-770]

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


9. Flash address can wrap ✓ Resolved 🐞 Bug ≡ Correctness
Description
WincFlashReader.ReadFlash computes (uint)(offset + read) without overflow checks, so large
offsets/lengths can wrap around and read from the wrong flash address range silently. This is a
correctness bug that can return incorrect data without an error.
Code

src/Daqifi.Core/Firmware/Winc/WincFlashReader.cs[R103-121]

+    internal byte[] ReadFlash(uint offset, int length, CancellationToken cancellationToken = default)
+    {
+        if (length <= 0)
+        {
+            throw new ArgumentOutOfRangeException(nameof(length), length, "Read length must be positive.");
+        }
+
+        var result = new byte[length];
+        var read = 0;
+
+        while (read < length)
+        {
+            cancellationToken.ThrowIfCancellationRequested();
+
+            var chunk = Math.Min(WincBridgeProtocol.MaxReadBlockSize, length - read);
+            var chunkData = ReadFlashChunk((uint)(offset + read), chunk);
+            Buffer.BlockCopy(chunkData, 0, result, read, chunk);
+            read += chunk;
+        }
Relevance

●●● Strong

Team has accepted overflow-safety guards; unchecked address arithmetic risks silent wraparound.

PR-#257

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
The code performs unchecked arithmetic to derive the flash address for each chunk, with no guard
that offset + (length-1) fits in uint, so wraparound is possible.

src/Daqifi.Core/Firmware/Winc/WincFlashReader.cs[103-121]

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

### Issue description
`ReadFlash` adds a `uint offset` and an `int read` and casts back to `uint` without validating that the requested span stays within the address space. If it overflows, the address wraps and subsequent chunks target the wrong address.

### Issue Context
Even if typical WINC flash ranges are small, this is an API boundary that should fail fast for out-of-range spans.

### Fix Focus Areas
- src/Daqifi.Core/Firmware/Winc/WincFlashReader.cs[103-121]

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


10. Inspector cancellation not enforced ✓ Resolved 🐞 Bug ☼ Reliability
Description
WincModuleInspector exposes CancellationToken but runs synchronous work via Task.Run and only
checks cancellation once before opening the port; cancellation after start won’t stop ongoing
handshakes/reads/baud negotiation. This also leaves the API exposed to the known
“SerialPort.Open() can hang uncancellably” failure mode without a hard-timeout/abandonment guard
used elsewhere in the repo.
Code

src/Daqifi.Core/Firmware/Winc/WincModuleInspector.cs[R74-155]

+    public Task<WincModuleIdentity> ReadIdentityAsync(
+        string portName,
+        CancellationToken cancellationToken = default)
+        => Task.Run(() => ReadIdentity(portName, cancellationToken), cancellationToken);
+
+    /// <summary>
+    /// Reads a span of the module's SPI flash. Changes nothing on the device.
+    /// </summary>
+    public Task<byte[]> ReadFlashAsync(
+        string portName,
+        uint offset,
+        int length,
+        CancellationToken cancellationToken = default)
+        => Task.Run(() => ReadFlash(portName, offset, length, cancellationToken), cancellationToken);
+
+    private WincModuleIdentity ReadIdentity(string portName, CancellationToken cancellationToken)
+    {
+        using var session = OpenSession(portName, cancellationToken);
+
+        var chipId = session.Reader.ReadChipId();
+        var flashId = session.Reader.ReadFlashJedecId();
+
+        _logger.LogInformation(
+            "WINC identity: chipId=0x{ChipId:X8} flashJedecId=0x{FlashId:X8} baud={Baud}.",
+            chipId,
+            flashId,
+            session.Port.BaudRate);
+
+        return new WincModuleIdentity
+        {
+            ChipId = chipId,
+            FlashJedecId = flashId,
+            IsRecognizedWinc = WincFlashReader.IsKnownWincChipId(chipId),
+            NegotiatedBaudRate = session.Port.BaudRate
+        };
+    }
+
+    private byte[] ReadFlash(string portName, uint offset, int length, CancellationToken cancellationToken)
+    {
+        using var session = OpenSession(portName, cancellationToken);
+        return session.Reader.ReadFlash(offset, length, cancellationToken);
+    }
+
+    /// <summary>
+    /// Opens the port, proves a bridge is listening, and negotiates up to the fast baud rate.
+    /// </summary>
+    private BridgeSession OpenSession(string portName, CancellationToken cancellationToken)
+    {
+        if (string.IsNullOrWhiteSpace(portName))
+        {
+            throw new ArgumentException("Port name cannot be empty.", nameof(portName));
+        }
+
+        cancellationToken.ThrowIfCancellationRequested();
+
+        var port = _portFactory(portName, InitialBaudRate);
+        try
+        {
+            port.Open();
+
+            var bridge = new WincSerialBridgeClient(port, _responseTimeout, _logger);
+
+            if (!bridge.TryHandshake())
+            {
+                throw new IOException(
+                    $"No WINC serial bridge responded on {portName}. The device must be in WiFi " +
+                    "firmware-update (bridge) mode before the module can be reached.");
+            }
+
+            bridge.ChangeBaudRate(FastBaudRate, _baudSettleDelay);
+
+            // Re-handshake at the new rate: this both confirms the switch actually took and leaves
+            // the bridge back in its op-code state before any command is issued.
+            if (!bridge.TryHandshake())
+            {
+                throw new IOException(
+                    $"The WINC bridge on {portName} stopped responding after switching to " +
+                    $"{FastBaudRate} baud.");
+            }
+
+            return new BridgeSession(port, new WincFlashReader(bridge, logger: _logger));
+        }
Relevance

●●● Strong

Repo previously added hard-timeout/cancellation patterns around SerialPort.Open/Task.Run; likely
expected here too.

PR-#326
PR-#315

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
The inspector’s async wrappers use Task.Run(...) and pass the token, but OpenSession only throws
for cancellation once (before opening/handshake/baud changes). The repo already documents that
SerialPort.Open() can hang uncancellably and addresses it in WifiBridgeActivator with a
hard-timeout abandonment pattern, which is missing here.

src/Daqifi.Core/Firmware/Winc/WincModuleInspector.cs[74-155]
src/Daqifi.Core/Firmware/WifiBridgeActivator.cs[20-33]
src/Daqifi.Core/Firmware/WifiBridgeActivator.cs[115-205]
PR-#326

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

### Issue description
`WincModuleInspector.ReadIdentityAsync/ReadFlashAsync` accept a `CancellationToken`, but once the `Task.Run` delegate begins, cancellation is largely not observed (only a single early check in `OpenSession`, plus per-chunk checks for flash reads). Additionally, `SerialPort.Open()` is a known uncancellable hang risk in this codebase and this API currently has no hard timeout or abandonment strategy.

### Issue Context
`WifiBridgeActivator` already implements a robust isolation pattern: dedicated thread (`LongRunning`), a hard timeout, and linked-token cancellation to prevent post-timeout device state changes.

### Fix Focus Areas
- src/Daqifi.Core/Firmware/Winc/WincModuleInspector.cs[74-155]
- src/Daqifi.Core/Firmware/WifiBridgeActivator.cs[20-33]
- src/Daqifi.Core/Firmware/WifiBridgeActivator.cs[115-205]

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


Grey Divider

To customize comments, go to the Qodo configuration screen, or learn more in the docs.

Qodo Logo

Comment thread src/Daqifi.Core/Firmware/Winc/WincFlashReader.cs
Comment thread src/Daqifi.Core/Firmware/Winc/WincModuleInspector.cs
Comment thread src/Daqifi.Core/Firmware/Winc/WincFlashReader.cs
Comment thread src/Daqifi.Core/Firmware/WifiModuleUpdater.cs
tylerkron added a commit that referenced this pull request Aug 1, 2026
… surface unreadable tool trees

Qodo round 1 on #423.

1. Unused logger in WincFlashReader - removed the field and the ctor parameter.
   Qodo's stated mechanism was wrong: it claimed CS0414 under TreatWarningsAsErrors
   makes this a merge-blocking compile failure. It does not. A clean rebuild
   (obj/bin removed) reports 0 warnings, 0 errors, and a verbose build contains
   zero CS0414 occurrences - CS0414 does not fire for a field assigned a
   non-constant expression. The field was still genuinely dead, so it is gone on
   its own merit, not because the build was broken.

2. Cancellation and the uncancellable SerialPort.Open hang - the real one.
   Open() takes no token and can block indefinitely on a wedged or
   half-enumerated USB CDC device, so it now runs under a hard deadline
   (default 5s) and is abandoned on timeout or cancel. An abandoned open is
   still running on a pool thread and still owns the handle, so disposal is
   handed to a continuation instead of being done underneath it - disposing
   under a blocked open turns a hang into a crash. An ownership flag keeps the
   caller from double-disposing. Cancellation is also now observed between
   bridge exchanges and during the baud settle wait (was Thread.Sleep).

3. Flash address wrap - ReadFlash added a uint offset to an int and cast back to
   uint with no validation, so a large span wrapped past 32 bits and silently
   read the wrong part of flash, returning plausible data with no error. Now
   bounds-checked in 64-bit against the 24-bit address the fast-read command can
   actually carry.

4. Tool lookup masking IO errors - TryResolveToolPath no longer swallows
   IOException/UnauthorizedAccessException. A tree that exists but cannot be read
   was being reported as "could not locate the tool - WiFi flashing is
   Windows-only", which is actively misleading when the tool is sitting there
   behind a permissions problem. IsAvailable stays total (a probe answers
   yes/no) and catches for itself. No logging added in either catch, per #98.

Five tests added, covering the open deadline (including that the abandoned open
is not disposed underneath), cancellation during a hanging open, address-wrap
rejection, the exact-last-address boundary, and the unreadable-tree split
between TryResolveToolPath and IsAvailable.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@tylerkron

Copy link
Copy Markdown
Contributor Author

Qodo round 1 addressed — 4 bugs, all fixed in 3e1b4aa

Three accepted as stated. One had a wrong stated mechanism, which I want on the record rather than quietly agreed to.

1. Unused logger — fixed, but the premise was false

The claim was that this "triggers CS0414" and is therefore "a merge-blocking compile failure" under TreatWarningsAsErrors. It is not. On a clean rebuild of the reviewed commit:

$ rm -rf src/Daqifi.Core/obj src/Daqifi.Core/bin && dotnet build
Build succeeded.
    0 Warning(s)    0 Error(s)

$ dotnet build -v n | grep -c CS0414
0

CS0414 is scoped to fields assigned a constant; _logger was assigned logger ?? NullLogger.Instance, which the compiler does not flag. CI was never broken. The field was still genuinely dead, so it is removed on its own merit — but "breaks the build" was not true, and accepting it would have put a false claim in the history.

2. Cancellation / SerialPort.Open() hang — the serious one

Open() takes no token and can block indefinitely on a wedged or half-enumerated USB CDC device. It now runs under a hard deadline (5s, injectable) and is abandoned on timeout or cancel.

The subtlety is disposal: an abandoned open is still running and still owns the handle, so disposing from the caller would tear the port out from under a blocked syscall and convert a hang into a crash. Disposal goes to a continuation, with an ownership flag preventing double-dispose — and there is a test asserting the port is not disposed while the open is still blocked. Cancellation is also now observed between bridge exchanges and during the baud settle wait (previously Thread.Sleep).

3. Flash address wrap — fixed

ReadFlash added a uint offset to an int and cast back per chunk with no validation; past 32 bits that wraps and reads a different part of flash, returning plausible bytes with no error at all. Now bounds-checked in 64-bit against the 24-bit address the fast-read command can physically carry. This one mattered most because it lives in the read path that cannot currently be validated on hardware.

4. Locator masking IO errors — fixed, without violating settled convention

No logging added in any catch (#98 rejected that; #180 settled that silent catches are fine). But "unreadable directory reported as file-not-found" produces "could not locate the tool — WiFi flashing is Windows-only" on a machine where the tool is present behind a permissions problem — that is a misleading diagnostic, not a style choice. Split by contract instead: TryResolveToolPath propagates (it is what the updater calls, so the real error surfaces), IsAvailable stays total and catches for itself.


5 tests added — open deadline, no-dispose-under-blocked-open, cancel during a hanging open, three address-wrap rejections plus the exact-last-address boundary, and the unreadable-tree split.

Suite 4,844 green across net9.0 and net10.0. Clean rebuild: 0 warnings, 0 errors.

Unchanged: the hardware gap in the PR body still stands — the bridge protocol has never run against a live bridge, and erase/program remains unimplemented. Bridge mode is a one-way door (no software exit; recovery needs a physical power cycle) and this bench is shared, so that run is deferred until someone is at the hardware.

@tylerkron

Copy link
Copy Markdown
Contributor Author

/agentic_review

Comment thread src/Daqifi.Core/Firmware/Winc/WincModuleInspector.cs
Comment thread src/Daqifi.Core/Firmware/Winc/WincFlashToolLocator.cs
@qodo-code-review

Copy link
Copy Markdown

Code review by qodo was updated up to the latest commit 3e1b4aa

tylerkron added a commit that referenced this pull request Aug 1, 2026
…e genuinely total

Qodo round 2 on #423. Both findings are the tail of round 1's own fixes.

1. Unobserved open task fault. The abandonment path added last round left the
   Task.Run(Open) with nobody watching it, so a later Open() failure was a
   silently swallowed background error - exactly what #377/#394 set out to
   eliminate. The continuation now reads completed.Exception (which marks it
   observed) and surfaces it at Debug. Debug rather than Warning because the
   caller already received a TimeoutException or a cancellation; this is
   diagnostic context, not a second failure to act on.

   Note this is not the logging-in-catch pattern rejected in #98 - it is a
   continuation observing a faulted task, and the log sits outside the
   dispose try/catch.

2. IsAvailable was documented "total by design" but only caught the IO family,
   so an ArgumentException from a malformed path could escape a probe whose
   whole purpose is to be safe to call with anything. Made the implementation
   match the contract rather than weakening the doc: the catch is now broad and
   the comment says why, and the returns tag spells out every case that yields
   false.

Five tests added. The unobserved-fault test is mutation-verified: removing the
completed.Exception read makes it fail, so it is not a no-op. It hooks
TaskScheduler.UnobservedTaskException, forces a GC after the abandoned open has
faulted, and also asserts the continuation still disposed the port.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@tylerkron

Copy link
Copy Markdown
Contributor Author

Qodo round 2 addressed — both fixed in 616be5c

Both findings are the tail of my own round-1 fixes rather than new territory, which is the expected shape for a second round.

Worth flagging for anyone reading later: the top-level summary reported Bugs (0) — these two exist only as inline threads. A summary-only check would have missed both.

1. Unobserved open task fault

Round 1 added a hard deadline around SerialPort.Open() and correctly stopped the caller disposing a port out from under a blocked syscall — but that left the abandoned task with nobody watching it. A later Open() fault was unobserved: a silently swallowed background failure, exactly what #377/#394 set out to eliminate here.

The continuation now reads completed.Exception (what actually marks it observed) and surfaces it at Debug — the caller already got a TimeoutException or a cancellation, so this is context about a port we deliberately walked away from, not a second failure to act on. Not the logging-in-catch pattern rejected in #98: it is a continuation observing a faulted task, and the log sits outside the dispose try/catch.

Mutation-verified, because unobserved-exception tests are easy to write as accidental no-ops: removing the completed.Exception read makes the test fail, restoring it makes it pass.

2. IsAvailable not total

Two options were offered — broaden the catch or weaken the doc. I took the first, because the doc described the contract I actually wanted. I split this API in round 1 specifically so TryResolveToolPath propagates the real reason while IsAvailable stays a safe yes/no probe; documenting it as total and implementing it as "total for the IO family" was my inconsistency. A malformed path could throw ArgumentException straight out of a capability probe.

Catch is now broad with a comment on why the breadth is deliberate, and <returns> enumerates every case yielding false.


5 tests added (85 WINC tests total). Suite 4,875 green across net9.0 and net10.0. Clean rebuild: 0 warnings, 0 errors.

Unchanged and still flagged: the bridge protocol has never run against a live bridge, and erase/program remains unimplemented. Bridge mode is a one-way door — no software exit, recovery needs a physical power cycle — and this bench is shared, so that run stays deferred until someone is at the hardware.

@tylerkron

Copy link
Copy Markdown
Contributor Author

/agentic_review

Comment thread src/Daqifi.Core.Tests/Firmware/Winc/WincFlasherTests.cs
@qodo-code-review

Copy link
Copy Markdown

Code review by qodo was updated up to the latest commit 616be5c

tylerkron added a commit that referenced this pull request Aug 1, 2026
…tead of via GC timing

Qodo round 3 on #423 - and fairly, the finding is about the test I
mutation-verified last round. Mutation proved it CAN catch the regression; it
did not prove it catches it RELIABLY on a loaded CI machine, which is a
different claim.

Two independent problems with the old test:

- Nondeterminism. It inferred "the fault was observed" from
  TaskScheduler.UnobservedTaskException after a forced GC. Collector and
  finalizer timing is not something to hang a CI gate on.
- No isolation. UnobservedTaskException is process-global and the handler never
  called SetObserved, so an unrelated task faulting anywhere in a parallel run
  could reach this handler, and this one could leak to other subscribers.

Replaced rather than patched. Reading Task.Exception is what marks a fault
observed, so the abandonment path now reports the observed exception through an
injectable hook and the test asserts on that directly - the same property,
decided by the code under test rather than by the runtime. No GC, no global
event, no SetObserved needed because nothing subscribes to the global event any
more.

The mutation property is preserved: removing the observation still fails the
test. On success it now completes in ~150 ms instead of waiting out a forced
collection, and it ran stable across four repeat runs.

The hook is null in production, where the Debug log remains the only output.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@tylerkron

Copy link
Copy Markdown
Contributor Author

Qodo round 3 addressed — fixed in f4c49e2

One finding, and it was about the test I mutation-verified in round 2. Fair catch.

What that verification actually proved. It showed the test can catch the regression. It said nothing about whether it catches it reliably under CI load — a different claim, and I conflated the two.

Both halves of the finding were real and independent:

  • Nondeterminism — the test inferred "the fault was observed" from TaskScheduler.UnobservedTaskException after a forced GC.Collect. That hangs a CI gate on collector and finalizer timing.
  • No isolationUnobservedTaskException is process-global and the handler never called SetObserved, so an unrelated task faulting anywhere in a parallel run could reach it, and mine could leak to other subscribers.

Replaced, not patched. Reading Task.Exception is what marks a fault observed — so the abandonment path now reports the observed exception through an injectable hook, and the test asserts that directly. Same property, decided by the code under test rather than by the runtime. No GC, no global event, and SetObserved becomes moot because nothing subscribes to the global event any more.

Evidence the rewrite kept its teeth:

  • Mutation re-verified — removing the observation still fails the test.
  • ~150 ms on success, down from waiting out a forced collection.
  • Four consecutive runs, no variance (~485 ms per full inspector-test pass).

The hook is null in production; the Debug log remains the only output there.


Suite 4,875 green across net9.0 and net10.0. Clean rebuild: 0 warnings, 0 errors. No production behavior changed by this round — the only source change is the injectable observation point.

Unchanged and still flagged: the bridge protocol has never run against a live bridge, and erase/program remains unimplemented. Bridge mode is a one-way door (no software exit; recovery needs a physical power cycle) and this bench is shared, so that run stays deferred until someone is at the hardware.

@tylerkron

Copy link
Copy Markdown
Contributor Author

/agentic_review

Comment thread src/Daqifi.Core/Firmware/Winc/WincModuleInspector.cs Outdated
@qodo-code-review

Copy link
Copy Markdown

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

tylerkron added a commit that referenced this pull request Aug 1, 2026
…nd drop the test-only hook

Qodo round 4 on #423. A production hazard introduced by round 3's test seam.

In the abandoned-open continuation the fault observation ran before an
unprotected port.Dispose(), so a throwing observer or logger skipped the
disposal entirely - leaking the serial handle and breaking every later open
until process exit. That is strictly worse than the unobserved fault round 2
set out to fix, so the ordering here is load-bearing.

Releasing the handle is the entire reason the continuation exists, so disposal
now sits in a finally that nothing above can be reachable-past, with the
observation in its own catch. The catch swallows rather than rethrows because
nothing awaits this continuation either - propagating would recreate the
unobserved fault the path exists to avoid.

Also removed the injectable observer added in round 3. Two consecutive findings
came from this continuation carrying a test-only seam, so it was worth
re-weighing rather than patching again. It turns out the seam was never needed:
production already reads completed.Exception to hand to the logger, and reading
Task.Exception is precisely what marks a fault observed - so a capturing logger
proves the same property through a seam that exists for production reasons.
This repo already has CapturingLogger/ThrowingLogger precedent
(DaqifiDeviceLoggerTests).

Note the guard is required regardless of the hook: a throwing logger has the
identical hazard, which is why ThrowingLogger exists in this repo at all.
Removing the hook narrows the exposure; it does not replace the fix.

Both properties are mutation-verified. Reverting to the unguarded ordering
fails the new throwing-logger disposal test; removing the Exception read fails
the observation test.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@tylerkron

Copy link
Copy Markdown
Contributor Author

Qodo round 4 addressed — fixed in 84243a4

One finding, and the sharpest of the four: a production hazard that my own round-3 test seam introduced.

The chain is worth stating plainly. Round 2 fixed an unobserved fault. Round 3 replaced the flaky GC-based test with an injectable observer. Round 4 found that observer could skip the port.Dispose() the entire abandonment strategy exists to perform. A leaked serial handle that breaks every later open until process exit is strictly worse than the unobserved fault I started from — so the sequencing here was load-bearing, not incidental.

The fix

Disposal now sits in a finally that nothing above can be reachable-past, with the observation in its own catch. The catch swallows rather than rethrows because nothing awaits this continuation either — propagating would recreate the very unobserved fault the path exists to avoid.

The test seam is gone

Two consecutive findings came from this continuation carrying a test-only hook, so I re-weighed it rather than patching again — and it was never needed. Production already reads completed.Exception to hand to the logger, and reading Task.Exception is precisely what marks a fault observed. A capturing logger proves the identical property through a seam that exists for production reasons. The repo already has CapturingLogger/ThrowingLogger precedent in DaqifiDeviceLoggerTests, so this also brings the test in line with house style.

To be precise: the guard is needed regardless of the hook — a throwing logger has the identical hazard, which is presumably why ThrowingLogger exists here at all. Removing the hook narrows exposure; it does not substitute for the fix.

Evidence

Both properties mutation-verified:

Mutation Result
Revert to unguarded ordering throwing-logger disposal test fails (port never disposed)
Remove the completed.Exception read observation test fails

Net effect of this round on production code: the continuation is guarded, and one test-only member is gone from WincModuleInspector.

Suite 4,877 green across net9.0 and net10.0. Clean rebuild: 0 warnings, 0 errors.

Unchanged and still flagged: the bridge protocol has never run against a live bridge, and erase/program remains unimplemented. Bridge mode is a one-way door — no software exit, recovery needs a physical power cycle — and this bench is shared, so that run stays deferred until someone is at the hardware.

@tylerkron

Copy link
Copy Markdown
Contributor Author

/agentic_review

Comment thread src/Daqifi.Core/Firmware/Winc/WincModuleInspector.cs Outdated
@qodo-code-review

Copy link
Copy Markdown

Code review by qodo was updated up to the latest commit 84243a4

tylerkron added a commit that referenced this pull request Aug 1, 2026
…tops capturing `this`

Qodo round 5 on #423.

The abandoned-open continuation logged through the instance field _logger, so
the closure captured `this`. In the scenario this code exists for - an open that
never returns - the still-running task retained the whole inspector object graph
for the life of the process rather than just the port and logger it needs for
cleanup.

Hoisting _logger into a local removes the last reach through the instance;
`port` and `portName` were already parameters, so the continuation now captures
only what it uses.

Managed retention, not a handle leak - materially smaller than round 4's
disposal hazard. Taken because it is a one-liner with no risk, not because it is
serious. No test: asserting on closure capture is brittle and would cost more
than it protects, and the existing tests already cover the behavior that matters
(the fault is observed, the port is disposed).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@tylerkron

Copy link
Copy Markdown
Contributor Author

Qodo round 5 addressed — fixed in 93a8b6e

One finding, and a one-line fix.

The abandoned-open continuation logged through the instance field _logger, so the closure captured this. In the scenario this code exists for — an open that never returns — the still-running task retained the whole inspector object graph for the life of the process, when it only needs the port and the logger for cleanup.

Hoisted _logger into a local. I checked the rest of the closure first, since the hoist buys nothing if something else still reaches through the instance: port and portName are method parameters and completed is the lambda parameter, so that was the only one. With round 4's observer hook already gone, the this capture is now eliminated entirely — verified after the edit that no field access or this. remains in the continuation body.

Calibration, stated plainly: this is managed retention, not a handle leak — materially smaller than round 4's disposal hazard. Taken because it is a one-liner with no behavioral risk, not because it is serious.

No test added. Asserting on closure capture would mean reflecting over compiler-generated display classes — brittle against codegen changes, and costing more than it protects. The existing tests cover the behavior that matters: the fault is observed, and the port is disposed even when observation throws.


Suite 4,877 green across net9.0 and net10.0. Clean rebuild: 0 warnings, 0 errors.

Where this PR stands

Five rounds in, the findings have moved from real correctness bugs — an address wrap that silently returned wrong flash data, a handle leak that would break every later port open — to object-graph retention. That is the normal shape of convergence, and I think this is the natural stopping point.

Unchanged and still flagged, because it is the thing that actually matters here: the bridge protocol has never run against a live bridge, and erase/program remains unimplemented. Bridge mode is a one-way door — no software exit, recovery needs a physical power cycle — and this bench is shared, so that run stays deferred until someone is at the hardware. Everything validated so far is the failure path plus 85 framing tests against a fake that re-implements the firmware's own parser. That is a real net, but it is not hardware validation.

@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 93a8b6e

@tylerkron

Copy link
Copy Markdown
Contributor Author

Bench run complete — the protocol is now demonstrated against a live WINC

The user was at the bench and approved the session. PR body updated; commit 722a43d.

What was proved

identity    chipId        = 0x001003A0
identity    flashJedecId  = 0xC21320C2
identity    negotiatedBaud= 500000
read        4096 bytes @0x000000; first 16: 4E 4D 49 53 18 05 00 00 00 00 0D 00 F4 04 00 00
read        ASCII of first 4: NMIS
verify      second read identical: True     different region differs: True

Handshake, baud negotiation to 500000, chip identity, and flash read-back all work against real hardware. Chip ID 0x001003A0 is byte-for-byte the value the firmware source records as bench-observed, family 0x10 = halted in download mode — which validates the big-endian register decode, the asymmetry most likely to be wrong. Offset 0 begins NMIS, the WINC image signature: real content, deterministic on re-read, and a different offset returns different data.

The bench found a bug 85 framing tests could not

ReadFlashJedecId returned 0x00000000 from the live module. It wrote DATA_CNT = 0 and DMA_ADDR = 0, so no result bytes were clocked back — yet the command completed and TR_DONE still went high.

Every frame was well-formed and the device ACKed every one. Nothing about the protocol layer was wrong; two register values were. This is structurally invisible to framing tests, because the fake faithfully answers exactly what the device answers.

Fixed against the driver's spi_flash_rdid (DATA_CNT = 4, DMA_ADDR = DUMMY_REGISTER), re-verified on hardware: 0xC21320C2 — Macronix (0xC2), type 0x20, capacity 0x13 = 4 Mbit / 512 KB, matching the 4 Mb WINC flash the issue describes. A test now pins the register sequence value-by-value rather than only the return.

This is the single best argument for having run it: five rounds of review hardened the code, and the first contact with hardware still found something none of it could reach.

Correction: bridge mode is not a one-way door

I stated in earlier revisions — repeatedly, and confidently — that escaping bridge mode required a physical power cycle, on the reasoning that CDC is transparent while bridged. That was wrong. WifiBridgeActivator.Deactivate (SetUsbTransparencyMode(0) + LAN:APPLY), already in Core, restored SCPI cleanly on both runs. The device was left healthy and streaming, never stranded.

I had reasoned from the firmware's re-init path without checking Core's own deactivation path — which was in a file I had already opened. That belief also caused me to defer this run once. Worth recording plainly.

Still not validated

Erase and program remain unimplemented and unexercised. No write of any kind was performed and none is possible through this code. The user authorized a bridge session, not a flash write — that remains a separate decision.


Suite 4,879 green across net9.0 and net10.0 (+2). Clean rebuild: 0 warnings, 0 errors. Device confirmed healthy and streaming after the session.

@tylerkron

Copy link
Copy Markdown
Contributor Author

/agentic_review

Comment thread src/Daqifi.Core.Tests/Firmware/Winc/WincFlasherTests.cs Outdated
@qodo-code-review

Copy link
Copy Markdown

Code review by qodo was updated up to the latest commit 722a43d

tylerkron added a commit that referenced this pull request Aug 2, 2026
…e, not a final-state map

Qodo round 6 on #423. The test's central claim was false.

ReadFlashJedecId_WritesTheExactRegisterSequenceTheWincDriverUses said "exact
register sequence" in its name but collapsed the writes into a
Dictionary<uint,uint> and asserted only the final value per address. That passes
on a wrong ordering, and passes on a wrong value later overwritten by a correct
one. For this command path order IS the protocol: DATA_CNT and DMA_ADDR stage
the transfer and CMD_CNT triggers it, so a map cannot express the property the
name promises - and the name is what a future reader trusts.

Now asserted as an ordered list of (address, value) pairs. Extended the same
guarantee to the fast-read path, which is the one that actually carries data
and has the identical staging requirement.

Register addresses are restated independently in the test from the WINC driver's
spi_flash.c rather than reused from production, so a typo in the production map
fails these tests instead of being silently agreed with.

Mutation-verified both, using reorderings that leave every value correct:
- JEDEC: firing CMD_CNT before DMA_ADDR turns it red. Notably the value-only
  test alongside it still passes, which is exactly the blind spot being closed.
- Fast read: swapping BUF2 and BUF_DIR turns it red.

Production code is untouched by this commit - test-only.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@tylerkron

Copy link
Copy Markdown
Contributor Author

Qodo round 6 addressed — fixed in d2ff50d

One finding, on the very test that pins the hardware fix — and it was right. Not a style nit: the test's central claim was false.

ReadFlashJedecId_WritesTheExactRegisterSequenceTheWincDriverUses said "exact register sequence" in its name, then collapsed the writes into a Dictionary<uint,uint> and asserted only the final value per address. That passes on a wrong ordering, and passes on a wrong value later overwritten by a correct one.

For this command path order is the protocolDATA_CNT and DMA_ADDR stage the transfer, CMD_CNT triggers it — so a map structurally cannot express the property the name promises. A weak assertion under an accurate name is a small problem; a weak assertion under a name a future reader will trust is a bigger one.

What changed

  • Asserted as an ordered list of (address, value) pairs.
  • Extended the same guarantee to the fast-read path, which actually carries data and has the identical staging requirement.
  • Register addresses are now restated independently in the test from the WINC driver's spi_flash.c rather than reused from production constants — reusing them would make the assertion tautological, silently agreeing with a typo in the production map instead of catching it.

Mutation-verified, using reorderings that leave every value correct

Mutation Result
JEDEC: CMD_CNT fired before DMA_ADDR red
Fast read: BUF2 and BUF_DIR swapped red

The JEDEC run demonstrates the finding exactly: 1 passed, 1 failed — the ordered test went red while the value-only test beside it still passed. That is the blind spot, visible.

Production code untouched by this commit; test-only. Suite 4,881 green across net9.0 and net10.0. Clean rebuild: 0 warnings, 0 errors.


Where #423 stands

Six rounds, all findings resolved, and the bench evidence is in. The arc is worth recording: review caught real correctness bugs (a silent flash-address wrap, a handle leak), hardware caught a bug review structurally could not (two wrong register values inside perfectly well-formed, ACKed frames), and this final round caught a test that overstated what it verified.

The scope boundary is unchanged and deliberate: erase and program remain unimplemented and unexercised. No write of any kind has been performed. Cross-platform WiFi flashing is still open — this PR is titled "part of #271" and does not close it.

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

Base automatically changed from refactor/split-firmware-update-service to main August 2, 2026 01:49
tylerkron and others added 8 commits August 1, 2026 19:50
…ule inspector (part of #271)

WiFi module flashing shells out to Microchip's winc_flash_tool, which is a
Windows .cmd/.exe, so WiFi flashing is unavailable on Linux and macOS.

This lands the foundation for a cross-platform path. It does NOT make WiFi
flashing cross-platform - see "what is missing" below.

The issue's premise turned out to be wrong. It says Microchip ships the
winc_programmer_uart SOURCE in the ATWINC15x0 package; they do not. Both the
Harmony repo (wireless_wifi/utilities/wifi/winc/tools) and the local
winc1500-Manual-UART-Firmware-Update package ship binaries only - verified via
the GitHub tree API, zero .c files for the programmer. DAQiFi's own firmware
implements the bridge (wifi_serial_bridge.c), so the protocol here is derived
from that instead, which is a better reference than anything Microchip publishes.

What lands:
- WincBridgeProtocol   - wire format: op codes, the 12-byte XOR-checked header,
                         and the mixed endianness (header fields little-endian,
                         register replies big-endian)
- WincSerialBridgeClient - one complete exchange per method
- WincFlashReader      - read-only flash/identity register sequences
- SystemWincSerialPort - System.IO.Ports, so the transport is cross-platform
- WincModuleInspector  - handshake, baud negotiation, chip/flash identity,
                         flash read-back. It inspects; it does not flash.
- WincFlashToolLocator - "can this machine flash?" answered up front

What is missing: erase and program. That path could not be validated - a wrong
opcode or address bricks the module with no recovery outside Microchip's Windows
tool - so it is absent rather than shipped unexercised behind an API that looks
complete.

Found a firmware defect while deriving the protocol: the bridge's READ_BLOCK
loop never decrements its counter nor advances the address, so any block read of
>= 2048 bytes loops forever re-sending the same chunk. MaxReadBlockSize is
capped at 2047 with tests pinning both the rejection and the chunking.

Also: the 500000-baud step is ceremonial over USB CDC, which ignores line rate.
Documented on the constant so nobody expects a speedup from it.

75 new tests, most of them protocol framing, against a fake that re-implements
the firmware's parser so mis-framed commands are rejected exactly as the device
would reject them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… surface unreadable tool trees

Qodo round 1 on #423.

1. Unused logger in WincFlashReader - removed the field and the ctor parameter.
   Qodo's stated mechanism was wrong: it claimed CS0414 under TreatWarningsAsErrors
   makes this a merge-blocking compile failure. It does not. A clean rebuild
   (obj/bin removed) reports 0 warnings, 0 errors, and a verbose build contains
   zero CS0414 occurrences - CS0414 does not fire for a field assigned a
   non-constant expression. The field was still genuinely dead, so it is gone on
   its own merit, not because the build was broken.

2. Cancellation and the uncancellable SerialPort.Open hang - the real one.
   Open() takes no token and can block indefinitely on a wedged or
   half-enumerated USB CDC device, so it now runs under a hard deadline
   (default 5s) and is abandoned on timeout or cancel. An abandoned open is
   still running on a pool thread and still owns the handle, so disposal is
   handed to a continuation instead of being done underneath it - disposing
   under a blocked open turns a hang into a crash. An ownership flag keeps the
   caller from double-disposing. Cancellation is also now observed between
   bridge exchanges and during the baud settle wait (was Thread.Sleep).

3. Flash address wrap - ReadFlash added a uint offset to an int and cast back to
   uint with no validation, so a large span wrapped past 32 bits and silently
   read the wrong part of flash, returning plausible data with no error. Now
   bounds-checked in 64-bit against the 24-bit address the fast-read command can
   actually carry.

4. Tool lookup masking IO errors - TryResolveToolPath no longer swallows
   IOException/UnauthorizedAccessException. A tree that exists but cannot be read
   was being reported as "could not locate the tool - WiFi flashing is
   Windows-only", which is actively misleading when the tool is sitting there
   behind a permissions problem. IsAvailable stays total (a probe answers
   yes/no) and catches for itself. No logging added in either catch, per #98.

Five tests added, covering the open deadline (including that the abandoned open
is not disposed underneath), cancellation during a hanging open, address-wrap
rejection, the exact-last-address boundary, and the unreadable-tree split
between TryResolveToolPath and IsAvailable.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…e genuinely total

Qodo round 2 on #423. Both findings are the tail of round 1's own fixes.

1. Unobserved open task fault. The abandonment path added last round left the
   Task.Run(Open) with nobody watching it, so a later Open() failure was a
   silently swallowed background error - exactly what #377/#394 set out to
   eliminate. The continuation now reads completed.Exception (which marks it
   observed) and surfaces it at Debug. Debug rather than Warning because the
   caller already received a TimeoutException or a cancellation; this is
   diagnostic context, not a second failure to act on.

   Note this is not the logging-in-catch pattern rejected in #98 - it is a
   continuation observing a faulted task, and the log sits outside the
   dispose try/catch.

2. IsAvailable was documented "total by design" but only caught the IO family,
   so an ArgumentException from a malformed path could escape a probe whose
   whole purpose is to be safe to call with anything. Made the implementation
   match the contract rather than weakening the doc: the catch is now broad and
   the comment says why, and the returns tag spells out every case that yields
   false.

Five tests added. The unobserved-fault test is mutation-verified: removing the
completed.Exception read makes it fail, so it is not a no-op. It hooks
TaskScheduler.UnobservedTaskException, forces a GC after the abandoned open has
faulted, and also asserts the continuation still disposed the port.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…tead of via GC timing

Qodo round 3 on #423 - and fairly, the finding is about the test I
mutation-verified last round. Mutation proved it CAN catch the regression; it
did not prove it catches it RELIABLY on a loaded CI machine, which is a
different claim.

Two independent problems with the old test:

- Nondeterminism. It inferred "the fault was observed" from
  TaskScheduler.UnobservedTaskException after a forced GC. Collector and
  finalizer timing is not something to hang a CI gate on.
- No isolation. UnobservedTaskException is process-global and the handler never
  called SetObserved, so an unrelated task faulting anywhere in a parallel run
  could reach this handler, and this one could leak to other subscribers.

Replaced rather than patched. Reading Task.Exception is what marks a fault
observed, so the abandonment path now reports the observed exception through an
injectable hook and the test asserts on that directly - the same property,
decided by the code under test rather than by the runtime. No GC, no global
event, no SetObserved needed because nothing subscribes to the global event any
more.

The mutation property is preserved: removing the observation still fails the
test. On success it now completes in ~150 ms instead of waiting out a forced
collection, and it ran stable across four repeat runs.

The hook is null in production, where the Debug log remains the only output.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…nd drop the test-only hook

Qodo round 4 on #423. A production hazard introduced by round 3's test seam.

In the abandoned-open continuation the fault observation ran before an
unprotected port.Dispose(), so a throwing observer or logger skipped the
disposal entirely - leaking the serial handle and breaking every later open
until process exit. That is strictly worse than the unobserved fault round 2
set out to fix, so the ordering here is load-bearing.

Releasing the handle is the entire reason the continuation exists, so disposal
now sits in a finally that nothing above can be reachable-past, with the
observation in its own catch. The catch swallows rather than rethrows because
nothing awaits this continuation either - propagating would recreate the
unobserved fault the path exists to avoid.

Also removed the injectable observer added in round 3. Two consecutive findings
came from this continuation carrying a test-only seam, so it was worth
re-weighing rather than patching again. It turns out the seam was never needed:
production already reads completed.Exception to hand to the logger, and reading
Task.Exception is precisely what marks a fault observed - so a capturing logger
proves the same property through a seam that exists for production reasons.
This repo already has CapturingLogger/ThrowingLogger precedent
(DaqifiDeviceLoggerTests).

Note the guard is required regardless of the hook: a throwing logger has the
identical hazard, which is why ThrowingLogger exists in this repo at all.
Removing the hook narrows the exposure; it does not replace the fix.

Both properties are mutation-verified. Reverting to the unguarded ordering
fails the new throwing-logger disposal test; removing the Exception read fails
the observation test.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…tops capturing `this`

Qodo round 5 on #423.

The abandoned-open continuation logged through the instance field _logger, so
the closure captured `this`. In the scenario this code exists for - an open that
never returns - the still-running task retained the whole inspector object graph
for the life of the process rather than just the port and logger it needs for
cleanup.

Hoisting _logger into a local removes the last reach through the instance;
`port` and `portName` were already parameters, so the continuation now captures
only what it uses.

Managed retention, not a handle leak - materially smaller than round 4's
disposal hazard. Taken because it is a one-liner with no risk, not because it is
serious. No test: asserting on closure capture is brittle and would cost more
than it protects, and the existing tests already cover the behavior that matters
(the fault is observed, the port is disposed).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ive bridge run

The bench session found a real bug that 85 framing tests could not.

ReadFlashJedecId wrote DATA_CNT 0 and DMA_ADDR 0. Against a live WINC the
command still completed and TR_DONE still went high, but no result bytes were
clocked back, so the read returned 0x00000000. Every frame was well-formed and
the device ACKed every one - nothing about the protocol layer was wrong, only
two register values, which is precisely the class of defect framing tests
cannot see.

Corrected against the WINC driver's spi_flash_rdid: DATA_CNT is the number of
result bytes (4) and DMA_ADDR is where they land (DUMMY_REGISTER).

Verified on hardware: 0x00000000 before, 0xC21320C2 after - Macronix (0xC2),
type 0x20, capacity 0x13 = 4 Mbit / 512 KB, which matches the 4 Mb WINC SPI
flash the issue describes.

Adds a test pinning the register sequence value-by-value against the firmware,
rather than only asserting the return, so this class of bug is caught in CI
from now on.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…e, not a final-state map

Qodo round 6 on #423. The test's central claim was false.

ReadFlashJedecId_WritesTheExactRegisterSequenceTheWincDriverUses said "exact
register sequence" in its name but collapsed the writes into a
Dictionary<uint,uint> and asserted only the final value per address. That passes
on a wrong ordering, and passes on a wrong value later overwritten by a correct
one. For this command path order IS the protocol: DATA_CNT and DMA_ADDR stage
the transfer and CMD_CNT triggers it, so a map cannot express the property the
name promises - and the name is what a future reader trusts.

Now asserted as an ordered list of (address, value) pairs. Extended the same
guarantee to the fast-read path, which is the one that actually carries data
and has the identical staging requirement.

Register addresses are restated independently in the test from the WINC driver's
spi_flash.c rather than reused from production, so a typo in the production map
fails these tests instead of being silently agreed with.

Mutation-verified both, using reorderings that leave every value correct:
- JEDEC: firing CMD_CNT before DMA_ADDR turns it red. Notably the value-only
  test alongside it still passes, which is exactly the blind spot being closed.
- Fast read: swapping BUF2 and BUF_DIR turns it red.

Production code is untouched by this commit - test-only.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@tylerkron
tylerkron force-pushed the feature/native-winc-flasher branch from d2ff50d to d2a719e Compare August 2, 2026 01:52
@tylerkron
tylerkron merged commit 359bf74 into main Aug 2, 2026
1 check failed
@tylerkron
tylerkron deleted the feature/native-winc-flasher branch August 2, 2026 01:52
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