@@ -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 ( ) ;
0 commit comments