Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
416bc93
perf(discovery): VID/PID-filter serial ports + minimal probe (closes …
cptkoolbeenz May 12, 2026
6c7cd42
Apply Qodo /improve pass 1: resolve sysfs symlink before traversal
cptkoolbeenz May 12, 2026
ba62343
Apply Qodo /improve pass 2: validate portName before WMI interpolation
cptkoolbeenz May 12, 2026
643a988
Apply Qodo /improve pass 3: invariant culture on hex parse
cptkoolbeenz May 12, 2026
97d0959
Apply Qodo /improve pass 4: dispose WMI results, narrow query
cptkoolbeenz May 12, 2026
d3d869a
Apply Qodo /improve pass 5: guard descriptor provider against throws
cptkoolbeenz May 12, 2026
1f24638
Apply Qodo /improve pass 6: explicit null/whitespace guard on portName
cptkoolbeenz May 12, 2026
03209c8
Apply Qodo /improve pass 7: System.Management reference unconditional
cptkoolbeenz May 12, 2026
e0e76ff
Apply Qodo /improve pass 8: tolerate OCE in null/throwing-provider tests
cptkoolbeenz May 12, 2026
43d82c2
Apply Qodo /agentic_review pass 9 on PR #201: parallel port probing
cptkoolbeenz May 12, 2026
1164d84
Apply Qodo /improve pass 10: rethrow OCE so WhenAll observes cancella…
cptkoolbeenz May 12, 2026
4cc0f08
Apply Qodo /improve pass 11: cap parallel probes + preserve completio…
cptkoolbeenz May 12, 2026
f0f5926
Apply Qodo /agentic_review pass 12: throwing-provider test seam
cptkoolbeenz May 12, 2026
5f213f6
Apply Qodo /agentic_review pass 13: invalid mock port name
cptkoolbeenz May 12, 2026
8b69f6b
Apply Qodo /improve pass 12: deterministic classifier-rejects test
cptkoolbeenz May 12, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -94,4 +94,67 @@ public void SerialDeviceFinder_CustomBaudRate_AcceptsCustomBaudRate()
// Assert
Assert.NotNull(finder);
}

[Fact]
public async Task DiscoverAsync_WithNonDaqifiVidPid_DoesNotProbePort()
{
// Closes #157: ports whose USB descriptor is NOT a known DAQiFi
// VID/PID get filtered before any port-open / SCPI traffic. This
// is both a correctness fix (don't talk to other vendors' devices)
// and the dominant performance win (~5s per skipped port).
//
// The fake provider classifies every port as a CH340 (non-DAQiFi)
// and tracks GetDescriptor calls. After DiscoverAsync, every
// platform-listed port should have been classified once and zero
// devices returned — proving the filter ran AND that the port-
// probe path was never reached.
var classifierCallCount = 0;
var fakeProvider = new RecordingUsbPortDescriptorProvider(_ =>
{
Interlocked.Increment(ref classifierCallCount);
return new UsbPortDescriptor(0x1A86, 0x7523); // CH340, not DAQiFi
});

using var finder = new SerialDeviceFinder(9600, fakeProvider);
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(2));

var stopwatch = System.Diagnostics.Stopwatch.StartNew();
var devices = await finder.DiscoverAsync(cts.Token);
stopwatch.Stop();

Assert.Empty(devices);
// If any port were probed, the test would take seconds per port
// (DeviceWakeUpDelayMs + ResponseTimeoutMs); should be well under
// 500ms for the classifier-only path even on a many-port system.
Assert.True(stopwatch.Elapsed < TimeSpan.FromSeconds(1),
$"Discovery took {stopwatch.ElapsedMilliseconds}ms — classifier filter may not be wired correctly.");
}

[Fact]
public async Task DiscoverAsync_WithNullDescriptor_FallsThroughToProbe()
{
// Cross-platform fallback: when the descriptor provider can't
// classify a port (returns null), the legacy probe behavior is
// preserved so we don't regress on Linux/macOS where we don't
// yet have a descriptor lookup. The probe will time out on
// non-DAQiFi ports as before — no change to that path.
var fakeProvider = new RecordingUsbPortDescriptorProvider(_ => null);

using var finder = new SerialDeviceFinder(9600, fakeProvider);
using var cts = new CancellationTokenSource(TimeSpan.FromMilliseconds(200));

// Just verifying it doesn't throw and returns a (probably empty) list;
// actual ports depend on the test machine. The key contract is that
// null-descriptor doesn't filter the port out of consideration.
var devices = await finder.DiscoverAsync(cts.Token);
Assert.NotNull(devices);
}

