Skip to content

Commit a5045ab

Browse files
authored
Merge branch 'main' into claude/objective-cartwright-8581ff
2 parents 51f7086 + b4c2ee6 commit a5045ab

10 files changed

Lines changed: 451 additions & 43 deletions

File tree

src/Daqifi.Core.Tests/Communication/Transport/HidLibraryDeviceEnumeratorTests.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -144,7 +144,7 @@ public FakeHidTransportDevice(
144144
public string? ProductName { get; }
145145
public bool IsConnected { get; private set; }
146146

147-
public void Open() => IsConnected = true;
147+
public void Open(bool exclusive) => IsConnected = true;
148148

149149
public void Close() => IsConnected = false;
150150

src/Daqifi.Core.Tests/Communication/Transport/HidLibraryTransportTests.cs

Lines changed: 72 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,36 @@ public async Task ConnectAsync_WithMatchingDevice_OpensAndStoresMetadata()
3333
Assert.Equal(1, device.OpenCount);
3434
}
3535

36+
[Fact]
37+
public async Task ConnectAsync_ByDefault_OpensDeviceShared()
38+
{
39+
var device = new FakeHidTransportDevice(0x04D8, 0x003C, "path-a", "SN-A");
40+
var platform = new FakeHidPlatform([device]);
41+
42+
using var transport = new HidLibraryTransport(platform);
43+
44+
await transport.ConnectAsync(0x04D8, 0x003C);
45+
46+
// ExclusiveAccess defaults false so non-bootloader HID consumers keep a shared open.
47+
Assert.False(transport.ExclusiveAccess);
48+
Assert.False(device.LastOpenExclusive);
49+
}
50+
51+
[Fact]
52+
public async Task ConnectAsync_WhenExclusiveAccessSet_OpensDeviceExclusively()
53+
{
54+
var device = new FakeHidTransportDevice(0x04D8, 0x003C, "path-a", "SN-A");
55+
var platform = new FakeHidPlatform([device]);
56+
57+
using var transport = new HidLibraryTransport(platform) { ExclusiveAccess = true };
58+
59+
await transport.ConnectAsync(0x04D8, 0x003C);
60+
61+
// A2: the bootloader flash must hold the HID handle exclusively. The flag is threaded to the
62+
// device open so the stray-write guard (and discovery lockout) is actually requested.
63+
Assert.True(device.LastOpenExclusive);
64+
}
65+
3666
[Fact]
3767
public async Task ConnectAsync_WithSerialFilter_SelectsMatchingDevice()
3868
{
@@ -76,6 +106,45 @@ public async Task ConnectAsync_WhenNoMatchingDevice_ThrowsIOException()
76106
await Assert.ThrowsAsync<IOException>(() => transport.ConnectAsync(0x04D8, 0x003C));
77107
}
78108

