diff --git a/src/Daqifi.Core.Tests/Firmware/Winc/FakeWincSerialPort.cs b/src/Daqifi.Core.Tests/Firmware/Winc/FakeWincSerialPort.cs new file mode 100644 index 0000000..87474d2 --- /dev/null +++ b/src/Daqifi.Core.Tests/Firmware/Winc/FakeWincSerialPort.cs @@ -0,0 +1,205 @@ +using Daqifi.Core.Firmware.Winc; + +namespace Daqifi.Core.Tests.Firmware.Winc; + +/// +/// A fake serial port that emulates the DAQiFi firmware's WINC bridge state machine, so the client +/// can be exercised end-to-end without hardware. +/// +/// +/// This deliberately re-implements the firmware's parser (op-code wait, 12-byte header, XOR +/// validation, ACK/NACK, payload wait) rather than replaying canned bytes. That way a client that +/// frames a command incorrectly gets rejected here exactly as the device would reject it — the +/// point is to catch framing bugs the bench cannot. +/// +internal sealed class FakeWincSerialPort : IWincSerialPort +{ + private readonly Queue _toHost = new(); + private readonly List _fromHost = []; + + private State _state = State.WaitOpCode; + private byte[] _header = []; + private int _pendingPayload; + + /// Register file the emulated WINC answers reads from. + internal Dictionary Registers { get; } = []; + + /// Memory the emulated WINC answers block reads from. + internal Dictionary Blocks { get; } = []; + + /// Every command header the host sent, in order. + internal List ReceivedHeaders { get; } = []; + + /// Every block-write payload the host sent, in order. + internal List ReceivedPayloads { get; } = []; + + /// Baud rates the host applied, in order. + internal List BaudRateHistory { get; } = []; + + /// When true, the emulated bridge NACKs the next block write. + internal bool FailNextBlockWrite { get; set; } + + /// When true, the bridge does not answer the identify op code. + internal bool SuppressIdentityResponse { get; set; } + + /// Number of times the host discarded its input buffer. + internal int DiscardCount { get; private set; } + + internal bool WasDisposed { get; private set; } + + public bool IsOpen { get; private set; } + + private int _baudRate = 115200; + + public int BaudRate + { + get => _baudRate; + set + { + _baudRate = value; + BaudRateHistory.Add(value); + } + } + + public void Open() => IsOpen = true; + + public void Close() => IsOpen = false; + + public void DiscardInBuffer() + { + DiscardCount++; + _toHost.Clear(); + } + + public void Write(byte[] buffer, int offset, int count) + { + for (var i = 0; i < count; i++) + { + Feed(buffer[offset + i]); + } + } + + public void ReadExactly(byte[] buffer, int offset, int count, TimeSpan timeout) + { + if (_toHost.Count < count) + { + throw new TimeoutException( + $"Fake port has {_toHost.Count} byte(s) buffered but {count} were requested."); + } + + for (var i = 0; i < count; i++) + { + buffer[offset + i] = _toHost.Dequeue(); + } + } + + public void Dispose() + { + WasDisposed = true; + IsOpen = false; + } + + /// Drives the emulated bridge one received byte at a time. + private void Feed(byte b) + { + _fromHost.Add(b); + + switch (_state) + { + case State.WaitOpCode: + if (b == WincBridgeProtocol.IdentifyVariableBaud) + { + if (!SuppressIdentityResponse) + { + _toHost.Enqueue(WincBridgeProtocol.Response.IdVariableBaud); + } + } + else if (b == WincBridgeProtocol.StartCommand) + { + _state = State.WaitHeader; + _header = []; + } + // Any other op code is ignored, exactly as the firmware does. + break; + + case State.WaitHeader: + _header = [.. _header, b]; + if (_header.Length == WincBridgeProtocol.HeaderSize) + { + ReceivedHeaders.Add(_header); + HandleHeader(); + } + break; + + case State.WaitPayload: + _header = [.. _header, b]; + if (_header.Length == _pendingPayload) + { + ReceivedPayloads.Add(_header); + _toHost.Enqueue( + FailNextBlockWrite + ? WincBridgeProtocol.Response.Nack + : WincBridgeProtocol.Response.Ack); + FailNextBlockWrite = false; + _state = State.WaitOpCode; + } + break; + } + } + + private void HandleHeader() + { + if (!WincBridgeProtocol.IsHeaderValid(_header)) + { + _toHost.Enqueue(WincBridgeProtocol.Response.Nack); + _state = State.WaitOpCode; + return; + } + + _toHost.Enqueue(WincBridgeProtocol.Response.Ack); + + var command = (WincBridgeProtocol.Command)_header[0]; + var size = (ushort)((_header[3] << 8) | _header[2]); + var address = ((uint)_header[7] << 24) | ((uint)_header[6] << 16) | ((uint)_header[5] << 8) | _header[4]; + + switch (command) + { + case WincBridgeProtocol.Command.ReadRegisterWithReturn: + var value = Registers.TryGetValue(address, out var v) ? v : 0u; + // Big-endian, matching the firmware. + _toHost.Enqueue((byte)(value >> 24)); + _toHost.Enqueue((byte)(value >> 16)); + _toHost.Enqueue((byte)(value >> 8)); + _toHost.Enqueue((byte)value); + _state = State.WaitOpCode; + break; + + case WincBridgeProtocol.Command.ReadBlock: + var block = Blocks.TryGetValue(address, out var data) ? data : new byte[size]; + for (var i = 0; i < size; i++) + { + _toHost.Enqueue(i < block.Length ? block[i] : (byte)0); + } + _state = State.WaitOpCode; + break; + + case WincBridgeProtocol.Command.WriteBlock: + _pendingPayload = size; + _header = []; + _state = State.WaitPayload; + break; + + default: + // WriteRegister and Reconfigure produce no data beyond the header ACK. + _state = State.WaitOpCode; + break; + } + } + + private enum State + { + WaitOpCode, + WaitHeader, + WaitPayload + } +} diff --git a/src/Daqifi.Core.Tests/Firmware/Winc/WincBridgeProtocolTests.cs b/src/Daqifi.Core.Tests/Firmware/Winc/WincBridgeProtocolTests.cs new file mode 100644 index 0000000..6a9109b --- /dev/null +++ b/src/Daqifi.Core.Tests/Firmware/Winc/WincBridgeProtocolTests.cs @@ -0,0 +1,192 @@ +using Daqifi.Core.Firmware.Winc; + +namespace Daqifi.Core.Tests.Firmware.Winc; + +/// +/// Framing tests for the WINC serial bridge wire format. +/// +/// +/// These are the primary correctness net for the native flasher: the erase/program path cannot be +/// exercised on the bench, so the framing has to be right by construction. Expected byte sequences +/// are derived from the DAQiFi firmware's bridge parser +/// (firmware/src/services/wifi_services/wifi_serial_bridge.c), which is the code that +/// actually accepts or rejects these headers. +/// +public class WincBridgeProtocolTests +{ + [Fact] + public void BuildHeader_ProducesTwelveBytesWhoseXorIsZero() + { + // The firmware XORs all 12 bytes and requires 0 before it will ACK. + var header = WincBridgeProtocol.BuildHeader( + WincBridgeProtocol.Command.ReadBlock, 0x0100, 0xD0000, 0); + + Assert.Equal(WincBridgeProtocol.HeaderSize, header.Length); + + byte xor = 0; + foreach (var b in header) + { + xor ^= b; + } + + Assert.Equal(0, xor); + Assert.True(WincBridgeProtocol.IsHeaderValid(header)); + } + + [Theory] + [InlineData((byte)0, (ushort)0, 0x1000u, 0u)] // ReadRegisterWithReturn + [InlineData((byte)1, (ushort)0, 0x10208u, 0x2Au)] // WriteRegister + [InlineData((byte)2, (ushort)2047, 0xD0000u, 0u)] // ReadBlock, max safe size + [InlineData((byte)3, (ushort)256, 0xD0000u, 0u)] // WriteBlock, one flash page + [InlineData((byte)5, (ushort)0, 0u, 500000u)] // Reconfigure to the fast baud + [InlineData((byte)1, (ushort)0xFFFF, 0xFFFFFFFFu, 0xFFFFFFFFu)] // all-ones saturation + public void BuildHeader_IsAlwaysAcceptedByTheFirmwareChecksumRule( + byte command, ushort size, uint address, uint value) + { + var header = WincBridgeProtocol.BuildHeader( + (WincBridgeProtocol.Command)command, size, address, value); + + Assert.True(WincBridgeProtocol.IsHeaderValid(header)); + } + + [Fact] + public void BuildHeader_LaysOutCommandSizeAddressAndValueLittleEndian() + { + // Byte-for-byte against the firmware's ProcessHeader field extraction: + // size = [3]<<8 | [2]; addr = [7]<<24 | [6]<<16 | [5]<<8 | [4]; val likewise from [8..11]. + var header = WincBridgeProtocol.BuildHeader( + WincBridgeProtocol.Command.WriteRegister, + size: 0x1234, + address: 0xAABBCCDD, + value: 0x11223344); + + Assert.Equal((byte)WincBridgeProtocol.Command.WriteRegister, header[0]); + + Assert.Equal(0x34, header[2]); + Assert.Equal(0x12, header[3]); + + Assert.Equal(0xDD, header[4]); + Assert.Equal(0xCC, header[5]); + Assert.Equal(0xBB, header[6]); + Assert.Equal(0xAA, header[7]); + + Assert.Equal(0x44, header[8]); + Assert.Equal(0x33, header[9]); + Assert.Equal(0x22, header[10]); + Assert.Equal(0x11, header[11]); + } + + [Fact] + public void BuildHeader_ReconfigurePutsTheBaudRateInTheValueField() + { + // The baud change is the one command whose payload is the value field alone. + var header = WincBridgeProtocol.BuildHeader( + WincBridgeProtocol.Command.Reconfigure, 0, 0, 500000); + + Assert.Equal(0x05, header[0]); + Assert.Equal(0x20, header[8]); + Assert.Equal(0xA1, header[9]); + Assert.Equal(0x07, header[10]); + Assert.Equal(0x00, header[11]); + Assert.True(WincBridgeProtocol.IsHeaderValid(header)); + } + + [Fact] + public void IsHeaderValid_RejectsASingleFlippedBit() + { + var header = WincBridgeProtocol.BuildHeader( + WincBridgeProtocol.Command.ReadBlock, 512, 0xD0000, 0); + + header[6] ^= 0x01; + + Assert.False(WincBridgeProtocol.IsHeaderValid(header)); + } + + [Theory] + [InlineData(0)] + [InlineData(11)] + [InlineData(13)] + public void IsHeaderValid_RejectsWrongLength(int length) + { + Assert.False(WincBridgeProtocol.IsHeaderValid(new byte[length])); + } + + [Fact] + public void IsHeaderValid_RejectsNull() + { + Assert.False(WincBridgeProtocol.IsHeaderValid(null!)); + } + + [Fact] + public void ComputeChecksum_IgnoresTheExistingChecksumSlot() + { + // Byte 1 must not feed its own computation, or rebuilding a header would drift. + var header = WincBridgeProtocol.BuildHeader( + WincBridgeProtocol.Command.ReadBlock, 128, 0x1000, 0); + + var recomputed = WincBridgeProtocol.ComputeChecksum(header); + + Assert.Equal(header[1], recomputed); + } + + [Fact] + public void ComputeChecksum_RejectsWrongLengthHeaders() + { + Assert.Throws(() => WincBridgeProtocol.ComputeChecksum(new byte[5])); + } + + [Fact] + public void DecodeRegisterValue_ReadsBigEndian_OppositeOfTheHeaderFields() + { + // The firmware writes the register value MSB-first, unlike the little-endian header — + // getting this backwards is the single easiest way to misread every register. + var value = WincBridgeProtocol.DecodeRegisterValue([0x00, 0x10, 0x03, 0xA0]); + + Assert.Equal(0x001003A0u, value); + } + + [Fact] + public void DecodeRegisterValue_HandlesTheFullRange() + { + Assert.Equal(0xFFFFFFFFu, WincBridgeProtocol.DecodeRegisterValue([0xFF, 0xFF, 0xFF, 0xFF])); + Assert.Equal(0u, WincBridgeProtocol.DecodeRegisterValue([0x00, 0x00, 0x00, 0x00])); + } + + [Theory] + [InlineData(0)] + [InlineData(3)] + [InlineData(5)] + public void DecodeRegisterValue_RejectsWrongLength(int length) + { + Assert.Throws(() => WincBridgeProtocol.DecodeRegisterValue(new byte[length])); + } + + [Fact] + public void OpCodesAndResponses_MatchTheFirmwareConstants() + { + // Pinned against wifi_serial_bridge.c; a silent drift here would break every exchange. + Assert.Equal(0x12, WincBridgeProtocol.IdentifyVariableBaud); + Assert.Equal(0x13, WincBridgeProtocol.IdentifyFixedBaud); + Assert.Equal(0xA5, WincBridgeProtocol.StartCommand); + + Assert.Equal(0x5A, WincBridgeProtocol.Response.Nack); + Assert.Equal(0x5B, WincBridgeProtocol.Response.IdVariableBaud); + Assert.Equal(0x5C, WincBridgeProtocol.Response.IdFixedBaud); + Assert.Equal(0xAC, WincBridgeProtocol.Response.Ack); + + Assert.Equal(0, (byte)WincBridgeProtocol.Command.ReadRegisterWithReturn); + Assert.Equal(1, (byte)WincBridgeProtocol.Command.WriteRegister); + Assert.Equal(2, (byte)WincBridgeProtocol.Command.ReadBlock); + Assert.Equal(3, (byte)WincBridgeProtocol.Command.WriteBlock); + Assert.Equal(5, (byte)WincBridgeProtocol.Command.Reconfigure); + } + + [Fact] + public void MaxReadBlockSize_StaysBelowTheFirmwareCommandBuffer() + { + // The device's read loop never terminates at or above its 2048-byte buffer, so 2047 is a + // hard ceiling rather than a tuning choice. + Assert.Equal(2047, WincBridgeProtocol.MaxReadBlockSize); + Assert.True(WincBridgeProtocol.MaxReadBlockSize < 2048); + } +} diff --git a/src/Daqifi.Core.Tests/Firmware/Winc/WincFlasherTests.cs b/src/Daqifi.Core.Tests/Firmware/Winc/WincFlasherTests.cs new file mode 100644 index 0000000..8b1bad0 --- /dev/null +++ b/src/Daqifi.Core.Tests/Firmware/Winc/WincFlasherTests.cs @@ -0,0 +1,648 @@ +using Daqifi.Core.Firmware.Winc; +using Microsoft.Extensions.Logging; + +namespace Daqifi.Core.Tests.Firmware.Winc; + +/// +/// Covers the flash-level read sequences and the two implementations. +/// +public class WincFlasherTests +{ + // Restated independently from the WINC driver's spi_flash.c rather than reused from + // production, so a typo in the production register map fails these tests instead of being + // silently agreed with. SPI_FLASH_BASE is 0x10200. + private const uint RegCommandCount = 0x10204; + private const uint RegDataCount = 0x10208; + private const uint RegBuffer1 = 0x1020C; + private const uint RegBuffer2 = 0x10210; + private const uint RegBufferDirection = 0x10214; + private const uint TransferDoneRegister = 0x10218; + private const uint RegDmaAddress = 0x1021C; + private const uint DummyRegister = 0x1084; + private const uint ShareMemoryBase = 0xD0000; + + /// + /// Builds a fake whose transfer-done register always reads 1, so flash sequences complete. + /// + private static FakeWincSerialPort CreateReadyPort() + { + var port = new FakeWincSerialPort(); + port.Registers[TransferDoneRegister] = 1; + return port; + } + + private static WincFlashReader CreateReader(FakeWincSerialPort port) + => new(new WincSerialBridgeClient(port, TimeSpan.FromSeconds(1))); + + [Theory] + [InlineData(0x001003A0u, true)] // bench-observed: halted in download mode + [InlineData(0x00150000u, true)] // firmware running + [InlineData(0x0015FFFFu, true)] + [InlineData(0x00000000u, false)] // nothing there + [InlineData(0xFFFFFFFFu, false)] // floating bus + [InlineData(0x00200000u, false)] // some other part + public void IsKnownWincChipId_RecognizesOnlyTheWinc1500Families(uint chipId, bool expected) + { + Assert.Equal(expected, WincFlashReader.IsKnownWincChipId(chipId)); + } + + [Fact] + public void ReadChipId_ReadsTheIdentityRegister() + { + var port = CreateReadyPort(); + port.Registers[WincFlashReader.ChipIdRegister] = 0x001003A0; + + Assert.Equal(0x001003A0u, CreateReader(port).ReadChipId()); + } + + [Fact] + public void ReadFlash_ChunksBelowTheSizeThatWedgesTheDevice() + { + // A 5 KB read must never issue a single block request at or above 2048 bytes, because the + // device's read loop would never terminate. This is the property that matters most here. + var port = CreateReadyPort(); + port.Blocks[ShareMemoryBase] = new byte[WincBridgeProtocol.MaxReadBlockSize]; + + CreateReader(port).ReadFlash(0, 5000); + + var blockReadSizes = port.ReceivedHeaders + .Where(h => h[0] == (byte)WincBridgeProtocol.Command.ReadBlock) + .Select(h => (h[3] << 8) | h[2]) + .ToList(); + + Assert.NotEmpty(blockReadSizes); + Assert.All(blockReadSizes, size => Assert.True(size <= WincBridgeProtocol.MaxReadBlockSize)); + } + + [Fact] + public void ReadFlash_ReturnsExactlyTheRequestedLength() + { + var port = CreateReadyPort(); + port.Blocks[ShareMemoryBase] = Enumerable.Range(0, WincBridgeProtocol.MaxReadBlockSize) + .Select(i => (byte)(i & 0xFF)) + .ToArray(); + + var data = CreateReader(port).ReadFlash(0, 3000); + + Assert.Equal(3000, data.Length); + } + + [Fact] + public void ReadFlash_IssuesTheFastReadCommandWithTheAddressInTheControllerWord() + { + // Mirrors the WINC host driver's load-to-shared-memory sequence: opcode 0x0B in the low + // byte, then the 24-bit flash address ascending. + var port = CreateReadyPort(); + port.Blocks[ShareMemoryBase] = new byte[16]; + + CreateReader(port).ReadFlash(0x123456, 16); + + var buffer1Write = port.ReceivedHeaders.First(h => + h[0] == (byte)WincBridgeProtocol.Command.WriteRegister && + (((uint)h[7] << 24) | ((uint)h[6] << 16) | ((uint)h[5] << 8) | h[4]) == 0x1020C); + + var commandWord = ((uint)buffer1Write[11] << 24) | ((uint)buffer1Write[10] << 16) + | ((uint)buffer1Write[9] << 8) | buffer1Write[8]; + + Assert.Equal(0x0Bu, commandWord & 0xFF); + Assert.Equal(0x12u, (commandWord >> 8) & 0xFF); + Assert.Equal(0x34u, (commandWord >> 16) & 0xFF); + Assert.Equal(0x56u, (commandWord >> 24) & 0xFF); + } + + [Fact] + public void ReadFlashJedecId_ReturnsTheControllerResult() + { + var port = CreateReadyPort(); + port.Registers[DummyRegister] = 0x00C22018; + + Assert.Equal(0x00C22018u, CreateReader(port).ReadFlashJedecId()); + } + + [Fact] + public void ReadFlashJedecId_WritesTheExactRegisterSequenceTheWincDriverUses() + { + // Pinned register-by-register against the WINC driver's spi_flash_rdid. + // + // This exists because the bench caught what framing tests structurally could not: the + // original code wrote DATA_CNT 0 and DMA_ADDR 0, so no result bytes were clocked back and + // the read returned 0x00000000 from a live module. Every frame was well-formed and the + // device ACKed every one, so nothing about the protocol layer was wrong - only the values. + // Asserting the sequence, not just the return, is what catches that class of bug. + var port = CreateReadyPort(); + port.Registers[DummyRegister] = 0x00C22018; + + CreateReader(port).ReadFlashJedecId(); + + // Asserted as an ordered sequence, not a final-state map. Order is the protocol here: + // DATA_CNT and DMA_ADDR configure the transfer and CMD_CNT triggers it, so a map-based + // assertion would pass even if CMD_CNT fired first, or if a wrong value were written and + // silently corrected afterwards. + Assert.Equal( + [ + (RegDataCount, 4u), // 4 result bytes + (RegBuffer1, 0x9Fu), // RDID opcode + (RegBufferDirection, 0x01u), + (RegDmaAddress, DummyRegister), // where the result lands + (RegCommandCount, 1u | (1u << 7)) // 1 command byte + start bit — must be last + ], + RegisterWriteSequence(port)); + } + + [Fact] + public void ReadFlash_WritesTheExactFastReadRegisterSequence() + { + // Same ordering guarantee on the path that actually carries data. The controller latches + // the staged configuration when CMD_CNT is written, so everything else must precede it. + var port = CreateReadyPort(); + port.Blocks[ShareMemoryBase] = new byte[16]; + + CreateReader(port).ReadFlash(0x123456, 16); + + Assert.Equal( + [ + (RegDataCount, 16u), + (RegBuffer1, 0x5634120Bu), // 0x0B opcode + 24-bit address ascending + (RegBuffer2, 0xA5u), // fast-read dummy byte + (RegBufferDirection, 0x1Fu), + (RegDmaAddress, ShareMemoryBase), + (RegCommandCount, 5u | (1u << 7)) // 5 command bytes + start bit — must be last + ], + RegisterWriteSequence(port)); + } + + /// + /// Every WriteRegister the client sent, in the order it was sent, as (address, value). + /// + private static List<(uint Address, uint Value)> RegisterWriteSequence(FakeWincSerialPort port) + => port.ReceivedHeaders + .Where(h => h[0] == (byte)WincBridgeProtocol.Command.WriteRegister) + .Select(h => ( + Address: ((uint)h[7] << 24) | ((uint)h[6] << 16) | ((uint)h[5] << 8) | h[4], + Value: ((uint)h[11] << 24) | ((uint)h[10] << 16) | ((uint)h[9] << 8) | h[8])) + .ToList(); + + [Fact] + public void ReadFlash_ThrowsWhenTheControllerNeverReportsDone() + { + // The firmware's own poll loop is unbounded; ours must not be, or a WINC that stops + // answering would hang the host indefinitely. + var port = new FakeWincSerialPort(); + port.Registers[TransferDoneRegister] = 0; + + var reader = new WincFlashReader( + new WincSerialBridgeClient(port, TimeSpan.FromSeconds(1)), transferPollLimit: 3); + + var ex = Assert.Throws(() => reader.ReadFlash(0, 16)); + + Assert.Contains("transfer-done", ex.Message, StringComparison.OrdinalIgnoreCase); + } + + [Theory] + [InlineData(0)] + [InlineData(-1)] + public void ReadFlash_RejectsNonPositiveLengths(int length) + { + Assert.Throws(() => CreateReader(CreateReadyPort()).ReadFlash(0, length)); + } + + [Theory] + [InlineData(0xFFFF00u, 0x200)] // straddles the top of the 24-bit space + [InlineData(0xFFFFFFu, 2)] // one byte past the last address + [InlineData(0xFFFFFFu, int.MaxValue)] + public void ReadFlash_RejectsSpansThatWouldWrapTheAddress(uint offset, int length) + { + // Without the bounds check, offset + read wraps past 32 bits and the chunk silently + // targets the wrong flash address — returning plausible data with no error at all. + var port = CreateReadyPort(); + + var ex = Assert.Throws( + () => CreateReader(port).ReadFlash(offset, length)); + + Assert.Equal("length", ex.ParamName); + Assert.Empty(port.ReceivedHeaders); + } + + [Fact] + public void ReadFlash_AcceptsASpanEndingExactlyAtTheLastAddress() + { + // Boundary: the final byte is addressable and must not be rejected. + var port = CreateReadyPort(); + port.Blocks[ShareMemoryBase] = new byte[16]; + + var data = CreateReader(port).ReadFlash(WincFlashReader.MaxFlashAddress - 15, 16); + + Assert.Equal(16, data.Length); + } + + [Fact] + public void ReadFlash_ObservesCancellation() + { + var port = CreateReadyPort(); + port.Blocks[ShareMemoryBase] = new byte[WincBridgeProtocol.MaxReadBlockSize]; + using var cts = new CancellationTokenSource(); + cts.Cancel(); + + Assert.Throws( + () => CreateReader(port).ReadFlash(0, 5000, cts.Token)); + } + + // ---- WincModuleInspector ------------------------------------------------- + + [Fact] + public async Task Inspector_ReadIdentityAsync_HandshakesNegotiatesBaudThenReadsIdentity() + { + var port = CreateReadyPort(); + port.Registers[WincFlashReader.ChipIdRegister] = 0x001003A0; + port.Registers[DummyRegister] = 0x00C22018; + + var flasher = new WincModuleInspector((_, _) => port, baudSettleDelay: TimeSpan.Zero); + + var identity = await flasher.ReadIdentityAsync("COM1"); + + Assert.Equal(0x001003A0u, identity.ChipId); + Assert.Equal(0x00C22018u, identity.FlashJedecId); + Assert.True(identity.IsRecognizedWinc); + Assert.Equal(WincModuleInspector.FastBaudRate, identity.NegotiatedBaudRate); + Assert.Contains(WincModuleInspector.FastBaudRate, port.BaudRateHistory); + } + + [Fact] + public async Task Inspector_ReadIdentityAsync_ReportsAnUnrecognizedChip() + { + // Bridge up but WINC not answering is a distinct, actionable condition from a dead port. + var port = CreateReadyPort(); + port.Registers[WincFlashReader.ChipIdRegister] = 0xFFFFFFFF; + + var flasher = new WincModuleInspector((_, _) => port, baudSettleDelay: TimeSpan.Zero); + + var identity = await flasher.ReadIdentityAsync("COM1"); + + Assert.False(identity.IsRecognizedWinc); + } + + [Fact] + public async Task Inspector_ReadIdentityAsync_ExplainsWhenNoBridgeIsListening() + { + var port = new FakeWincSerialPort { SuppressIdentityResponse = true }; + var flasher = new WincModuleInspector((_, _) => port, baudSettleDelay: TimeSpan.Zero); + + var ex = await Assert.ThrowsAsync(() => flasher.ReadIdentityAsync("COM1")); + + Assert.Contains("bridge", ex.Message, StringComparison.OrdinalIgnoreCase); + Assert.True(port.WasDisposed); + } + + [Fact] + public async Task Inspector_AbandonsAnOpenThatHangs_RatherThanBlockingForever() + { + // SerialPort.Open takes no cancellation token and can block indefinitely on a wedged or + // half-enumerated USB CDC device. The only way to stay responsive is a hard deadline. + var port = new HangingOpenPort(); + var inspector = new WincModuleInspector( + (_, _) => port, + baudSettleDelay: TimeSpan.Zero, + openTimeout: TimeSpan.FromMilliseconds(150)); + + var sw = System.Diagnostics.Stopwatch.StartNew(); + var ex = await Assert.ThrowsAsync(() => inspector.ReadIdentityAsync("COM1")); + sw.Stop(); + + Assert.Contains("did not complete", ex.Message, StringComparison.OrdinalIgnoreCase); + Assert.True(sw.Elapsed < TimeSpan.FromSeconds(5), $"took {sw.Elapsed}, should have bailed at the deadline"); + + // The abandoned open still owns the handle, so this side must NOT have disposed it. + Assert.False(port.DisposedWhileOpenStillBlocked); + + port.ReleaseOpen(); + } + + [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). + // + // Asserted through the logger rather than TaskScheduler.UnobservedTaskException plus a + // forced GC. That route depends on collector and finalizer timing — nondeterminism that + // makes a flaky CI gate — and the event is process-global, coupling this test to every + // other task in a parallel run. + // + // Reading Task.Exception is what marks a fault observed, and production already reads it + // to hand to the logger. So a logger that captures the exception proves the read happened, + // using a seam that exists for production reasons rather than a test-only hook. + var logger = new CapturingLogger(); + var port = new FaultingAfterDelayPort(TimeSpan.FromMilliseconds(150)); + var inspector = new WincModuleInspector( + (_, _) => port, + logger, + baudSettleDelay: TimeSpan.Zero, + openTimeout: TimeSpan.FromMilliseconds(50)); + + await Assert.ThrowsAsync(() => inspector.ReadIdentityAsync("COM1")); + + var observed = await logger.FirstException.WaitAsync(TimeSpan.FromSeconds(10)); + + Assert.IsType(observed); + Assert.IsType(((AggregateException)observed).InnerException); + + // The continuation still owns disposal of the port it took over. + await port.Disposed.WaitAsync(TimeSpan.FromSeconds(10)); + } + + [Fact] + public async Task Inspector_DisposesTheAbandonedPort_EvenWhenObservingTheFaultThrows() + { + // Releasing the handle is the whole reason the abandonment continuation exists. If a + // throwing logger could skip past the disposal, the abandoned port would leak and break + // every later open until the process exits — strictly worse than the fault being reported. + var port = new FaultingAfterDelayPort(TimeSpan.FromMilliseconds(150)); + var inspector = new WincModuleInspector( + (_, _) => port, + new ThrowingLogger(), + baudSettleDelay: TimeSpan.Zero, + openTimeout: TimeSpan.FromMilliseconds(50)); + + await Assert.ThrowsAsync(() => inspector.ReadIdentityAsync("COM1")); + + // Disposal must still happen despite the observation throwing. + await port.Disposed.WaitAsync(TimeSpan.FromSeconds(10)); + } + + [Fact] + public async Task Inspector_ObservesCancellationDuringAHangingOpen() + { + var port = new HangingOpenPort(); + var inspector = new WincModuleInspector( + (_, _) => port, + baudSettleDelay: TimeSpan.Zero, + openTimeout: TimeSpan.FromMinutes(5)); + + using var cts = new CancellationTokenSource(TimeSpan.FromMilliseconds(150)); + + await Assert.ThrowsAnyAsync( + () => inspector.ReadIdentityAsync("COM1", cts.Token)); + + port.ReleaseOpen(); + } + + [Fact] + public async Task Inspector_ReadIdentityAsync_RejectsAnEmptyPortName() + { + var flasher = new WincModuleInspector((_, _) => CreateReadyPort()); + + await Assert.ThrowsAsync(() => flasher.ReadIdentityAsync(" ")); + } + + // ---- WincFlashToolLocator ------------------------------------------- + + [Fact] + public void Locator_IsAvailable_WhenTheFirmwarePathIsTheToolItself() + { + var toolPath = Path.Combine(Path.GetTempPath(), $"winc_flash_tool_{Guid.NewGuid():N}.cmd"); + File.WriteAllText(toolPath, "@echo off"); + + try + { + var locator = new WincFlashToolLocator("winc_flash_tool.cmd"); + + Assert.True(locator.IsAvailable(toolPath)); + } + finally + { + File.Delete(toolPath); + } + } + + [Fact] + public void Locator_IsAvailable_FindsTheToolBeneathADirectory() + { + var root = Path.Combine(Path.GetTempPath(), $"winc_{Guid.NewGuid():N}"); + var nested = Path.Combine(root, "winc"); + Directory.CreateDirectory(nested); + var toolPath = Path.Combine(nested, "winc_flash_tool.cmd"); + File.WriteAllText(toolPath, "@echo off"); + + try + { + var locator = new WincFlashToolLocator("winc_flash_tool.cmd"); + + Assert.True(locator.IsAvailable(root)); + Assert.True(locator.TryResolveToolPath(root, out var resolved)); + Assert.Equal(toolPath, resolved); + } + finally + { + Directory.Delete(root, recursive: true); + } + } + + [Fact] + public void Locator_IsNotAvailable_WhenTheToolIsMissing() + { + // This is the Linux/macOS case that motivates issue #271 — the answer must be a clean + // "no", not an exception mid-update. + var root = Path.Combine(Path.GetTempPath(), $"winc_{Guid.NewGuid():N}"); + Directory.CreateDirectory(root); + + try + { + var locator = new WincFlashToolLocator("winc_flash_tool.cmd"); + + Assert.False(locator.IsAvailable(root)); + Assert.False(locator.TryResolveToolPath(root, out _)); + } + finally + { + Directory.Delete(root, recursive: true); + } + } + + [Fact] + public void Locator_TryResolve_PropagatesAnUnreadableTree_RatherThanReportingNotFound() + { + // "Could not locate the tool - WiFi flashing is Windows-only" is genuinely misleading when + // the tool is sitting right there behind a permissions problem, so this case must surface. + if (OperatingSystem.IsWindows()) + { + return; // chmod semantics differ; the behavior under test is the catch removal itself. + } + + var root = Path.Combine(Path.GetTempPath(), $"winc_{Guid.NewGuid():N}"); + var locked = Path.Combine(root, "locked"); + Directory.CreateDirectory(locked); + File.WriteAllText(Path.Combine(locked, "winc_flash_tool.cmd"), "@echo off"); + + try + { + File.SetUnixFileMode(locked, UnixFileMode.None); + + var locator = new WincFlashToolLocator("winc_flash_tool.cmd"); + + Assert.ThrowsAny(() => locator.TryResolveToolPath(root, out _)); + + // IsAvailable stays total: a probe answers yes/no and must not throw. + Assert.False(locator.IsAvailable(root)); + } + finally + { + File.SetUnixFileMode(locked, UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute); + Directory.Delete(root, recursive: true); + } + } + + [Fact] + public void Locator_IsNotAvailable_ForAPathThatDoesNotExist() + { + var locator = new WincFlashToolLocator("winc_flash_tool.cmd"); + + Assert.False(locator.IsAvailable(Path.Combine(Path.GetTempPath(), $"missing_{Guid.NewGuid():N}"))); + } + + /// + /// A port whose blocks until released, standing in for the real + /// SerialPort.Open() hang on a wedged or half-enumerated USB CDC device. + /// + private sealed class HangingOpenPort : IWincSerialPort + { + private readonly ManualResetEventSlim _release = new(false); + + /// True if something disposed the port while the open was still blocked. + internal bool DisposedWhileOpenStillBlocked { get; private set; } + + public bool IsOpen { get; private set; } + public int BaudRate { get; set; } = 115200; + + public void Open() + { + _release.Wait(); + IsOpen = true; + } + + internal void ReleaseOpen() => _release.Set(); + + public void Close() => IsOpen = false; + public void DiscardInBuffer() { } + public void Write(byte[] buffer, int offset, int count) { } + + public void ReadExactly(byte[] buffer, int offset, int count, TimeSpan timeout) + => throw new TimeoutException("Port never opened."); + + public void Dispose() + { + if (!_release.IsSet) + { + DisposedWhileOpenStillBlocked = true; + } + + _release.Set(); + } + } + + [Theory] + [InlineData("\0invalid")] // embedded NUL - ArgumentException, not IO + [InlineData(" ")] + [InlineData("")] + public void Locator_IsAvailable_IsTotal_EvenForMalformedPaths(string path) + { + // IsAvailable is documented as never throwing. A narrow IO-only catch would let an + // ArgumentException from a malformed path escape a probe whose entire purpose is to be + // safe to call with anything. + var locator = new WincFlashToolLocator("winc_flash_tool.cmd"); + + var ex = Record.Exception(() => locator.IsAvailable(path)); + + Assert.Null(ex); + Assert.False(locator.IsAvailable(path)); + } + + [Fact] + public void Locator_IsAvailable_IsTotal_ForAnAbsurdlyLongPath() + { + var locator = new WincFlashToolLocator("winc_flash_tool.cmd"); + var longPath = "/" + new string('x', 40_000); + + Assert.Null(Record.Exception(() => locator.IsAvailable(longPath))); + } + + /// Captures logged exceptions, exposing the first one as an awaitable. + private sealed class CapturingLogger : ILogger + { + private readonly TaskCompletionSource _firstException = + new(TaskCreationOptions.RunContinuationsAsynchronously); + + internal Task FirstException => _firstException.Task; + + public IDisposable? BeginScope(TState state) where TState : notnull => null; + + public bool IsEnabled(LogLevel logLevel) => true; + + public void Log( + LogLevel logLevel, + EventId eventId, + TState state, + Exception? exception, + Func formatter) + { + if (exception is not null) + { + _firstException.TrySetResult(exception); + } + } + } + + /// A logger that throws, standing in for a misbehaving logging pipeline. + private sealed class ThrowingLogger : ILogger + { + public IDisposable? BeginScope(TState state) where TState : notnull => null; + + public bool IsEnabled(LogLevel logLevel) => true; + + public void Log( + LogLevel logLevel, + EventId eventId, + TState state, + Exception? exception, + Func formatter) + => throw new InvalidOperationException("logger-boom"); + } + + /// + /// A port whose blocks briefly and then throws, standing in for an open that + /// is abandoned on the timeout and only fails afterwards. + /// + private sealed class FaultingAfterDelayPort(TimeSpan delay) : IWincSerialPort + { + private readonly TaskCompletionSource _disposed = + new(TaskCreationOptions.RunContinuationsAsynchronously); + + /// Completes when the port is disposed, so tests can await it deterministically. + internal Task Disposed => _disposed.Task; + + public bool IsOpen => false; + public int BaudRate { get; set; } = 115200; + + public void Open() + { + Thread.Sleep(delay); + throw new InvalidOperationException("abandoned-open-fault"); + } + + public void Close() { } + public void DiscardInBuffer() { } + public void Write(byte[] buffer, int offset, int count) { } + + public void ReadExactly(byte[] buffer, int offset, int count, TimeSpan timeout) + => throw new TimeoutException("Port never opened."); + + public void Dispose() => _disposed.TrySetResult(); + } + + [Fact] + public void Locator_RejectsAnEmptyToolName() + { + Assert.Throws(() => new WincFlashToolLocator(" ")); + } + + +} diff --git a/src/Daqifi.Core.Tests/Firmware/Winc/WincSerialBridgeClientTests.cs b/src/Daqifi.Core.Tests/Firmware/Winc/WincSerialBridgeClientTests.cs new file mode 100644 index 0000000..e398434 --- /dev/null +++ b/src/Daqifi.Core.Tests/Firmware/Winc/WincSerialBridgeClientTests.cs @@ -0,0 +1,304 @@ +using Daqifi.Core.Firmware.Winc; + +namespace Daqifi.Core.Tests.Firmware.Winc; + +/// +/// Exercises the bridge client against a fake that re-implements the firmware's parser, so a +/// mis-framed command is rejected here the same way the device would reject it. +/// +public class WincSerialBridgeClientTests +{ + private static WincSerialBridgeClient CreateClient(FakeWincSerialPort port) + => new(port, TimeSpan.FromSeconds(1)); + + [Fact] + public void TryHandshake_SucceedsWhenTheBridgeIdentifies() + { + var port = new FakeWincSerialPort(); + + Assert.True(CreateClient(port).TryHandshake()); + } + + [Fact] + public void TryHandshake_ReturnsFalseWhenNothingAnswers() + { + // A silent port means the device is not in bridge mode — a normal, recoverable condition, + // so this reports rather than throws. + var port = new FakeWincSerialPort { SuppressIdentityResponse = true }; + + Assert.False(CreateClient(port).TryHandshake()); + } + + [Fact] + public void TryHandshake_DiscardsStaleInputFirst() + { + var port = new FakeWincSerialPort(); + + CreateClient(port).TryHandshake(); + + Assert.True(port.DiscardCount >= 1); + } + + [Fact] + public void ReadRegister_RoundTripsABigEndianValue() + { + var port = new FakeWincSerialPort(); + port.Registers[0x1000] = 0x001003A0; // bench-observed WINC1500 chip id + + Assert.Equal(0x001003A0u, CreateClient(port).ReadRegister(0x1000)); + } + + [Fact] + public void ReadRegister_SendsAWellFormedHeaderTheDeviceAccepts() + { + var port = new FakeWincSerialPort(); + port.Registers[0x10218] = 1; + + CreateClient(port).ReadRegister(0x10218); + + var header = Assert.Single(port.ReceivedHeaders); + Assert.True(WincBridgeProtocol.IsHeaderValid(header)); + Assert.Equal((byte)WincBridgeProtocol.Command.ReadRegisterWithReturn, header[0]); + } + + [Fact] + public void WriteRegister_SendsValueAndAddressInTheHeader() + { + var port = new FakeWincSerialPort(); + + CreateClient(port).WriteRegister(0x10208, 0x400); + + var header = Assert.Single(port.ReceivedHeaders); + Assert.Equal((byte)WincBridgeProtocol.Command.WriteRegister, header[0]); + Assert.Equal(0x08, header[4]); + Assert.Equal(0x02, header[5]); + Assert.Equal(0x01, header[6]); + Assert.Equal(0x00, header[8]); + Assert.Equal(0x04, header[9]); + } + + [Fact] + public void ReadBlock_ReturnsTheRequestedBytes() + { + var port = new FakeWincSerialPort(); + port.Blocks[0xD0000] = [1, 2, 3, 4, 5, 6, 7, 8]; + + var data = CreateClient(port).ReadBlock(0xD0000, 8); + + Assert.Equal([1, 2, 3, 4, 5, 6, 7, 8], data); + } + + [Theory] + [InlineData(2048)] + [InlineData(4096)] + [InlineData(65536)] + public void ReadBlock_RejectsSizesThatWouldWedgeTheDevice(int size) + { + // At or above the firmware's 2048-byte buffer its read loop never terminates. Failing fast + // here is what keeps a bulk read from hanging forever against real hardware. + var port = new FakeWincSerialPort(); + + var ex = Assert.Throws( + () => CreateClient(port).ReadBlock(0xD0000, size)); + + Assert.Equal("size", ex.ParamName); + Assert.Empty(port.ReceivedHeaders); + } + + [Fact] + public void ReadBlock_AcceptsTheMaximumSafeSize() + { + var port = new FakeWincSerialPort(); + port.Blocks[0xD0000] = new byte[WincBridgeProtocol.MaxReadBlockSize]; + + var data = CreateClient(port).ReadBlock(0xD0000, WincBridgeProtocol.MaxReadBlockSize); + + Assert.Equal(WincBridgeProtocol.MaxReadBlockSize, data.Length); + } + + [Theory] + [InlineData(0)] + [InlineData(-1)] + public void ReadBlock_RejectsNonPositiveSizes(int size) + { + Assert.Throws( + () => CreateClient(new FakeWincSerialPort()).ReadBlock(0xD0000, size)); + } + + [Fact] + public void WriteBlock_SendsThePayloadAfterTheHeaderAck() + { + var port = new FakeWincSerialPort(); + byte[] payload = [0xDE, 0xAD, 0xBE, 0xEF]; + + CreateClient(port).WriteBlock(0xD0000, payload); + + Assert.Equal(payload, Assert.Single(port.ReceivedPayloads)); + var header = Assert.Single(port.ReceivedHeaders); + Assert.Equal((byte)WincBridgeProtocol.Command.WriteBlock, header[0]); + Assert.Equal(4, (header[3] << 8) | header[2]); + } + + [Fact] + public void WriteBlock_ThrowsWhenTheDeviceNacksThePayload() + { + var port = new FakeWincSerialPort { FailNextBlockWrite = true }; + + var ex = Assert.Throws( + () => CreateClient(port).WriteBlock(0xD0000, [1, 2, 3, 4])); + + Assert.Contains("rejected", ex.Message, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void WriteBlock_RejectsAnEmptyPayload() + { + Assert.Throws( + () => CreateClient(new FakeWincSerialPort()).WriteBlock(0xD0000, [])); + } + + [Fact] + public void WriteBlock_RejectsAnOversizePayload() + { + Assert.Throws( + () => CreateClient(new FakeWincSerialPort()) + .WriteBlock(0xD0000, new byte[WincBridgeProtocol.MaxWriteBlockSize + 1])); + } + + [Fact] + public void ChangeBaudRate_TellsTheDeviceThenMovesTheHost() + { + // Order is load-bearing: the device switches as soon as it processes the command, so the + // header must already be on the wire before the host rate changes. + var port = new FakeWincSerialPort(); + + CreateClient(port).ChangeBaudRate(500000, TimeSpan.Zero); + + var header = Assert.Single(port.ReceivedHeaders); + Assert.Equal((byte)WincBridgeProtocol.Command.Reconfigure, header[0]); + Assert.Equal(500000, Assert.Single(port.BaudRateHistory)); + Assert.Equal(500000, port.BaudRate); + } + + [Fact] + public void ChangeBaudRate_DiscardsBytesStragglingInAtTheOldRate() + { + var port = new FakeWincSerialPort(); + + CreateClient(port).ChangeBaudRate(500000, TimeSpan.Zero); + + Assert.True(port.DiscardCount >= 1); + } + + [Theory] + [InlineData(0)] + [InlineData(-9600)] + public void ChangeBaudRate_RejectsNonPositiveRates(int baud) + { + var port = new FakeWincSerialPort(); + + Assert.Throws( + () => CreateClient(port).ChangeBaudRate(baud, TimeSpan.Zero)); + Assert.Empty(port.ReceivedHeaders); + } + + [Fact] + public void Commands_AreAlwaysPrefixedWithTheStartByte() + { + // Without the 0xA5 prefix the device stays in its op-code state and the header is read as a + // stream of unknown op codes, which fails silently rather than loudly. + var port = new RecordingPort(); + var client = new WincSerialBridgeClient(port, TimeSpan.FromSeconds(1)); + + try + { + client.WriteRegister(0x1000, 1); + } + catch (TimeoutException) + { + // The recording port never answers; we only care about what went out. + } + + Assert.Equal(WincBridgeProtocol.StartCommand, port.Written[0]); + Assert.Equal(1 + WincBridgeProtocol.HeaderSize, port.Written.Count); + } + + [Fact] + public void Commands_ThrowWhenTheDeviceNacksTheHeader() + { + var port = new NackingPort(); + var client = new WincSerialBridgeClient(port, TimeSpan.FromSeconds(1)); + + var ex = Assert.Throws(() => client.ReadRegister(0x1000)); + + Assert.Contains("checksum", ex.Message, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void Commands_ThrowWhenTheDeviceReturnsAnUnexpectedByte() + { + // Out-of-sync with the bridge state machine is a distinct failure from a rejected checksum, + // and the message should say so. + var port = new NackingPort { Verdict = 0x77 }; + var client = new WincSerialBridgeClient(port, TimeSpan.FromSeconds(1)); + + var ex = Assert.Throws(() => client.ReadRegister(0x1000)); + + Assert.Contains("unexpected", ex.Message, StringComparison.OrdinalIgnoreCase); + Assert.Contains("0x77", ex.Message, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void Constructor_RejectsANullPort() + { + Assert.Throws(() => new WincSerialBridgeClient(null!)); + } + + /// Captures outbound bytes and never answers. + private sealed class RecordingPort : IWincSerialPort + { + internal List Written { get; } = []; + + public bool IsOpen => true; + public int BaudRate { get; set; } = 115200; + public void Open() { } + public void Close() { } + public void DiscardInBuffer() { } + + public void Write(byte[] buffer, int offset, int count) + { + for (var i = 0; i < count; i++) + { + Written.Add(buffer[offset + i]); + } + } + + public void ReadExactly(byte[] buffer, int offset, int count, TimeSpan timeout) + => throw new TimeoutException("Recording port never answers."); + + public void Dispose() { } + } + + /// Answers every header with a fixed verdict byte. + private sealed class NackingPort : IWincSerialPort + { + internal byte Verdict { get; set; } = WincBridgeProtocol.Response.Nack; + + public bool IsOpen => true; + public int BaudRate { get; set; } = 115200; + public void Open() { } + public void Close() { } + public void DiscardInBuffer() { } + public void Write(byte[] buffer, int offset, int count) { } + + public void ReadExactly(byte[] buffer, int offset, int count, TimeSpan timeout) + { + for (var i = 0; i < count; i++) + { + buffer[offset + i] = Verdict; + } + } + + public void Dispose() { } + } +} diff --git a/src/Daqifi.Core/Firmware/WifiModuleUpdater.cs b/src/Daqifi.Core/Firmware/WifiModuleUpdater.cs index ee50aa2..cb245d0 100644 --- a/src/Daqifi.Core/Firmware/WifiModuleUpdater.cs +++ b/src/Daqifi.Core/Firmware/WifiModuleUpdater.cs @@ -1,5 +1,6 @@ using Daqifi.Core.Communication.Producers; using Daqifi.Core.Device; +using Daqifi.Core.Firmware.Winc; using Microsoft.Extensions.Logging; namespace Daqifi.Core.Firmware; @@ -743,28 +744,30 @@ private static bool ContainsAny(IReadOnlyList lines, params string[] mar 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}"); } private string ResolveWifiPort(IStreamingDevice device) diff --git a/src/Daqifi.Core/Firmware/Winc/IWincSerialPort.cs b/src/Daqifi.Core/Firmware/Winc/IWincSerialPort.cs new file mode 100644 index 0000000..9a5961e --- /dev/null +++ b/src/Daqifi.Core/Firmware/Winc/IWincSerialPort.cs @@ -0,0 +1,42 @@ +namespace Daqifi.Core.Firmware.Winc; + +/// +/// The narrow serial surface the WINC bridge protocol needs: raw byte I/O plus a baud rate that can +/// change on an already-open port. +/// +/// +/// Core's SerialStreamTransport deliberately does not fit here — it fixes baud at +/// construction and adds a liveness watchdog, whereas the bridge re-negotiates from 115200 to +/// 500000 partway through a session and must not be probed underneath. Keeping this interface +/// separate also makes the whole protocol layer testable without hardware. +/// +internal interface IWincSerialPort : IDisposable +{ + /// Whether the port is currently open. + bool IsOpen { get; } + + /// + /// Current baud rate. Setting it on an open port re-negotiates the host side, and must only be + /// done after the bridge has acknowledged a . + /// + int BaudRate { get; set; } + + /// Opens the port. + void Open(); + + /// Closes the port. + void Close(); + + /// Drops any buffered inbound bytes, so a read starts from a known-clean state. + void DiscardInBuffer(); + + /// Writes bytes from to the port. + void Write(byte[] buffer, int offset, int count); + + /// + /// Reads exactly bytes into , blocking until + /// they arrive or elapses. + /// + /// Fewer than bytes arrived in time. + void ReadExactly(byte[] buffer, int offset, int count, TimeSpan timeout); +} diff --git a/src/Daqifi.Core/Firmware/Winc/SystemWincSerialPort.cs b/src/Daqifi.Core/Firmware/Winc/SystemWincSerialPort.cs new file mode 100644 index 0000000..ff9c6fb --- /dev/null +++ b/src/Daqifi.Core/Firmware/Winc/SystemWincSerialPort.cs @@ -0,0 +1,122 @@ +using System.IO.Ports; + +namespace Daqifi.Core.Firmware.Winc; + +/// +/// over . Cross-platform: this is what makes a +/// native WINC flash possible on Linux and macOS, where Microchip's tool does not run. +/// +internal sealed class SystemWincSerialPort : IWincSerialPort +{ + private readonly SerialPort _port; + private bool _disposed; + + internal SystemWincSerialPort(string portName, int baudRate = 115200) + { + _port = new SerialPort(portName, baudRate, Parity.None, 8, StopBits.One) + { + // USB CDC delivers nothing to the host without DTR asserted — the DAQiFi firmware + // gates its writes on the host being present. + DtrEnable = true, + RtsEnable = false, + ReadTimeout = 2000, + WriteTimeout = 2000 + }; + } + + public bool IsOpen => _port.IsOpen; + + public int BaudRate + { + get => _port.BaudRate; + set => _port.BaudRate = value; + } + + public void Open() + { + if (!_port.IsOpen) + { + _port.Open(); + } + } + + public void Close() + { + if (_port.IsOpen) + { + _port.Close(); + } + } + + public void DiscardInBuffer() + { + if (_port.IsOpen) + { + _port.DiscardInBuffer(); + } + } + + public void Write(byte[] buffer, int offset, int count) + { + _port.Write(buffer, offset, count); + } + + public void ReadExactly(byte[] buffer, int offset, int count, TimeSpan timeout) + { + var deadline = DateTime.UtcNow + timeout; + var read = 0; + + while (read < count) + { + var remaining = deadline - DateTime.UtcNow; + if (remaining <= TimeSpan.Zero) + { + throw new TimeoutException( + $"Timed out reading from {_port.PortName}: wanted {count} bytes, got {read}."); + } + + // SerialPort.Read returns as soon as *any* bytes are available, so loop until the full + // frame has arrived rather than assuming one read yields everything. + _port.ReadTimeout = Math.Max(1, (int)remaining.TotalMilliseconds); + + int chunk; + try + { + chunk = _port.Read(buffer, offset + read, count - read); + } + catch (TimeoutException) + { + throw new TimeoutException( + $"Timed out reading from {_port.PortName}: wanted {count} bytes, got {read}."); + } + + if (chunk <= 0) + { + throw new IOException($"Serial port {_port.PortName} returned no data before closing."); + } + + read += chunk; + } + } + + public void Dispose() + { + if (_disposed) + { + return; + } + + _disposed = true; + + try + { + Close(); + } + catch (Exception) + { + // Disposal must not throw; a port that is already gone is not an error worth surfacing. + } + + _port.Dispose(); + } +} diff --git a/src/Daqifi.Core/Firmware/Winc/WincBridgeProtocol.cs b/src/Daqifi.Core/Firmware/Winc/WincBridgeProtocol.cs new file mode 100644 index 0000000..a732d8d --- /dev/null +++ b/src/Daqifi.Core/Firmware/Winc/WincBridgeProtocol.cs @@ -0,0 +1,183 @@ +namespace Daqifi.Core.Firmware.Winc; + +/// +/// Wire-format constants and framing for the WINC serial bridge — the UART protocol the DAQiFi +/// PIC32 speaks while it is bridging its USB-CDC port through to the WINC1500. +/// +/// +/// +/// The authority for this format is the DAQiFi firmware's own bridge implementation +/// (firmware/src/services/wifi_services/wifi_serial_bridge.c), not Microchip's tool — +/// Microchip ships winc_programmer_uart as a binary only, with no source, so the bridge +/// the device actually implements is the better and more accurate reference. +/// +/// +/// A command is an byte followed by a 12-byte header whose bytes XOR to +/// zero. Note the mixed endianness, which is easy to get wrong: header fields are little-endian, +/// but a register value read back by arrives +/// big-endian. +/// +/// +internal static class WincBridgeProtocol +{ + /// Size of the fixed command header that follows . + internal const int HeaderSize = 12; + + /// Identify op code; the bridge answers . + internal const byte IdentifyVariableBaud = 0x12; + + /// + /// Identify op code for a fixed-baud bridge. The DAQiFi bridge is variable-baud and answers + /// nothing at all to this, so it is only useful for distinguishing bridge flavors. + /// + internal const byte IdentifyFixedBaud = 0x13; + + /// Op code that begins a command; the next bytes are the header. + internal const byte StartCommand = 0xA5; + + /// + /// Largest payload a single may request. + /// + /// + /// The bridge's read loop neither decrements its counter nor advances the address, so a request + /// of 2048 or more spins forever re-sending the same chunk and never returns. Reads must + /// therefore stay strictly below the firmware's 2048-byte command buffer. This cap is a + /// deliberate guard against that firmware behavior, not a protocol limit. + /// + internal const int MaxReadBlockSize = 2047; + + /// Largest payload a single may carry. + internal const int MaxWriteBlockSize = 2048; + + /// Bridge command identifiers (header byte 0). + internal enum Command : byte + { + /// Read a 32-bit register; the bridge returns 4 big-endian bytes. + ReadRegisterWithReturn = 0, + + /// Write a 32-bit register. No data response beyond the header ACK. + WriteRegister = 1, + + /// Read size raw bytes starting at address. + ReadBlock = 2, + + /// Write size payload bytes, sent after the header ACK. + WriteBlock = 3, + + /// Re-negotiate the bridge's UART baud rate to value. + Reconfigure = 5 + } + + /// Single-byte responses the bridge emits. + internal static class Response + { + /// Header checksum rejected, or a block write failed. + internal const byte Nack = 0x5A; + + /// Identify response from a variable-baud bridge. + internal const byte IdVariableBaud = 0x5B; + + /// Identify response from a fixed-baud bridge. + internal const byte IdFixedBaud = 0x5C; + + /// Header accepted, or a block write succeeded. + internal const byte Ack = 0xAC; + } + + /// + /// Builds the 12-byte command header. Header fields are little-endian; byte 1 is the checksum + /// slot, set so the XOR of all twelve bytes is zero. + /// + /// The command identifier. + /// Payload/transfer size (little-endian u16). + /// Target address (little-endian u32). + /// Command value — the register value, or the new baud rate (little-endian u32). + internal static byte[] BuildHeader(Command command, ushort size, uint address, uint value) + { + var header = new byte[HeaderSize]; + + header[0] = (byte)command; + // header[1] is the checksum, filled in below. + header[2] = (byte)(size & 0xFF); + header[3] = (byte)((size >> 8) & 0xFF); + header[4] = (byte)(address & 0xFF); + header[5] = (byte)((address >> 8) & 0xFF); + header[6] = (byte)((address >> 16) & 0xFF); + header[7] = (byte)((address >> 24) & 0xFF); + header[8] = (byte)(value & 0xFF); + header[9] = (byte)((value >> 8) & 0xFF); + header[10] = (byte)((value >> 16) & 0xFF); + header[11] = (byte)((value >> 24) & 0xFF); + + header[1] = ComputeChecksum(header); + return header; + } + + /// + /// Returns the byte that, placed at index 1, makes the header's full XOR zero. Computed by + /// XOR-ing every byte except index 1 itself. + /// + internal static byte ComputeChecksum(IReadOnlyList header) + { + ArgumentNullException.ThrowIfNull(header); + + if (header.Count != HeaderSize) + { + throw new ArgumentException( + $"A bridge header is exactly {HeaderSize} bytes; got {header.Count}.", nameof(header)); + } + + byte checksum = 0; + for (var i = 0; i < HeaderSize; i++) + { + if (i == 1) + { + continue; + } + + checksum ^= header[i]; + } + + return checksum; + } + + /// + /// True when the header satisfies the bridge's acceptance test: the XOR of all twelve bytes is + /// zero. This is the exact check the firmware performs before it ACKs. + /// + internal static bool IsHeaderValid(IReadOnlyList header) + { + if (header is null || header.Count != HeaderSize) + { + return false; + } + + byte checksum = 0; + for (var i = 0; i < HeaderSize; i++) + { + checksum ^= header[i]; + } + + return checksum == 0; + } + + /// + /// Decodes a register value from a reply, which the + /// bridge sends big-endian — the opposite order from the header fields. + /// + internal static uint DecodeRegisterValue(byte[] response) + { + ArgumentNullException.ThrowIfNull(response); + + if (response.Length != 4) + { + throw new ArgumentException( + $"A register response is exactly 4 bytes; got {response.Length}.", nameof(response)); + } + + return ((uint)response[0] << 24) + | ((uint)response[1] << 16) + | ((uint)response[2] << 8) + | response[3]; + } +} diff --git a/src/Daqifi.Core/Firmware/Winc/WincFlashReader.cs b/src/Daqifi.Core/Firmware/Winc/WincFlashReader.cs new file mode 100644 index 0000000..99c5bcb --- /dev/null +++ b/src/Daqifi.Core/Firmware/Winc/WincFlashReader.cs @@ -0,0 +1,187 @@ +namespace Daqifi.Core.Firmware.Winc; + +/// +/// Read-only access to the WINC1500's SPI flash and identity registers, driven through the serial +/// bridge. Every operation here is non-destructive: identity reads and flash reads only. +/// +/// +/// +/// The WINC's flash controller is a set of memory-mapped registers, so a flash read is a scripted +/// register sequence followed by a block read of the shared memory window — the same sequence the +/// WINC host driver's spi_flash.c uses. Notably this needs no programmer_firmware.bin +/// blob; that upload is only required for the erase/program path. +/// +/// +/// Erase and program are deliberately absent. They are the operations that can brick a module, and +/// nothing in this class can leave the WINC in a worse state than it started. +/// +/// +internal sealed class WincFlashReader +{ + /// Chip identity register. A live WINC1500 reads 0x0015xxxx, or 0x0010xxxx once halted in download mode. + internal const uint ChipIdRegister = 0x1000; + + private const uint SpiFlashBase = 0x10200; + private const uint RegCommandCount = SpiFlashBase + 0x04; + private const uint RegDataCount = SpiFlashBase + 0x08; + private const uint RegBuffer1 = SpiFlashBase + 0x0C; + private const uint RegBuffer2 = SpiFlashBase + 0x10; + private const uint RegBufferDirection = SpiFlashBase + 0x14; + private const uint RegTransferDone = SpiFlashBase + 0x18; + private const uint RegDmaAddress = SpiFlashBase + 0x1C; + + /// Scratch window in WINC memory that flash reads are staged through. + private const uint HostShareMemoryBase = 0xD0000; + + /// Register the flash controller parks a byte-wide result in. + private const uint DummyRegister = 0x1084; + + // SPI NOR flash commands (MX25L-compatible). + private const byte FlashCommandFastRead = 0x0B; + private const byte FlashCommandReadIdentification = 0x9F; + + /// Dummy byte the fast-read command clocks out before data. + private const byte FastReadDummyByte = 0xA5; + + /// Bit that starts a flash-controller transfer. + private const uint CommandStartBit = 1u << 7; + + /// + /// Highest addressable flash byte. The fast-read command carries a 24-bit address, so this is + /// what the protocol can express regardless of the part's actual capacity. + /// + internal const uint MaxFlashAddress = 0xFFFFFF; + + private readonly WincSerialBridgeClient _bridge; + private readonly int _transferPollLimit; + + internal WincFlashReader(WincSerialBridgeClient bridge, int transferPollLimit = 1000) + { + _bridge = bridge ?? throw new ArgumentNullException(nameof(bridge)); + _transferPollLimit = transferPollLimit; + } + + /// + /// Reads the WINC chip identity register. + /// + internal uint ReadChipId() => _bridge.ReadRegister(ChipIdRegister); + + /// + /// True when a chip ID looks like a reachable WINC1500 — family 0x15 (firmware running) or + /// 0x10 (halted in download mode). Anything else means the bridge is up but the WINC behind it + /// is not answering, which is worth distinguishing from a dead port. + /// + internal static bool IsKnownWincChipId(uint chipId) + { + var family = chipId >> 16; + return family is 0x15 or 0x10; + } + + /// + /// Reads the SPI flash JEDEC identification word (command 0x9F). + /// + internal uint ReadFlashJedecId() + { + // DATA_CNT is the number of result bytes to clock back, and DMA_ADDR is where they land. + // Both matter: with DATA_CNT 0 and DMA_ADDR 0 the command still completes and TR_DONE still + // goes high, but nothing is transferred and the result register reads 0x00000000. That is + // what the bench returned before this was corrected against the WINC driver's + // spi_flash_rdid — a bug no amount of framing tests could have caught, because every frame + // was well-formed and the device answered every one of them. + _bridge.WriteRegister(RegDataCount, 4); + _bridge.WriteRegister(RegBuffer1, FlashCommandReadIdentification); + _bridge.WriteRegister(RegBufferDirection, 0x01); + _bridge.WriteRegister(RegDmaAddress, DummyRegister); + _bridge.WriteRegister(RegCommandCount, 1 | CommandStartBit); + + WaitForTransferDone("read flash JEDEC id"); + + return _bridge.ReadRegister(DummyRegister); + } + + /// + /// Reads bytes of SPI flash starting at , + /// chunking to stay inside both the flash controller's staging window and the bridge's + /// block-read ceiling. + /// + internal byte[] ReadFlash(uint offset, int length, CancellationToken cancellationToken = default) + { + if (length <= 0) + { + throw new ArgumentOutOfRangeException(nameof(length), length, "Read length must be positive."); + } + + // Bounds-check in 64-bit before any address arithmetic. Without this the per-chunk + // `offset + read` would silently wrap past 32 bits and quietly read the wrong part of + // flash — returning plausible-looking data with no error at all. + var endExclusive = (long)offset + length; + if (endExclusive > (long)MaxFlashAddress + 1) + { + throw new ArgumentOutOfRangeException( + nameof(length), + length, + $"A read of {length} bytes at 0x{offset:X6} would end at 0x{endExclusive:X}, past the " + + $"highest addressable flash byte 0x{MaxFlashAddress:X6}."); + } + + var result = new byte[length]; + var read = 0; + + while (read < length) + { + cancellationToken.ThrowIfCancellationRequested(); + + var chunk = Math.Min(WincBridgeProtocol.MaxReadBlockSize, length - read); + // Safe: the bounds check above guarantees offset + read stays within 24 bits. + var chunkData = ReadFlashChunk((uint)(offset + read), chunk); + Buffer.BlockCopy(chunkData, 0, result, read, chunk); + read += chunk; + } + + return result; + } + + /// + /// Stages one chunk of flash into the shared memory window and reads it back. Mirrors the host + /// driver's spi_flash_load_to_cortus_mem followed by a block read. + /// + private byte[] ReadFlashChunk(uint flashAddress, int size) + { + // The fast-read command word packs the opcode and the 24-bit address into one register, + // opcode in the low byte and address bytes ascending from there. + var commandWord = (uint)FlashCommandFastRead + | ((flashAddress >> 16) & 0xFFu) << 8 + | ((flashAddress >> 8) & 0xFFu) << 16 + | (flashAddress & 0xFFu) << 24; + + _bridge.WriteRegister(RegDataCount, (uint)size); + _bridge.WriteRegister(RegBuffer1, commandWord); + _bridge.WriteRegister(RegBuffer2, FastReadDummyByte); + _bridge.WriteRegister(RegBufferDirection, 0x1F); + _bridge.WriteRegister(RegDmaAddress, HostShareMemoryBase); + _bridge.WriteRegister(RegCommandCount, 5 | CommandStartBit); + + WaitForTransferDone($"read {size} flash bytes at 0x{flashAddress:X6}"); + + return _bridge.ReadBlock(HostShareMemoryBase, size); + } + + /// + /// Polls the flash controller's done flag. Bounded rather than a bare spin: the firmware's own + /// equivalent loop is unbounded, and a WINC that stops answering would otherwise hang the host. + /// + private void WaitForTransferDone(string operation) + { + for (var attempt = 0; attempt < _transferPollLimit; attempt++) + { + if (_bridge.ReadRegister(RegTransferDone) == 1) + { + return; + } + } + + throw new TimeoutException( + $"WINC flash controller never reported transfer-done while attempting to {operation} " + + $"(polled {_transferPollLimit} times)."); + } +} diff --git a/src/Daqifi.Core/Firmware/Winc/WincFlashToolLocator.cs b/src/Daqifi.Core/Firmware/Winc/WincFlashToolLocator.cs new file mode 100644 index 0000000..b9093fa --- /dev/null +++ b/src/Daqifi.Core/Firmware/Winc/WincFlashToolLocator.cs @@ -0,0 +1,110 @@ +using System.Diagnostics.CodeAnalysis; + +namespace Daqifi.Core.Firmware.Winc; + +/// +/// Finds Microchip's external WINC flash tool under a firmware path, so a caller can answer +/// "can this machine flash the WiFi module?" before starting an update rather than discovering the +/// answer partway through. +/// +/// +/// This is the check that separates Windows from Linux/macOS: Microchip ships the flash tool as a +/// Windows .cmd/.exe, so on other platforms it is genuinely absent rather than +/// misconfigured. It locates the tool; it does not run it — the WiFi update flow owns that, +/// including the interactive prompt handshake and output-based success verification. +/// +public sealed class WincFlashToolLocator +{ + private readonly string _toolFileName; + + /// + /// Creates a locator that searches for (e.g. + /// winc_flash_tool.cmd). + /// + public WincFlashToolLocator(string toolFileName) + { + if (string.IsNullOrWhiteSpace(toolFileName)) + { + throw new ArgumentException("Tool file name cannot be empty.", nameof(toolFileName)); + } + + _toolFileName = toolFileName; + } + + /// + /// Whether the flash tool is present for the given firmware path. + /// + /// + /// true when the tool was found; false for every other outcome, including a path + /// that does not exist, a malformed or too-long path, and a tree that exists but cannot be read. + /// + /// + /// + /// Total by design, and the implementation matches: a capability probe answers yes or no, and + /// callers reach for it precisely when they do not want to reason about filesystem failure + /// modes. Catching narrowly would leave a UI or a decision path crashing on a malformed string. + /// + /// + /// Because it cannot distinguish "not there" from "could not look", use + /// when a caller is about to act on the answer and needs the + /// real reason. + /// + /// + public bool IsAvailable(string firmwarePath) + { + try + { + return TryResolveToolPath(firmwarePath, out _); + } + catch (Exception) + { + // Deliberately broad: the documented contract is that this never throws. Narrowing it + // to the IO family would let an ArgumentException from a malformed path escape a probe + // whose whole purpose is to be safe to call with anything. + return false; + } + } + + /// + /// Resolves the flash tool for a firmware path: the path itself when it is the tool, otherwise + /// the first match found beneath it. + /// + /// true when the tool was found. + /// The directory tree could not be searched. + /// The directory tree could not be read. + /// + /// A tree that exists but cannot be read propagates rather than returning false. + /// Collapsing it into "not found" is what produces the genuinely misleading + /// "could not locate the tool — WiFi flashing is Windows-only" message on a machine where the + /// tool is sitting right there behind a permissions problem. + /// + public bool TryResolveToolPath(string firmwarePath, [NotNullWhen(true)] out string? toolPath) + { + toolPath = null; + + if (string.IsNullOrWhiteSpace(firmwarePath)) + { + return false; + } + + if (File.Exists(firmwarePath)) + { + toolPath = firmwarePath; + return true; + } + + if (!Directory.Exists(firmwarePath)) + { + return false; + } + + var matches = Directory.GetFiles(firmwarePath, _toolFileName, SearchOption.AllDirectories); + if (matches.Length == 0) + { + return false; + } + + toolPath = matches[0]; + return true; + } +} diff --git a/src/Daqifi.Core/Firmware/Winc/WincModuleIdentity.cs b/src/Daqifi.Core/Firmware/Winc/WincModuleIdentity.cs new file mode 100644 index 0000000..29cd84c --- /dev/null +++ b/src/Daqifi.Core/Firmware/Winc/WincModuleIdentity.cs @@ -0,0 +1,26 @@ +namespace Daqifi.Core.Firmware.Winc; + +/// +/// A non-destructive summary of the WINC module: what the bridge and the module reported without +/// changing anything. Produced by . +/// +public sealed class WincModuleIdentity +{ + /// + /// Chip identity register (0x1000). A live WINC1500 reads 0x0015xxxx with firmware running, or + /// 0x0010xxxx once halted in download mode. + /// + public required uint ChipId { get; init; } + + /// SPI flash JEDEC identification word. + public required uint FlashJedecId { get; init; } + + /// + /// Whether matches a known WINC1500 family. False means the bridge + /// answered but the module behind it did not — a different problem from a dead port. + /// + public required bool IsRecognizedWinc { get; init; } + + /// Link speed in effect when the identity was read. + public required int NegotiatedBaudRate { get; init; } +} diff --git a/src/Daqifi.Core/Firmware/Winc/WincModuleInspector.cs b/src/Daqifi.Core/Firmware/Winc/WincModuleInspector.cs new file mode 100644 index 0000000..2de9fb8 --- /dev/null +++ b/src/Daqifi.Core/Firmware/Winc/WincModuleInspector.cs @@ -0,0 +1,279 @@ +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; + +namespace Daqifi.Core.Firmware.Winc; + +/// +/// Managed, cross-platform access to the WINC module over its UART serial bridge — no external +/// tool, so it works on Linux and macOS where Microchip's programmer does not run. +/// +/// +/// +/// This inspects; it does not flash. It implements the non-destructive half of the WINC +/// protocol — bridge handshake, baud negotiation, chip and flash identity, and flash +/// read-back/verify — and the name says so rather than hiding it behind a "flasher" that cannot +/// write. +/// +/// +/// The erase/program path is absent because it could not be validated. Programming a WINC means +/// erase and page-program cycles against the module's SPI flash, and a wrong opcode or address +/// bricks the module with no recovery path outside Microchip's Windows tool. Shipping an +/// unexercised write path would be worse than shipping none: it would look complete, callers would +/// select it, and the first time it ran would be on someone's hardware. The framing such a writer +/// needs () is here and tested, so that work starts from a +/// known-good base. +/// +/// +public sealed class WincModuleInspector +{ + /// Rate the bridge starts at before renegotiation. + public const int InitialBaudRate = 115200; + + /// + /// Rate the bridge is moved to for bulk transfer, matching Microchip's tool. + /// + /// + /// Over the DAQiFi device's USB-CDC link this is largely ceremonial — CDC ignores the line + /// rate — but the exchange still has to happen: it is what the bridge expects, and completing + /// it proves the command path works before any bulk transfer starts. + /// + public const int FastBaudRate = 500000; + + private readonly Func _portFactory; + private readonly ILogger _logger; + private readonly TimeSpan _baudSettleDelay; + private readonly TimeSpan _responseTimeout; + private readonly TimeSpan _openTimeout; + + + /// + /// Creates an inspector that opens real serial ports. + /// + public WincModuleInspector(ILogger? logger = null) + : this((port, baud) => new SystemWincSerialPort(port, baud), logger) + { + } + + /// + /// Test seam: supply the serial port implementation. + /// + internal WincModuleInspector( + Func portFactory, + ILogger? logger = null, + TimeSpan? baudSettleDelay = null, + TimeSpan? responseTimeout = null, + TimeSpan? openTimeout = null) + { + _portFactory = portFactory ?? throw new ArgumentNullException(nameof(portFactory)); + _logger = logger ?? NullLogger.Instance; + _baudSettleDelay = baudSettleDelay ?? TimeSpan.FromMilliseconds(100); + _responseTimeout = responseTimeout ?? TimeSpan.FromSeconds(2); + _openTimeout = openTimeout ?? TimeSpan.FromSeconds(5); + } + + /// + /// Opens the bridge, handshakes, moves to , and reports the module's + /// chip and flash identity. Changes nothing on the device. + /// + public Task ReadIdentityAsync( + string portName, + CancellationToken cancellationToken = default) + => Task.Run(() => ReadIdentity(portName, cancellationToken), cancellationToken); + + /// + /// Reads a span of the module's SPI flash. Changes nothing on the device. + /// + public Task 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); + + // Each bridge exchange is individually bounded by the response timeout, but a caller who + // cancels should not have to wait out the remaining ones. + cancellationToken.ThrowIfCancellationRequested(); + var chipId = session.Reader.ReadChipId(); + + cancellationToken.ThrowIfCancellationRequested(); + 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); + } + + /// + /// Opens the port, proves a bridge is listening, and negotiates up to the fast baud rate. + /// + 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); + + // Ownership flag: once the open has been abandoned it is still running on a pool thread + // and holding the handle, so disposing here would race it. In that case the abandonment + // continuation owns disposal instead. + var ownsPort = true; + try + { + OpenWithTimeout(port, portName, ref ownsPort, cancellationToken); + + 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, cancellationToken); + + // 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)); + } + catch + { + if (ownsPort) + { + port.Dispose(); + } + + throw; + } + } + + /// + /// Opens the port under a hard deadline. + /// + /// + /// can block indefinitely on a wedged or + /// half-enumerated USB CDC device and takes no cancellation token, so the only way to stay + /// responsive is to run it elsewhere and walk away from it. An abandoned open keeps running on + /// a pool thread and still owns the handle, so disposal is handed to a continuation rather than + /// done here — disposing underneath a blocked open is how you turn a hang into a crash. + /// is cleared on that path so the caller does not double-dispose. + /// + private void OpenWithTimeout( + IWincSerialPort port, + string portName, + ref bool ownsPort, + CancellationToken cancellationToken) + { + 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; + + // Hoisted so the continuation closes over the logger rather than `this`. An open that + // never returns keeps this continuation — and everything it captured — alive for the + // life of the process, and it only needs the port and the logger, not the whole + // inspector. `port` and `portName` are already parameters, so this removes the last + // reach through the instance. + var logger = _logger; + + openTask.ContinueWith( + completed => + { + // Releasing the handle is the entire reason this continuation exists — the + // abandoned open owns the port and nothing else will ever free it. So disposal + // sits in a finally that nothing above can be reachable-past: a throwing logger + // would otherwise leak the handle and break every later open until the process + // exits, which is a strictly worse outcome than the fault it was reporting. + try + { + // Observe the abandoned open's fault. Nobody awaits this task any more, so + // an unobserved exception here would be exactly the silently-swallowed + // background failure #377/#394 set out to eliminate. Reading Exception is + // what marks it observed; Debug keeps the diagnostic without implying the + // caller must act — they already got a TimeoutException or a cancellation. + if (completed.Exception is { } fault) + { + logger.LogDebug( + fault, + "Abandoned open of {PortName} faulted after it was given up on.", + portName); + } + } + catch (Exception) + { + // Swallowed rather than propagated: nothing awaits this continuation + // either, so rethrowing would recreate the unobserved fault this whole + // path exists to avoid. + } + finally + { + try + { + port.Dispose(); + } + catch (Exception) + { + // Best-effort cleanup of a port we already gave up on. + } + } + }, + CancellationToken.None, + TaskContinuationOptions.ExecuteSynchronously, + TaskScheduler.Default); + + if (ex is TimeoutException) + { + throw new TimeoutException( + $"Opening serial port {portName} did not complete within {_openTimeout}. The port " + + "may be held by another process, or the device may be half-enumerated."); + } + + throw; + } + } + + private sealed class BridgeSession(IWincSerialPort port, WincFlashReader reader) : IDisposable + { + internal IWincSerialPort Port { get; } = port; + + internal WincFlashReader Reader { get; } = reader; + + public void Dispose() => Port.Dispose(); + } +} diff --git a/src/Daqifi.Core/Firmware/Winc/WincSerialBridgeClient.cs b/src/Daqifi.Core/Firmware/Winc/WincSerialBridgeClient.cs new file mode 100644 index 0000000..3946b19 --- /dev/null +++ b/src/Daqifi.Core/Firmware/Winc/WincSerialBridgeClient.cs @@ -0,0 +1,203 @@ +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; + +namespace Daqifi.Core.Firmware.Winc; + +/// +/// Speaks the WINC serial bridge protocol over an : identify, register +/// read/write, block read/write and the baud re-negotiation. Every method is one complete bridge +/// exchange; sequencing and the flash-level meaning of those registers belong to callers. +/// +/// +/// See for the wire format. This type does no retrying — a bridge +/// exchange that fails has usually left the bridge state machine mid-command, so recovery is a +/// caller-level decision, not a blind repeat. +/// +internal sealed class WincSerialBridgeClient +{ + private readonly IWincSerialPort _port; + private readonly ILogger _logger; + private readonly TimeSpan _responseTimeout; + + internal WincSerialBridgeClient( + IWincSerialPort port, + TimeSpan? responseTimeout = null, + ILogger? logger = null) + { + _port = port ?? throw new ArgumentNullException(nameof(port)); + _responseTimeout = responseTimeout ?? TimeSpan.FromSeconds(2); + _logger = logger ?? NullLogger.Instance; + } + + /// + /// Sends the identify op code and checks for the variable-baud bridge reply. This is the + /// handshake that proves something is actually bridging on the other end of the port. + /// + /// True when the bridge answered with the expected identify byte. + internal bool TryHandshake() + { + _port.DiscardInBuffer(); + _port.Write([WincBridgeProtocol.IdentifyVariableBaud], 0, 1); + + var response = new byte[1]; + try + { + _port.ReadExactly(response, 0, 1, _responseTimeout); + } + catch (TimeoutException) + { + _logger.LogDebug("WINC bridge handshake timed out; no identify response."); + return false; + } + + if (response[0] == WincBridgeProtocol.Response.IdVariableBaud) + { + return true; + } + + _logger.LogDebug( + "WINC bridge handshake returned 0x{Actual:X2}; expected 0x{Expected:X2}.", + response[0], + WincBridgeProtocol.Response.IdVariableBaud); + return false; + } + + /// Reads a 32-bit WINC register. + internal uint ReadRegister(uint address) + { + SendCommand(WincBridgeProtocol.Command.ReadRegisterWithReturn, 0, address, 0); + + var response = new byte[4]; + _port.ReadExactly(response, 0, 4, _responseTimeout); + return WincBridgeProtocol.DecodeRegisterValue(response); + } + + /// Writes a 32-bit WINC register. + internal void WriteRegister(uint address, uint value) + => SendCommand(WincBridgeProtocol.Command.WriteRegister, 0, address, value); + + /// + /// Reads a block of WINC memory. Callers must chunk to + /// ; a larger request wedges the bridge. + /// + internal byte[] ReadBlock(uint address, int size) + { + if (size <= 0) + { + throw new ArgumentOutOfRangeException(nameof(size), size, "Block read size must be positive."); + } + + if (size > WincBridgeProtocol.MaxReadBlockSize) + { + // Not a style preference: at or above the firmware's 2048-byte buffer the bridge's read + // loop never terminates, so this would hang rather than fail. + throw new ArgumentOutOfRangeException( + nameof(size), + size, + $"Block reads must be at most {WincBridgeProtocol.MaxReadBlockSize} bytes; larger requests " + + "do not terminate in the device's bridge read loop."); + } + + SendCommand(WincBridgeProtocol.Command.ReadBlock, (ushort)size, address, 0); + + var payload = new byte[size]; + _port.ReadExactly(payload, 0, size, _responseTimeout); + return payload; + } + + /// + /// Writes a block of WINC memory: header, ACK, payload, then a final ACK/NACK verdict. + /// + internal void WriteBlock(uint address, byte[] data) + { + ArgumentNullException.ThrowIfNull(data); + + if (data.Length == 0) + { + throw new ArgumentException("Block write payload cannot be empty.", nameof(data)); + } + + if (data.Length > WincBridgeProtocol.MaxWriteBlockSize) + { + throw new ArgumentOutOfRangeException( + nameof(data), + data.Length, + $"Block writes must be at most {WincBridgeProtocol.MaxWriteBlockSize} bytes."); + } + + SendCommand(WincBridgeProtocol.Command.WriteBlock, (ushort)data.Length, address, 0); + + _port.Write(data, 0, data.Length); + + var verdict = new byte[1]; + _port.ReadExactly(verdict, 0, 1, _responseTimeout); + + if (verdict[0] != WincBridgeProtocol.Response.Ack) + { + throw new IOException( + $"WINC bridge rejected a {data.Length}-byte block write at 0x{address:X8} " + + $"(responded 0x{verdict[0]:X2})."); + } + } + + /// + /// Re-negotiates the link speed: tells the bridge to switch, then moves the host side to match. + /// + /// + /// Order matters. The bridge changes its own rate as soon as it processes the command, so the + /// host must follow immediately; anything sent in between is lost. The settle delay gives the + /// device's UART time to reconfigure before the next byte arrives. + /// + internal void ChangeBaudRate( + int newBaudRate, + TimeSpan settleDelay, + CancellationToken cancellationToken = default) + { + if (newBaudRate <= 0) + { + throw new ArgumentOutOfRangeException(nameof(newBaudRate), newBaudRate, "Baud rate must be positive."); + } + + SendCommand(WincBridgeProtocol.Command.Reconfigure, 0, 0, (uint)newBaudRate); + + _port.BaudRate = newBaudRate; + + if (settleDelay > TimeSpan.Zero) + { + // Cancellable rather than Thread.Sleep so a cancel during the settle window is + // observed instead of being slept through. + cancellationToken.WaitHandle.WaitOne(settleDelay); + cancellationToken.ThrowIfCancellationRequested(); + } + + _port.DiscardInBuffer(); + _logger.LogDebug("WINC bridge baud rate changed to {BaudRate}.", newBaudRate); + } + + /// + /// Writes the start byte and header, then consumes the bridge's ACK/NACK verdict on the header. + /// + private void SendCommand(WincBridgeProtocol.Command command, ushort size, uint address, uint value) + { + var header = WincBridgeProtocol.BuildHeader(command, size, address, value); + + _port.Write([WincBridgeProtocol.StartCommand], 0, 1); + _port.Write(header, 0, header.Length); + + var verdict = new byte[1]; + _port.ReadExactly(verdict, 0, 1, _responseTimeout); + + if (verdict[0] == WincBridgeProtocol.Response.Ack) + { + return; + } + + // A NACK means the bridge rejected our checksum, so it has already returned to its + // op-code state; anything else means we are out of sync with its state machine. + var reason = verdict[0] == WincBridgeProtocol.Response.Nack + ? "rejected the command header checksum" + : $"returned an unexpected byte 0x{verdict[0]:X2} instead of an ACK"; + + throw new IOException($"WINC bridge {reason} for command {command} at 0x{address:X8}."); + } +}