private sealed class RecordingUsbPortDescriptorProvider : IUsbPortDescriptorProvider
{
private readonly Func<string, UsbPortDescriptor?> _classifier;
public RecordingUsbPortDescriptorProvider(Func<string, UsbPortDescriptor?> classifier)
=> _classifier = classifier;
public UsbPortDescriptor? GetDescriptor(string portName) => _classifier(portName);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
using Daqifi.Core.Device.Discovery;

namespace Daqifi.Core.Tests.Device.Discovery;

public class UsbPortDescriptorTests
{
[Fact]
public void DaqifiUsbIds_VendorAndCdcProductId_MatchExpectedValues()
{
// Locks the published constants. If these change without
// explicit intent, every consumer's USB filter breaks.
Assert.Equal(0x04D8, DaqifiUsbIds.VendorId);
Assert.Equal(0xF794, DaqifiUsbIds.CdcProductId);
}

[Fact]
public void DaqifiUsbIds_IsDaqifiCdcDevice_TrueForExactMatch()
{
var descriptor = new UsbPortDescriptor(0x04D8, 0xF794);
Assert.True(DaqifiUsbIds.IsDaqifiCdcDevice(descriptor));
}

[Fact]
public void DaqifiUsbIds_IsDaqifiCdcDevice_FalseForBootloaderPid()
{
// Bootloader mode PID — not the CDC mode we discover via SerialDeviceFinder.
var descriptor = new UsbPortDescriptor(0x04D8, 0x003C);
Assert.False(DaqifiUsbIds.IsDaqifiCdcDevice(descriptor));
}

[Fact]
public void DaqifiUsbIds_IsDaqifiCdcDevice_FalseForOtherVendor()
{
// CH340 vendor — common USB serial chip; must not be classified as DAQiFi.
var descriptor = new UsbPortDescriptor(0x1A86, 0x7523);
Assert.False(DaqifiUsbIds.IsDaqifiCdcDevice(descriptor));
}

[Fact]
public void NullUsbPortDescriptorProvider_AlwaysReturnsNull()
{
var provider = NullUsbPortDescriptorProvider.Instance;
Assert.Null(provider.GetDescriptor("COM9"));
Assert.Null(provider.GetDescriptor("/dev/ttyACM1"));
Assert.Null(provider.GetDescriptor(""));
}
}
8 changes: 8 additions & 0 deletions src/Daqifi.Core/Daqifi.Core.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -35,5 +35,13 @@
<InternalsVisibleTo Include="Daqifi.Core.Tests" />
</ItemGroup>

<!-- Windows-only USB descriptor lookup via WMI for SerialDeviceFinder
VID/PID port filtering. The package adds nothing for Linux/macOS;
the runtime platform check in WindowsUsbPortDescriptorProvider
returns null on those platforms anyway. -->
<ItemGroup Condition="'$(OS)' == 'Windows_NT'">
<PackageReference Include="System.Management" Version="10.0.7" />
</ItemGroup>
Comment thread
qodo-code-review[bot] marked this conversation as resolved.
Outdated


</Project>
35 changes: 35 additions & 0 deletions src/Daqifi.Core/Device/Discovery/DaqifiUsbIds.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
namespace Daqifi.Core.Device.Discovery;

/// <summary>
/// Known USB Vendor / Product identifiers for DAQiFi devices in normal
/// (non-bootloader) operating mode. Used by <see cref="SerialDeviceFinder"/>
/// to filter serial ports before probing — only ports matching one of these
/// IDs are opened, eliminating accidental SCPI traffic to other vendors'
/// COM ports (Bluetooth radios, GPS receivers, Arduinos, etc.).
/// </summary>
public static class DaqifiUsbIds
{
/// <summary>
/// USB vendor ID assigned by Microchip and used by DAQiFi devices
/// (PIC32-based). Same VID is used in bootloader mode.
/// </summary>
public const int VendorId = 0x04D8;

/// <summary>
/// USB product ID for DAQiFi devices in normal USB CDC serial mode
/// (Nyquist1, Nyquist3). Bootloader mode uses a different PID
/// (<c>0x003C</c>, see <c>FirmwareUpdateServiceOptions.BootloaderProductId</c>).
/// </summary>
public const int CdcProductId = 0xF794;

/// <summary>
/// Returns true if the supplied descriptor matches a known DAQiFi USB
/// CDC serial-mode device (matches <see cref="VendorId"/> and one of
/// the known product IDs).
/// </summary>
public static bool IsDaqifiCdcDevice(UsbPortDescriptor descriptor)
{
return descriptor.VendorId == VendorId
&& descriptor.ProductId == CdcProductId;
}
}
32 changes: 32 additions & 0 deletions src/Daqifi.Core/Device/Discovery/IUsbPortDescriptorProvider.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
namespace Daqifi.Core.Device.Discovery;

/// <summary>
/// Resolves the USB Vendor/Product ID associated with a serial port name
/// (e.g. <c>COM9</c> on Windows, <c>/dev/ttyACM1</c> on Linux). Used by
/// <see cref="SerialDeviceFinder"/> to pre-filter ports before opening
/// them, so non-DAQiFi hardware (Bluetooth radios, GPS receivers, other
/// vendors' COM ports, etc.) is skipped instantly without sending SCPI
/// commands or waiting for a probe timeout.
/// </summary>
/// <remarks>
/// Implementations are platform-specific: Windows uses WMI, Linux reads
/// <c>/sys/class/tty</c>. Platforms without an implementation fall back to
/// <see cref="NullUsbPortDescriptorProvider"/> which returns null for every
/// port, preserving the legacy "probe every port" behavior.
/// </remarks>
public interface IUsbPortDescriptorProvider
{
/// <summary>
/// Returns the USB descriptor for <paramref name="portName"/>, or
/// <c>null</c> if the port is not USB-attached or the descriptor
/// cannot be resolved.
/// </summary>
UsbPortDescriptor? GetDescriptor(string portName);
}

/// <summary>
/// USB Vendor/Product identification for a serial port.
/// </summary>
/// <param name="VendorId">USB vendor ID (e.g. 0x04D8 for DAQiFi/Microchip).</param>
/// <param name="ProductId">USB product ID (e.g. 0xF794 for DAQiFi USB CDC mode).</param>
public sealed record UsbPortDescriptor(int VendorId, int ProductId);
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
using System.Globalization;
using System.Runtime.InteropServices;

namespace Daqifi.Core.Device.Discovery;

/// <summary>
/// Resolves USB VID/PID for serial ports via Linux <c>/sys/class/tty/</c>
/// sysfs entries. Returns null on non-Linux platforms or for ports whose
/// sysfs lookup fails (non-USB serial, virtual ttys, etc.).
/// </summary>
internal sealed class LinuxUsbPortDescriptorProvider : IUsbPortDescriptorProvider
{
public UsbPortDescriptor? GetDescriptor(string portName)
{
if (!RuntimeInformation.IsOSPlatform(OSPlatform.Linux))
{
return null;
}

// portName is typically /dev/ttyACM0 or /dev/ttyUSB0.
// The corresponding sysfs path is /sys/class/tty/<base>/device/...
// We walk up the device tree looking for idVendor + idProduct,
// which sit on the USB device node (a few levels above the tty).
var baseName = System.IO.Path.GetFileName(portName);
if (string.IsNullOrEmpty(baseName))
return null;

var sysfsRoot = $"/sys/class/tty/{baseName}/device";
if (!System.IO.Directory.Exists(sysfsRoot))
return null;

// Walk up the symlink-resolved path looking for idVendor/idProduct.
// Bound the depth to keep this defensive against unexpected layouts.
try
{
var current = System.IO.Path.GetFullPath(sysfsRoot);
for (var i = 0; i < 8; i++)
{
var vendorPath = System.IO.Path.Combine(current, "idVendor");
var productPath = System.IO.Path.Combine(current, "idProduct");
if (System.IO.File.Exists(vendorPath) && System.IO.File.Exists(productPath))
{
var vidText = System.IO.File.ReadAllText(vendorPath).Trim();
var pidText = System.IO.File.ReadAllText(productPath).Trim();
if (int.TryParse(vidText, NumberStyles.HexNumber, CultureInfo.InvariantCulture, out var vid) &&
int.TryParse(pidText, NumberStyles.HexNumber, CultureInfo.InvariantCulture, out var pid))
{
return new UsbPortDescriptor(vid, pid);
}
return null;
}

var parent = System.IO.Directory.GetParent(current);
if (parent == null || parent.FullName == current)
break;
current = parent.FullName;
}
}
catch
{
// Permission denied / IO error → fall through to null.
}

return null;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
namespace Daqifi.Core.Device.Discovery;

/// <summary>
/// No-op descriptor provider that returns null for every port. Used as the
/// fallback on platforms where USB descriptor enumeration isn't implemented,
/// or when callers want to preserve the legacy "probe every port" behavior.
/// </summary>
internal sealed class NullUsbPortDescriptorProvider : IUsbPortDescriptorProvider
{
public static readonly NullUsbPortDescriptorProvider Instance = new();

private NullUsbPortDescriptorProvider() { }

public UsbPortDescriptor? GetDescriptor(string portName) => null;
}
Loading
Loading