diff --git a/src/Daqifi.Core.Tests/Device/Discovery/SerialDeviceFinderFilterTests.cs b/src/Daqifi.Core.Tests/Device/Discovery/SerialDeviceFinderFilterTests.cs new file mode 100644 index 0000000..c48f741 --- /dev/null +++ b/src/Daqifi.Core.Tests/Device/Discovery/SerialDeviceFinderFilterTests.cs @@ -0,0 +1,144 @@ +using Daqifi.Core.Device.Discovery; + +namespace Daqifi.Core.Tests.Device.Discovery; + +public class SerialDeviceFinderFilterTests +{ + #region FilterProbableDaqifiPorts + + [Fact] + public void FilterProbableDaqifiPorts_ExcludesBluetoothPorts() + { + var ports = new[] { "COM1", "COM3", "/dev/tty.Bluetooth-Incoming-Port" }; + var result = SerialDeviceFinder.FilterProbableDaqifiPorts(ports).ToList(); + + Assert.DoesNotContain(result, p => p.Contains("Bluetooth")); + } + + [Fact] + public void FilterProbableDaqifiPorts_ExcludesDebugPorts() + { + var ports = new[] { "COM1", "/dev/cu.debug-console" }; + var result = SerialDeviceFinder.FilterProbableDaqifiPorts(ports).ToList(); + + Assert.DoesNotContain(result, p => p.Contains("debug")); + } + + [Fact] + public void FilterProbableDaqifiPorts_ExcludesWlanPorts() + { + var ports = new[] { "COM1", "/dev/cu.wlan-debug" }; + var result = SerialDeviceFinder.FilterProbableDaqifiPorts(ports).ToList(); + + Assert.DoesNotContain(result, p => p.Contains("wlan")); + } + + [Fact] + public void FilterProbableDaqifiPorts_EmptyInput_ReturnsEmpty() + { + var result = SerialDeviceFinder.FilterProbableDaqifiPorts(Array.Empty()).ToList(); + Assert.Empty(result); + } + + #endregion + + #region FilterByUsbVidPid + + [Fact] + public void FilterByUsbVidPid_EmptyUsbInfo_ReturnsAllPorts() + { + var ports = new[] { "COM1", "COM2", "COM3" }; + var usbInfo = new Dictionary(); + + var result = SerialDeviceFinder.FilterByUsbVidPid(ports, usbInfo).ToList(); + + Assert.Equal(3, result.Count); + Assert.Equal(ports, result); + } + + [Fact] + public void FilterByUsbVidPid_KeepsDaqifiVendorPorts() + { + var ports = new[] { "COM1", "COM2" }; + var usbInfo = new Dictionary + { + ["COM1"] = new(SerialPortUsbDetector.DaqifiVendorId, 0x003C), + ["COM2"] = new(SerialPortUsbDetector.DaqifiVendorId, 0x0042) + }; + + var result = SerialDeviceFinder.FilterByUsbVidPid(ports, usbInfo).ToList(); + + Assert.Equal(2, result.Count); + Assert.Contains("COM1", result); + Assert.Contains("COM2", result); + } + + [Fact] + public void FilterByUsbVidPid_RejectsNonDaqifiVendorPorts() + { + var ports = new[] { "COM1", "COM2", "COM3" }; + var usbInfo = new Dictionary + { + ["COM1"] = new(0x1234, 0x5678), // Random USB device + ["COM2"] = new(SerialPortUsbDetector.DaqifiVendorId, 0x003C), // DAQiFi + ["COM3"] = new(0x2341, 0x0043) // Arduino + }; + + var result = SerialDeviceFinder.FilterByUsbVidPid(ports, usbInfo).ToList(); + + Assert.Single(result); + Assert.Contains("COM2", result); + } + + [Fact] + public void FilterByUsbVidPid_KeepsPortsWithNoUsbInfo() + { + // Ports not in usbInfo are kept (could be non-USB serial or detection missed them) + var ports = new[] { "COM1", "COM2", "COM3" }; + var usbInfo = new Dictionary + { + ["COM1"] = new(0x1234, 0x5678) // Known non-DAQiFi + }; + + var result = SerialDeviceFinder.FilterByUsbVidPid(ports, usbInfo).ToList(); + + // COM1 rejected (known non-DAQiFi), COM2 and COM3 kept (unknown = safe to probe) + Assert.Equal(2, result.Count); + Assert.DoesNotContain("COM1", result); + Assert.Contains("COM2", result); + Assert.Contains("COM3", result); + } + + [Fact] + public void FilterByUsbVidPid_MixedScenario() + { + var ports = new[] { "/dev/cu.usbmodem101", "/dev/cu.usbserial-1420", "/dev/cu.SLAB_USBtoUART", "COM4" }; + var usbInfo = new Dictionary + { + ["/dev/cu.usbmodem101"] = new(SerialPortUsbDetector.DaqifiVendorId, 0x003C), // DAQiFi + ["/dev/cu.usbserial-1420"] = new(0x0403, 0x6001), // FTDI chip + ["/dev/cu.SLAB_USBtoUART"] = new(0x10C4, 0xEA60) // Silicon Labs + // COM4 not in usbInfo + }; + + var result = SerialDeviceFinder.FilterByUsbVidPid(ports, usbInfo).ToList(); + + Assert.Equal(2, result.Count); + Assert.Contains("/dev/cu.usbmodem101", result); // DAQiFi VID match + Assert.Contains("COM4", result); // Unknown = kept + } + + [Fact] + public void FilterByUsbVidPid_EmptyInput_ReturnsEmpty() + { + var usbInfo = new Dictionary + { + ["COM1"] = new(SerialPortUsbDetector.DaqifiVendorId, 0x003C) + }; + + var result = SerialDeviceFinder.FilterByUsbVidPid(Enumerable.Empty(), usbInfo).ToList(); + Assert.Empty(result); + } + + #endregion +} diff --git a/src/Daqifi.Core.Tests/Device/Discovery/SerialPortUsbDetectorTests.cs b/src/Daqifi.Core.Tests/Device/Discovery/SerialPortUsbDetectorTests.cs new file mode 100644 index 0000000..bf6478a --- /dev/null +++ b/src/Daqifi.Core.Tests/Device/Discovery/SerialPortUsbDetectorTests.cs @@ -0,0 +1,46 @@ +using Daqifi.Core.Device.Discovery; + +namespace Daqifi.Core.Tests.Device.Discovery; + +public class SerialPortUsbDetectorTests +{ + [Fact] + public void IsDaqifiVendor_WithDaqifiVid_ReturnsTrue() + { + Assert.True(SerialPortUsbDetector.IsDaqifiVendor(0x04D8)); + } + + [Fact] + public void IsDaqifiVendor_WithOtherVid_ReturnsFalse() + { + Assert.False(SerialPortUsbDetector.IsDaqifiVendor(0x1234)); + Assert.False(SerialPortUsbDetector.IsDaqifiVendor(0x0000)); + Assert.False(SerialPortUsbDetector.IsDaqifiVendor(0xFFFF)); + } + + [Fact] + public void DaqifiVendorId_MatchesHidDeviceFinderVendorId() + { + // Ensure serial and HID discovery use the same vendor ID + Assert.Equal(HidDeviceFinder.DefaultVendorId, SerialPortUsbDetector.DaqifiVendorId); + } + + [Fact] + public void GetPortUsbInfo_ReturnsNonNullDictionary() + { + // Should never throw; returns empty dictionary on failure + var result = SerialPortUsbDetector.GetPortUsbInfo(); + Assert.NotNull(result); + } + + [Fact] + public void UsbId_RecordEquality() + { + var a = new SerialPortUsbDetector.UsbId(0x04D8, 0x003C); + var b = new SerialPortUsbDetector.UsbId(0x04D8, 0x003C); + var c = new SerialPortUsbDetector.UsbId(0x1234, 0x5678); + + Assert.Equal(a, b); + Assert.NotEqual(a, c); + } +} diff --git a/src/Daqifi.Core/Device/Discovery/SerialDeviceFinder.cs b/src/Daqifi.Core/Device/Discovery/SerialDeviceFinder.cs index b350737..057879a 100644 --- a/src/Daqifi.Core/Device/Discovery/SerialDeviceFinder.cs +++ b/src/Daqifi.Core/Device/Discovery/SerialDeviceFinder.cs @@ -15,7 +15,7 @@ namespace Daqifi.Core.Device.Discovery; /// /// Discovers DAQiFi devices connected via USB/Serial ports. -/// Probes each port by sending SCPI commands and validating protobuf responses. +/// Filters ports by USB VID/PID, then probes candidates in parallel using SCPI commands. /// public class SerialDeviceFinder : IDeviceFinder, IDisposable { @@ -23,10 +23,14 @@ public class SerialDeviceFinder : IDeviceFinder, IDisposable private const int DefaultBaudRate = 9600; private const int ProbeTimeoutMs = 1000; - private const int DeviceWakeUpDelayMs = 1000; - private const int ResponseTimeoutMs = 4000; - private const int MaxRetries = 3; - private const int RetryIntervalMs = 1000; + + /// + /// Discovery-specific timeouts (shorter than connection timeouts for fast scanning). + /// + private const int DiscoveryWakeUpDelayMs = 200; + private const int DiscoveryResponseTimeoutMs = 1000; + private const int DiscoveryMaxRetries = 2; + private const int DiscoveryRetryIntervalMs = 300; private const int PollIntervalMs = 100; #endregion @@ -77,7 +81,7 @@ public SerialDeviceFinder(int baudRate) /// /// Discovers devices asynchronously with a cancellation token. - /// Probes each serial port to identify DAQiFi devices. + /// Filters ports by USB VID/PID, then probes candidates in parallel. /// /// Cancellation token to abort the operation. /// A task containing the collection of discovered DAQiFi devices. @@ -89,32 +93,21 @@ public async Task> DiscoverAsync(CancellationToken canc await _discoverySemaphore.WaitAsync(cancellationToken); try { - var discoveredDevices = new List(); - var availablePorts = FilterProbableDaqifiPorts(SerialStreamTransport.GetAvailablePortNames()); + var allPorts = FilterProbableDaqifiPorts(SerialStreamTransport.GetAvailablePortNames()); - foreach (var portName in availablePorts) - { - if (cancellationToken.IsCancellationRequested) - break; + // Filter by USB VID/PID before probing (avoids sending SCPI to non-DAQiFi devices) + var candidatePorts = FilterByUsbVidPid(allPorts).ToList(); - try - { - var deviceInfo = await TryGetDeviceInfoAsync(portName, cancellationToken); - if (deviceInfo != null) - { - discoveredDevices.Add(deviceInfo); - OnDeviceDiscovered(deviceInfo); - } - } - catch (OperationCanceledException) - { - break; - } - catch (Exception) - { - // Skip ports that fail to open or respond - // This is normal as not all serial ports are DAQiFi devices - } + // Probe candidate ports in parallel + var probeTasks = candidatePorts.Select(portName => + TryGetDeviceInfoAsync(portName, cancellationToken)); + + var results = await Task.WhenAll(probeTasks); + var discoveredDevices = results.Where(d => d != null).Cast().ToList(); + + foreach (var device in discoveredDevices) + { + OnDeviceDiscovered(device); } OnDiscoveryCompleted(); @@ -144,6 +137,7 @@ public async Task> DiscoverAsync(TimeSpan timeout) /// /// Attempts to probe a serial port and retrieve device information. /// Opens the port, sends GetDeviceInfo command, and waits for a status response. + /// Uses reduced timeouts and minimal commands optimized for fast discovery. /// /// The serial port name. /// Cancellation token. @@ -167,8 +161,8 @@ public async Task> DiscoverAsync(TimeSpan timeout) port.Open(); port.DtrEnable = true; - // Wait for device to wake up (devices need time after DTR is enabled) - await Task.Delay(DeviceWakeUpDelayMs, cancellationToken); + // Brief delay for device to wake up after DTR is enabled + await Task.Delay(DiscoveryWakeUpDelayMs, cancellationToken); // Set up message producer and consumer var stream = port.BaseStream; @@ -197,36 +191,23 @@ public async Task> DiscoverAsync(TimeSpan timeout) consumer.Start(); - // Initialize device - send commands to prepare for communication - // These are required for the device to respond properly - producer.Send(ScpiMessageProducer.DisableDeviceEcho); - await Task.Delay(100, cancellationToken); - - producer.Send(ScpiMessageProducer.StopStreaming); - await Task.Delay(100, cancellationToken); - - producer.Send(ScpiMessageProducer.TurnDeviceOn); - await Task.Delay(100, cancellationToken); - - producer.Send(ScpiMessageProducer.SetProtobufStreamFormat); - await Task.Delay(100, cancellationToken); - - // Send GetDeviceInfo command with retry logic - var timeout = DateTime.UtcNow.AddMilliseconds(ResponseTimeoutMs); + // Discovery only needs GetDeviceInfo — no setup commands required. + // The full initialization sequence (DisableEcho, StopStreaming, TurnDeviceOn, + // SetProtobufStreamFormat) is handled during connection, not discovery. + var timeout = DateTime.UtcNow.AddMilliseconds(DiscoveryResponseTimeoutMs); var lastRequestTime = DateTime.MinValue; var retryCount = 0; while (statusMessage == null && DateTime.UtcNow < timeout && !cancellationToken.IsCancellationRequested) { - // Send request every RetryIntervalMs, up to MaxRetries times - if ((DateTime.UtcNow - lastRequestTime).TotalMilliseconds >= RetryIntervalMs && retryCount < MaxRetries) + if ((DateTime.UtcNow - lastRequestTime).TotalMilliseconds >= DiscoveryRetryIntervalMs && + retryCount < DiscoveryMaxRetries) { producer.Send(ScpiMessageProducer.GetDeviceInfo); lastRequestTime = DateTime.UtcNow; retryCount++; } - // Wait a bit for response var remainingTime = Math.Min(PollIntervalMs, (int)(timeout - DateTime.UtcNow).TotalMilliseconds); if (remainingTime > 0) { @@ -308,13 +289,64 @@ await Task.WhenAny( } } + /// + /// Filters ports by USB VID/PID using the system's USB device information. + /// + /// Ports to filter. + /// Ports that are candidates for DAQiFi devices. + internal static IEnumerable FilterByUsbVidPid(IEnumerable ports) + { + return FilterByUsbVidPid(ports, SerialPortUsbDetector.GetPortUsbInfo()); + } + + /// + /// Filters ports by USB VID/PID, keeping only ports that belong to known DAQiFi vendors. + /// Ports with unknown USB identity (non-USB or detection failed) are included as candidates. + /// If VID/PID detection is entirely unavailable (empty dictionary), all ports are returned. + /// + /// Ports to filter. + /// USB VID/PID info per port from . + /// Ports that are candidates for DAQiFi devices. + internal static IEnumerable FilterByUsbVidPid( + IEnumerable ports, Dictionary usbInfo) + { + var portList = ports.ToList(); + + if (usbInfo.Count == 0) + { + // VID/PID detection unavailable — probe all ports + return portList; + } + + var candidates = new List(); + foreach (var port in portList) + { + if (usbInfo.TryGetValue(port, out var id)) + { + // USB info available — only include if VID matches DAQiFi + if (SerialPortUsbDetector.IsDaqifiVendor(id.VendorId)) + { + candidates.Add(port); + } + } + else + { + // No USB info for this port — include to be safe + // (could be a non-USB serial port or detection missed it) + candidates.Add(port); + } + } + + return candidates; + } + /// /// Filters the list of available ports to only include those likely to be DAQiFi devices. /// Excludes debug ports, Bluetooth ports, and on macOS prefers /dev/cu.* over /dev/tty.*. /// /// All available serial port names. /// Filtered list of ports to probe. - private static IEnumerable FilterProbableDaqifiPorts(string[] allPorts) + internal static IEnumerable FilterProbableDaqifiPorts(string[] allPorts) { // Skip debug and bluetooth ports which are unlikely to be DAQiFi devices var excludePatterns = new[] diff --git a/src/Daqifi.Core/Device/Discovery/SerialPortUsbDetector.cs b/src/Daqifi.Core/Device/Discovery/SerialPortUsbDetector.cs new file mode 100644 index 0000000..2c2b413 --- /dev/null +++ b/src/Daqifi.Core/Device/Discovery/SerialPortUsbDetector.cs @@ -0,0 +1,267 @@ +using System.Diagnostics; +using System.Runtime.InteropServices; +using System.Runtime.Versioning; +using System.Text.RegularExpressions; + +namespace Daqifi.Core.Device.Discovery; + +/// +/// Detects USB Vendor ID and Product ID for serial ports using platform-specific APIs. +/// Used to filter serial ports before probing, avoiding sending SCPI commands to non-DAQiFi devices. +/// +internal static class SerialPortUsbDetector +{ + /// + /// Known DAQiFi USB Vendor ID (Microchip Technology Inc.). + /// Shared with for bootloader mode. + /// + internal const int DaqifiVendorId = 0x04D8; + + /// + /// Represents USB identification for a serial port. + /// + internal sealed record UsbId(int VendorId, int ProductId); + + /// + /// Gets USB VID/PID information for serial ports on the system. + /// Returns a dictionary mapping port names to their USB IDs (null values not included). + /// Returns empty dictionary if detection is unavailable or fails. + /// + internal static Dictionary GetPortUsbInfo() + { + try + { + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + return GetPortUsbInfoWindows(); + if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) + return GetPortUsbInfoMacOS(); + if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux)) + return GetPortUsbInfoLinux(); + } + catch + { + // Platform detection failed — caller will probe all ports + } + + return new Dictionary(); + } + + /// + /// Checks whether the given USB Vendor ID matches a known DAQiFi vendor. + /// + internal static bool IsDaqifiVendor(int vendorId) => vendorId == DaqifiVendorId; + + #region Windows + + [SupportedOSPlatform("windows")] + private static Dictionary GetPortUsbInfoWindows() + { + var result = new Dictionary(StringComparer.OrdinalIgnoreCase); + + try + { + // Enumerate USB devices in the registry to find COM port mappings + // Path: HKLM\SYSTEM\CurrentControlSet\Enum\USB\VID_xxxx&PID_xxxx\{serial}\Device Parameters\PortName + using var usbKey = Microsoft.Win32.Registry.LocalMachine.OpenSubKey( + @"SYSTEM\CurrentControlSet\Enum\USB"); + if (usbKey == null) return result; + + foreach (var vidPidKeyName in usbKey.GetSubKeyNames()) + { + var match = Regex.Match(vidPidKeyName, + @"VID_([0-9A-Fa-f]{4})&PID_([0-9A-Fa-f]{4})", RegexOptions.IgnoreCase); + if (!match.Success) continue; + + var vid = Convert.ToInt32(match.Groups[1].Value, 16); + var pid = Convert.ToInt32(match.Groups[2].Value, 16); + + using var vidPidKey = usbKey.OpenSubKey(vidPidKeyName); + if (vidPidKey == null) continue; + + foreach (var instanceKeyName in vidPidKey.GetSubKeyNames()) + { + using var deviceParamsKey = vidPidKey.OpenSubKey($@"{instanceKeyName}\Device Parameters"); + var portName = deviceParamsKey?.GetValue("PortName") as string; + if (!string.IsNullOrEmpty(portName)) + { + result[portName] = new UsbId(vid, pid); + } + } + } + } + catch + { + // Registry access failed — return what we have + } + + return result; + } + + #endregion + + #region macOS + + private static Dictionary GetPortUsbInfoMacOS() + { + // Try AppleUSBDevice first (most macOS versions), fall back to IOUSBHostDevice + var result = ParseIoregUsbSerialPorts("AppleUSBDevice"); + if (result.Count == 0) + result = ParseIoregUsbSerialPorts("IOUSBHostDevice"); + return result; + } + + /// + /// Parses ioreg output for USB devices of the specified class to find serial port mappings. + /// The output from ioreg -r -c {className} -l -w0 lists each USB device and its descendants, + /// including any IOSerialBSDClient entries that contain IOCalloutDevice paths. + /// + private static Dictionary ParseIoregUsbSerialPorts(string usbClassName) + { + var result = new Dictionary(); + + var output = RunProcess("/usr/sbin/ioreg", $"-r -c {usbClassName} -l -w0"); + if (string.IsNullOrEmpty(output)) return result; + + // Walk through the ioreg output tracking the current USB device's VID/PID. + // USB device properties (idVendor, idProduct) appear before any descendant + // IOSerialBSDClient entries containing IOCalloutDevice. + // When a new top-level USB device is encountered, VID/PID is naturally overwritten. + int? currentVid = null; + int? currentPid = null; + + foreach (var line in output.Split('\n')) + { + var vidMatch = Regex.Match(line, @"""idVendor""\s*=\s*(\d+)"); + if (vidMatch.Success) + { + currentVid = int.Parse(vidMatch.Groups[1].Value); + continue; + } + + var pidMatch = Regex.Match(line, @"""idProduct""\s*=\s*(\d+)"); + if (pidMatch.Success) + { + currentPid = int.Parse(pidMatch.Groups[1].Value); + continue; + } + + var portMatch = Regex.Match(line, @"""IOCalloutDevice""\s*=\s*""([^""]+)"""); + if (portMatch.Success && currentVid.HasValue && currentPid.HasValue) + { + result[portMatch.Groups[1].Value] = new UsbId(currentVid.Value, currentPid.Value); + } + } + + return result; + } + + #endregion + + #region Linux + + private static Dictionary GetPortUsbInfoLinux() + { + var result = new Dictionary(); + + try + { + // Read USB VID/PID from sysfs for each tty device + var ttyDir = "/sys/class/tty"; + if (!Directory.Exists(ttyDir)) return result; + + foreach (var ttyPath in Directory.GetDirectories(ttyDir)) + { + var ttyName = Path.GetFileName(ttyPath); + var deviceLink = Path.Combine(ttyPath, "device"); + if (!Directory.Exists(deviceLink)) continue; + + // Walk up the sysfs tree to find the USB device with idVendor/idProduct + var usbDevicePath = FindUsbParent(deviceLink); + if (usbDevicePath == null) continue; + + var vidFile = Path.Combine(usbDevicePath, "idVendor"); + var pidFile = Path.Combine(usbDevicePath, "idProduct"); + + if (!File.Exists(vidFile) || !File.Exists(pidFile)) continue; + + var vidStr = File.ReadAllText(vidFile).Trim(); + var pidStr = File.ReadAllText(pidFile).Trim(); + + if (int.TryParse(vidStr, System.Globalization.NumberStyles.HexNumber, null, out var vid) && + int.TryParse(pidStr, System.Globalization.NumberStyles.HexNumber, null, out var pid)) + { + result[$"/dev/{ttyName}"] = new UsbId(vid, pid); + } + } + } + catch + { + // sysfs access failed — return what we have + } + + return result; + } + + private static string? FindUsbParent(string devicePath) + { + var current = Path.GetFullPath(devicePath); + + for (var i = 0; i < 10; i++) + { + if (File.Exists(Path.Combine(current, "idVendor"))) + return current; + + var parent = Path.GetDirectoryName(current); + if (parent == null || parent == current) + break; + + current = parent; + } + + return null; + } + + #endregion + + #region Helpers + + private const int ProcessTimeoutMs = 5000; + + private static string RunProcess(string fileName, string arguments) + { + try + { + using var process = new Process + { + StartInfo = new ProcessStartInfo + { + FileName = fileName, + Arguments = arguments, + RedirectStandardOutput = true, + UseShellExecute = false, + CreateNoWindow = true + } + }; + + process.Start(); + + // Read stdout asynchronously so we can enforce a timeout. + // ReadToEnd() before WaitForExit() can block indefinitely if the process stalls. + var readTask = process.StandardOutput.ReadToEndAsync(); + if (process.WaitForExit(ProcessTimeoutMs) && readTask.Wait(ProcessTimeoutMs)) + { + return readTask.Result; + } + + // Process or read timed out — kill and return empty + try { process.Kill(entireProcessTree: true); } catch { /* best effort */ } + return string.Empty; + } + catch + { + return string.Empty; + } + } + + #endregion +}