109+
[Fact]
110+
public async Task ConnectByPathAsync_WithMatchingPath_OpensThatExactDevice()
111+
{
112+
// Two identical bootloaders (same VID/PID, no serial); only the path tells them apart.
113+
var deviceA = new FakeHidTransportDevice(0x04D8, 0x003C, "path-a", string.Empty);
114+
var deviceB = new FakeHidTransportDevice(0x04D8, 0x003C, "path-b", string.Empty);
115+
var platform = new FakeHidPlatform([deviceA, deviceB]);
116+
117+
using var transport = new HidLibraryTransport(platform);
118+
119+
await transport.ConnectByPathAsync("path-b");
120+
121+
Assert.True(transport.IsConnected);
122+
Assert.Equal("path-b", transport.DevicePath);
123+
Assert.Equal(0, deviceA.OpenCount);
124+
Assert.Equal(1, deviceB.OpenCount);
125+
}
126+
127+
[Fact]
128+
public async Task ConnectByPathAsync_WhenNoDeviceMatchesPath_ThrowsIOException()
129+
{
130+
var device = new FakeHidTransportDevice(0x04D8, 0x003C, "path-a", "SN-A");
131+
var platform = new FakeHidPlatform([device]);
132+
using var transport = new HidLibraryTransport(platform);
133+
134+
await Assert.ThrowsAsync<IOException>(() => transport.ConnectByPathAsync("path-z"));
135+
Assert.False(transport.IsConnected);
136+
Assert.Equal(0, device.OpenCount);
137+
}
138+
139+
[Fact]
140+
public async Task ConnectByPathAsync_WithEmptyPath_ThrowsArgumentException()
141+
{
142+
var platform = new FakeHidPlatform([]);
143+
using var transport = new HidLibraryTransport(platform);
144+
145+
await Assert.ThrowsAsync<ArgumentException>(() => transport.ConnectByPathAsync(" "));
146+
}
147+
79148
[Fact]
80149
public async Task WriteAsync_WhenNotConnected_ThrowsInvalidOperationException()
81150
{
@@ -221,16 +290,18 @@ public FakeHidTransportDevice(int vendorId, int productId, string devicePath, st
221290

222291
public int OpenCount { get; private set; }
223292
public int CloseCount { get; private set; }
293+
public bool LastOpenExclusive { get; private set; }
224294
public int? LastWriteTimeoutMs { get; private set; }
225295
public int? LastReadTimeoutMs { get; private set; }
226296

227297
public bool NextWriteResult { get; set; } = true;
228298
public HidTransportReadResult NextReadResult { get; set; }
229299
= HidTransportReadResult.Success(Array.Empty<byte>());
230300

231-
public void Open()
301+
public void Open(bool exclusive)
232302
{
233303
OpenCount++;
304+
LastOpenExclusive = exclusive;
234305
IsConnected = true;
235306
}
236307

src/Daqifi.Core.Tests/Daqifi.Core.Tests.csproj

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@
1818
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
1919
</PackageReference>
2020
<PackageReference Include="coverlet.collector" Version="10.0.1" />
21-
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.6.0" />
21+
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.7.0" />
2222
<PackageReference Include="xunit" Version="2.9.3" />
2323
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.5" />
2424
</ItemGroup>

src/Daqifi.Core.Tests/Firmware/FirmwareUpdateServiceTests.cs

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -134,6 +134,82 @@ [new HidDeviceInfo(0x04D8, 0x003C, "path-1", "SN-1", "DAQiFi Bootloader")]
134134
Assert.Contains(hidTransport.Writes, w => w.Length > 0 && w[0] == 0x44);
135135
}
136136

137+
[Fact]
138+
public async Task UpdateFirmwareAsync_WithTargetDevicePath_FlashesThatExactBootloaderByPath()
139+
{
140+
// Multi-device: several identical bootloaders (same VID/PID, no serial) are enumerated at once.
141+
// targetDevicePath must select and connect to that EXACT device by path — not VID/PID first-match.
142+
var device = new FakeStreamingDevice("COM3");
143+
var hidTransport = new FakeHidTransport();
144+
hidTransport.EnqueueRead([0x01, 0x10]); // version
145+
hidTransport.EnqueueRead([0x01, 0x02]); // erase ack
146+
hidTransport.EnqueueRead([0x01, 0x03]); // program ack 1
147+
hidTransport.EnqueueRead([0x01, 0x03]); // program ack 2
148+
hidTransport.EnqueueRead([0xCD, 0xAB]); // READ_CRC response → 0xABCD (match)
149+
150+
var enumerator = new FakeHidDeviceEnumerator([
151+
[
152+
new HidDeviceInfo(0x04D8, 0x003C, "path-1", null, "DAQiFi Bootloader"),
153+
new HidDeviceInfo(0x04D8, 0x003C, "path-2", null, "DAQiFi Bootloader")
154+
]
155+
]);
156+
157+
var bootloaderProtocol = new FakeBootloaderProtocol(
158+
[[0xA1, 0x01], [0xA1, 0x02]],
159+
crcRegions: [new FlashCrcRegion(0x9D000000, 256, 0xABCD)]);
160+
161+
var service = new FirmwareUpdateService(
162+
hidTransport,
163+
new FakeFirmwareDownloadService(),
164+
new FakeExternalProcessRunner(),
165+
NullLogger<FirmwareUpdateService>.Instance,
166+
bootloaderProtocol,
167+
enumerator,
168+
CreateFastOptions());
169+
170+
var hexPath = CreateTempFile();
171+
try
172+
{
173+
await service.UpdateFirmwareAsync(device, hexPath, progress: null, targetDevicePath: "path-2");
174+
}
175+
finally
176+
{
177+
File.Delete(hexPath);
178+
}
179+
180+
Assert.Equal(FirmwareUpdateState.Complete, service.CurrentState);
181+
Assert.Equal("path-2", hidTransport.LastConnectByPath);
182+
Assert.Equal(1, hidTransport.ConnectByPathAttempts);
183+
Assert.Equal(0, hidTransport.ConnectAttempts); // did NOT fall back to VID/PID first-match
184+
}
185+
186+
[Fact]
187+
public async Task UpdateFirmwareAsync_WithWhitespaceTargetDevicePath_ThrowsArgumentException()
188+
{
189+
// A whitespace target can never match an enumerated path, so fail fast instead of polling
190+
// until the WaitingForBootloader timeout. (null target is allowed = untargeted.)
191+
var service = new FirmwareUpdateService(
192+
new FakeHidTransport(),
193+
new FakeFirmwareDownloadService(),
194+
new FakeExternalProcessRunner(),
195+
NullLogger<FirmwareUpdateService>.Instance,
196+
new FakeBootloaderProtocol([[0xA1, 0x01]]),
197+
new FakeHidDeviceEnumerator([]),
198+
CreateFastOptions());
199+
200+
var device = new FakeStreamingDevice("COM3");
201+
var hexPath = CreateTempFile();
202+
try
203+
{
204+
await Assert.ThrowsAsync<ArgumentException>(
205+
() => service.UpdateFirmwareAsync(device, hexPath, progress: null, targetDevicePath: " "));
206+
}
207+
finally
208+
{
209+
File.Delete(hexPath);
210+
}
211+
}
212+
137213
[Fact]
138214
public async Task UpdateFirmwareAsync_WhenFlashCrcMismatches_FailsVerificationIntoFailedState()
139215
{
@@ -2060,6 +2136,24 @@ public void Connect(int vendorId, int productId, string? serialNumber = null)
20602136
ConnectAsync(vendorId, productId, serialNumber).GetAwaiter().GetResult();
20612137
}
20622138

2139+
public int ConnectByPathAttempts { get; private set; }
2140+
public string? LastConnectByPath { get; private set; }
2141+
2142+
public Task ConnectByPathAsync(string devicePath, CancellationToken cancellationToken = default)
2143+
{
2144+
cancellationToken.ThrowIfCancellationRequested();
2145+
ConnectByPathAttempts++;
2146+
LastConnectByPath = devicePath;
2147+
IsConnected = true;
2148+
DevicePath = devicePath;
2149+
return Task.CompletedTask;
2150+
}
2151+
2152+
public void ConnectByPath(string devicePath)
2153+
{
2154+
ConnectByPathAsync(devicePath).GetAwaiter().GetResult();
2155+
}
2156+
20632157
public Task WriteAsync(byte[] data, CancellationToken cancellationToken = default)
20642158
{
20652159
cancellationToken.ThrowIfCancellationRequested();

src/Daqifi.Core/Communication/Transport/HidLibraryPlatform.cs

Lines changed: 75 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,10 @@ internal interface IHidTransportDevice
1818
string? ProductName { get; }
1919
bool IsConnected { get; }
2020

21-
void Open();
21+
// When exclusive is true, request an exclusive open (Windows dwShareMode=0; macOS IOKit seize)
22+
// so no other user-mode opener can open or write to the device while this handle is held.
23+
// Best-effort: a refused exclusive open falls back to a shared open.
24+
void Open(bool exclusive);
2225
void Close();
2326
bool Write(byte[] data, int timeoutMs);
2427
Task<bool> WriteAsync(byte[] data, int timeoutMs);
@@ -115,19 +118,86 @@ ex is ObjectDisposedException ||
115118
}
116119
}
117120

