Skip to content
Open
Show file tree
Hide file tree
Changes from 10 commits
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,103 @@ 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.
// The legacy probe path may exceed 200ms on machines with real ports —
// an OperationCanceledException there still proves the contract:
// null descriptors fall through to probing rather than being filtered.
Comment on lines +151 to +153
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Action required

1. Null descriptor still probes ports 📎 Requirement gap ≡ Correctness

The new test codifies that when GetDescriptor() returns null, discovery falls through to legacy
probing, which can open/send commands to ports without a confirmed DAQiFi VID/PID. This violates the
requirement that only VID/PID-matched DAQiFi ports are probed and others receive no traffic.
Agent Prompt
## Issue description
Current behavior (and test expectation) allows `null` USB descriptors to fall through to probing, which can open and send DAQiFi/SCPI commands to non-DAQiFi ports when VID/PID cannot be resolved.

## Issue Context
PR Compliance requires serial discovery to avoid probing non-DAQiFi ports by filtering on known DAQiFi VID/PID values before any port is opened.

## Fix Focus Areas
- src/Daqifi.Core.Tests/Device/Discovery/SerialDeviceFinderTests.cs[149-160]

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

try
{
var devices = await finder.DiscoverAsync(cts.Token);
Assert.NotNull(devices);
}
catch (OperationCanceledException)
{
// Probe ran (legacy fallback engaged) and exceeded the test budget.
}
}

[Fact]
public async Task DiscoverAsync_WithThrowingDescriptorProvider_DoesNotAbortDiscovery()
{
// A custom IUsbPortDescriptorProvider that throws must NEVER take
// down the whole discovery pass — fall through to legacy probing
// for the port and continue with the rest of the list.
var fakeProvider = new RecordingUsbPortDescriptorProvider(_ =>
throw new InvalidOperationException("simulated provider failure"));

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

// Same caveat as the null-descriptor test: probe path may exceed
// 200ms on machines with real ports. OCE is acceptable here too
// — what matters is that the throwing provider didn't propagate.
try
{
var devices = await finder.DiscoverAsync(cts.Token);
Assert.NotNull(devices);
}
catch (OperationCanceledException)
{
// Probe ran (provider throw was caught and treated as null).
}
}

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(""));
}
}
10 changes: 10 additions & 0 deletions src/Daqifi.Core/Daqifi.Core.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -35,5 +35,15 @@
<InternalsVisibleTo Include="Daqifi.Core.Tests" />
</ItemGroup>

<!-- USB descriptor lookup uses WMI on Windows. The package itself is
cross-platform metadata-only; only the WMI types execute, and the
runtime guard in WindowsUsbPortDescriptorProvider ensures it never
runs on non-Windows platforms. The reference is unconditional so
WindowsUsbPortDescriptorProvider.cs (compiled on every target)
can resolve System.Management even during a Linux/macOS build. -->
<ItemGroup>
<PackageReference Include="System.Management" Version="10.0.7" />
</ItemGroup>


</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,73 @@
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
{
// /sys/class/tty/<base>/device is a symlink into the actual USB
// device tree (e.g. /sys/devices/pci.../usb1/.../1-1.2). Walking
// parents of the unresolved logical path lands back in /sys/class
// and never reaches the node that holds idVendor/idProduct, so
// resolve to the physical target before traversal.
var dirInfo = new System.IO.DirectoryInfo(sysfsRoot);
var resolved = dirInfo.ResolveLinkTarget(returnFinalTarget: true);
var current = (resolved ?? dirInfo).FullName;
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