118-
public void Open()
121+
public void Open(bool exclusive)
119122
{
120123
if (_stream != null)
121124
{
122125
return;
123126
}
124127

125-
if (!_device.TryOpen(out var stream) || stream == null)
128+
HidStream? opened = null;
129+
Exception? exclusiveOpenError = null;
130+
131+
if (exclusive)
132+
{
133+
// A2 (stray-write guard): open the bootloader's vendor collection exclusively
134+
// (Windows dwShareMode=0) so no other user-mode opener — the desktop's own HID discovery
135+
// loop, a second app instance, anything — can open or write to the device while this
136+
// handle is held. The PIC32 bootloader's CRC check is disabled, so a stray SOH…EOT frame
137+
// from another opener could be mis-parsed as an ERASE; the exclusive handle guards
138+
// against that. A vendor-defined top-level collection (Usage Page 0xFF00) permits
139+
// exclusive access, unlike the system keyboard/mouse collections hidclass keeps shared.
140+
var exclusiveConfig = new OpenConfiguration();
141+
exclusiveConfig.SetOption(OpenOption.Exclusive, true);
142+
143+
// Best-effort: a refused exclusive open falls through to the shared open below, so a flash
144+
// that works today is never regressed by the added guard. This covers BOTH ways HidSharp can
145+
// refuse — TryOpen returning false AND TryOpen throwing (e.g. a sharing-violation surfaced as
146+
// an exception) — otherwise an exclusive-open throw would become a hard connection failure.
147+
try
148+
{
149+
if (_device.TryOpen(exclusiveConfig, out var exclusiveStream) && exclusiveStream != null)
150+
{
151+
opened = exclusiveStream;
152+
}
153+
else
154+
{
155+
// TryOpen reported failure; dispose any partial stream so it isn't leaked.
156+
exclusiveStream?.Dispose();
157+
}
158+
}
159+
catch (Exception ex)
160+
{
161+
// Exclusive open threw; remember why and leave opened == null so the shared open below
162+
// is attempted. The cause is chained into the final throw if the shared open also fails.
163+
exclusiveOpenError = ex;
164+
}
165+
}
166+
167+
if (opened == null)
126168
{
127-
throw new IOException("Failed to open HID device.");
169+
// The shared open can also throw (not just return false); catch it so a throwing shared
170+
// fallback still routes through our normalized IOException and never drops the exclusive cause.
171+
HidStream? sharedStream = null;
172+
Exception? sharedOpenError = null;
173+
try
174+
{
175+
if (!_device.TryOpen(out sharedStream) || sharedStream == null)
176+
{
177+
// TryOpen reported failure; dispose any partial stream and fall through to the throw.
178+
sharedStream?.Dispose();
179+
sharedStream = null;
180+
}
181+
}
182+
catch (Exception ex)
183+
{
184+
sharedOpenError = ex;
185+
}
186+
187+
if (sharedStream == null)
188+
{
189+
// Both opens failed — preserve whatever cause(s) we have. When both threw, chain both via
190+
// an AggregateException so neither root cause is lost for diagnosis.
191+
var cause = exclusiveOpenError != null && sharedOpenError != null
192+
? new AggregateException(exclusiveOpenError, sharedOpenError)
193+
: sharedOpenError ?? exclusiveOpenError;
194+
throw new IOException("Failed to open HID device.", cause);
195+
}
196+
197+
opened = sharedStream;
128198
}
129199

130-
_stream = stream;
200+
_stream = opened;
131201
}
132202

133203
public void Close()

0 commit comments

Comments
 (0)