diff --git a/.github/workflows/build-and-release.yml b/.github/workflows/build-and-release.yml index 147ac40..49329cc 100644 --- a/.github/workflows/build-and-release.yml +++ b/.github/workflows/build-and-release.yml @@ -114,15 +114,25 @@ jobs: } 'linux-x64' { Copy-Item -Force './CollimationCircles/Libraries/ASI_linux_mac_SDK_V1.41/lib/x64/libASICamera2.so*' $output + # Copy libuvc for UVC camera control and streaming via libuvc on Linux + Copy-Item -Force './CollimationCircles/Libraries/libuvc/linux/x64/libuvc.so' $output } 'linux-arm64' { Copy-Item -Force './CollimationCircles/Libraries/ASI_linux_mac_SDK_V1.41/lib/armv8/libASICamera2.so*' $output + # Copy libuvc for UVC camera control and streaming via libuvc on Linux + Copy-Item -Force './CollimationCircles/Libraries/libuvc/linux/arm64/libuvc.so' $output } 'win-x64' { Copy-Item -Force './CollimationCircles/Libraries/ASI_Windows_SDK_V1.41/lib/x64/ASICamera2.dll' $output + # Copy libusb + libuvc for UVC camera control and streaming on Windows + Copy-Item -Force './CollimationCircles/Libraries/libusb/win/x64/libusb-1.0.dll' $output + Copy-Item -Force './CollimationCircles/Libraries/libuvc/win/x64/libuvc.dll' $output } 'win-arm64' { Write-Warning 'No ZWO ASI Windows arm64 binary is shipped in the SDK; skipping native library copy.' + # Copy libusb + libuvc for UVC camera control and streaming on Windows + Copy-Item -Force './CollimationCircles/Libraries/libusb/win/arm64/libusb-1.0.dll' $output + Copy-Item -Force './CollimationCircles/Libraries/libuvc/win/arm64/libuvc.dll' $output } } diff --git a/CollimationCircles/CollimationCircles.csproj b/CollimationCircles/CollimationCircles.csproj index 4178412..627ebb6 100644 --- a/CollimationCircles/CollimationCircles.csproj +++ b/CollimationCircles/CollimationCircles.csproj @@ -169,6 +169,54 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + libvlc\%(RecursiveDir)%(Filename)%(Extension) diff --git a/CollimationCircles/Program.cs b/CollimationCircles/Program.cs index 4e2ee88..5439d0a 100644 --- a/CollimationCircles/Program.cs +++ b/CollimationCircles/Program.cs @@ -7,6 +7,7 @@ using System.IO; using System.Linq; using System.Runtime.InteropServices; +using System.Threading.Tasks; namespace CollimationCircles { @@ -48,6 +49,12 @@ public static void Main(string[] args) try { + if (StartupOptions.DebugUvc) + { + RunUvcDebug().Wait(); + return; + } + if (StartupOptions.RecoverUvcVidPid is { } recoverVidPid) { bool recovered = UvcFrameSource.TryRecoverUvcDevice(recoverVidPid.VendorId, recoverVidPid.ProductId); @@ -101,6 +108,47 @@ private static void InstallLinuxCrashHandler() } } + private static async Task RunUvcDebug() + { + logger.Info("=== UVC Camera Detection Debug Mode ==="); + logger.Info($"Operating System: Windows"); + + try + { + logger.Info("Initiating UVC camera detection..."); + var detector = new UvcCameraDetectWindows(); + var cameras = await detector.GetCameras(); + + logger.Info($"\n========================================"); + logger.Info($"DETECTION COMPLETE"); + logger.Info($"Total UVC cameras found: {cameras.Count}"); + logger.Info($"========================================"); + + if (cameras.Count > 0) + { + logger.Info("Cameras detected:"); + foreach (var camera in cameras) + { + logger.Info($"\n Camera {camera.Index}:"); + logger.Info($" Name: {camera.Name}"); + logger.Info($" VendorID: 0x{camera.VendorId:X4}"); + logger.Info($" ProductID: 0x{camera.ProductId:X4}"); + logger.Info($" Path: {camera.Path}"); + logger.Info($" APIType: {camera.APIType}"); + } + } + else + { + logger.Warn("No UVC cameras were detected."); + logger.Info("Check the debug output above for details on why devices were filtered."); + } + } + catch (Exception ex) + { + logger.Fatal(ex, "Error during UVC camera detection"); + } + } + private static void BootstrapMacArm64VlcEnvironment(string[] args) { if (!IsMacArm64) diff --git a/CollimationCircles/Services/CameraControlService.cs b/CollimationCircles/Services/CameraControlService.cs index 3fadae4..390e107 100644 --- a/CollimationCircles/Services/CameraControlService.cs +++ b/CollimationCircles/Services/CameraControlService.cs @@ -26,6 +26,14 @@ public void Set(ControlType controlName, double value, Camera camera) { new UvcCameraDetectMac().SetControl(camera, controlName, value); } + else if (OperatingSystem.IsLinux()) + { + new UvcCameraDetectLinux().SetControl(camera, controlName, value); + } + else if (OperatingSystem.IsWindows()) + { + new UvcCameraDetectWindows().SetControl(camera, controlName, value); + } } // ZWO camera controls (Linux/Windows/macOS) @@ -72,6 +80,14 @@ public void SetAuto(ControlType controlName, bool isAuto, Camera camera) { new UvcCameraDetectMac().SetControlAuto(camera, controlName, isAuto); } + else if (OperatingSystem.IsLinux()) + { + new UvcCameraDetectLinux().SetControlAuto(camera, controlName, isAuto); + } + else if (OperatingSystem.IsWindows()) + { + new UvcCameraDetectWindows().SetControlAuto(camera, controlName, isAuto); + } } // ZWO camera auto-controls (Linux/Windows/macOS) @@ -89,6 +105,10 @@ public async Task> GetCameraList() { var dshowCameras = await new DShowCameraDetect().GetCameras(); cameras.AddRange(dshowCameras); + + // Also detect UVC cameras via libuvc on Windows + var windowsUvcCameras = await new UvcCameraDetectWindows().GetCameras(); + cameras.AddRange(windowsUvcCameras); } else if (OperatingSystem.IsMacOS()) { @@ -112,6 +132,10 @@ public async Task> GetCameraList() var v4l2Cameras = await new V4L2CameraDetect().GetCameras(); cameras.AddRange(v4l2Cameras); + + // Also detect UVC cameras via libuvc on Linux + var linuxUvcCameras = await new UvcCameraDetectLinux().GetCameras(); + cameras.AddRange(linuxUvcCameras); } var zwoCameras = await new ZWOCameraDetect().GetCameras(); diff --git a/CollimationCircles/Services/Uvc/UvcCameraDetectLinux.cs b/CollimationCircles/Services/Uvc/UvcCameraDetectLinux.cs new file mode 100644 index 0000000..d8981f8 --- /dev/null +++ b/CollimationCircles/Services/Uvc/UvcCameraDetectLinux.cs @@ -0,0 +1,313 @@ +using CollimationCircles.Models; +using CommunityToolkit.Diagnostics; +using CommunityToolkit.Mvvm.DependencyInjection; +using System; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Threading.Tasks; + +namespace CollimationCircles.Services.Uvc +{ + /// + /// Detects UVC cameras on Linux by scanning /sys/class/video4linux/ for USB + /// video devices and extracting vendor/product IDs. The returned cameras use + /// APIType.Uvc and are handled by UvcFrameSource (via libuvc) for both + /// streaming and control — the same code path as macOS. + /// + /// This replaces the old V4L2CameraDetect which used v4l2-ctl subprocess + /// calls and required separate V4L2-specific streaming/control logic. + /// + internal class UvcCameraDetectLinux() : ICameraDetect + { + private static readonly NLog.Logger logger = NLog.LogManager.GetCurrentClassLogger(); + + public Dictionary ControlMapping => new() + { + // Controls are enumerated at stream start by UvcFrameSource.EnumerateControls + // (via libuvc), so this mapping is unused. + }; + + public async Task> GetCameras() + { + List cameras = []; + + if (!OperatingSystem.IsLinux()) + { + logger.Debug("UvcCameraDetectLinux.GetCameras: skipped — not Linux"); + return cameras; + } + + logger.Info("UvcCameraDetectLinux.GetCameras: begin"); + + try + { + await Task.Run(() => DetectUvcCameras(cameras)); + } + catch (Exception ex) + { + logger.Error(ex, "Error while detecting UVC cameras on Linux"); + } + + logger.Info($"UvcCameraDetectLinux.GetCameras: returning {cameras.Count} camera(s)"); + return cameras; + } + + private void DetectUvcCameras(List cameras) + { + string videoDevicesDir = "/sys/class/video4linux"; + if (!Directory.Exists(videoDevicesDir)) + { + logger.Warn($"UvcCameraDetectLinux: /sys/class/video4linux not found — no video devices detected"); + return; + } + + string[] videoDeviceDirs = Directory.GetDirectories(videoDevicesDir); + logger.Info($"UvcCameraDetectLinux: found {videoDeviceDirs.Length} video device(s) in {videoDevicesDir}"); + + int index = 0; + + foreach (string deviceDir in videoDeviceDirs) + { + try + { + logger.Debug($"UvcCameraDetectLinux: processing '{deviceDir}'"); + + // Resolve the device symlink to a real path + string realPath = ResolveSymlink(deviceDir); + if (string.IsNullOrEmpty(realPath)) + { + logger.Debug($"UvcCameraDetectLinux: could not resolve symlink for '{deviceDir}'"); + continue; + } + + logger.Debug($"UvcCameraDetectLinux: real path = '{realPath}'"); + + // The device path contains the /dev/video* name + string deviceName = Path.GetFileName(deviceDir); + string devPath = $"/dev/{deviceName}"; + + // Read the device name from the 'name' file + string? name = ReadFileContent(Path.Combine(deviceDir, "name"))?.Trim(); + if (string.IsNullOrWhiteSpace(name)) + { + name = deviceName; + logger.Debug($"UvcCameraDetectLinux: no name file, using '{deviceName}'"); + } + + // Check if this is a USB device by looking for idVendor/idProduct + // in the device's parent USB hierarchy + int vendorId = 0; + int productId = 0; + string? usbDevicePath = FindUsbDevicePath(realPath); + + if (usbDevicePath != null) + { + logger.Debug($"UvcCameraDetectLinux: found USB device path '{usbDevicePath}'"); + string? vidStr = ReadFileContent(Path.Combine(usbDevicePath, "idVendor")); + string? pidStr = ReadFileContent(Path.Combine(usbDevicePath, "idProduct")); + + if (!string.IsNullOrWhiteSpace(vidStr) && !string.IsNullOrWhiteSpace(pidStr)) + { + vendorId = ParseHexId(vidStr.Trim()); + productId = ParseHexId(pidStr.Trim()); + logger.Debug($"UvcCameraDetectLinux: parsed VID={vendorId} PID={productId} from '{vidStr.Trim()}' / '{pidStr.Trim()}'"); + } + else + { + logger.Debug($"UvcCameraDetectLinux: idVendor or idProduct not found at '{usbDevicePath}'"); + } + } + else + { + logger.Debug($"UvcCameraDetectLinux: no USB device path found for real path '{realPath}'"); + } + + // Only add cameras with valid USB VID/PID (UVC cameras) + if (vendorId > 0 && productId > 0) + { + Camera camera = new() + { + Index = index++, + APIType = APIType.Uvc, + Name = name, + Path = devPath, + VendorId = vendorId, + ProductId = productId + }; + + // Controls are enumerated at stream start by UvcFrameSource + camera.Controls = []; + + cameras.Add(camera); + logger.Info($"UvcCameraDetectLinux: added UVC camera '{camera.Name}' (VID={vendorId} PID={productId}) at {devPath}"); + } + else + { + logger.Debug($"UvcCameraDetectLinux: skipping non-USB video device '{name}' at {devPath} (VID={vendorId} PID={productId})"); + } + } + catch (Exception ex) + { + logger.Warn(ex, $"UvcCameraDetectLinux: error processing video device '{deviceDir}'"); + } + } + + logger.Info($"UvcCameraDetectLinux: detected {cameras.Count} UVC camera(s)"); + } + + /// + /// Walks up the device tree from the video device's real path to find + /// the USB device node containing idVendor/idProduct files. + /// + private static string? FindUsbDevicePath(string deviceRealPath) + { + // Walk up the directory tree looking for a USB device with idVendor/idProduct + DirectoryInfo? dir = new DirectoryInfo(deviceRealPath); + + for (int i = 0; i < 20 && dir != null; i++) + { + string vidPath = Path.Combine(dir.FullName, "idVendor"); + string pidPath = Path.Combine(dir.FullName, "idProduct"); + + if (File.Exists(vidPath) && File.Exists(pidPath)) + { + return dir.FullName; + } + + dir = dir.Parent; + } + + return null; + } + + /// + /// Resolves a symlink to its real (absolute) path. Returns null on failure. + /// + private static string? ResolveSymlink(string path) + { + try + { + var linkTarget = File.ResolveLinkTarget(path, true); + return linkTarget?.FullName; + } + catch + { + return null; + } + } + + private static string? ReadFileContent(string path) + { + try + { + if (File.Exists(path)) + { + return File.ReadAllText(path).Trim(); + } + } + catch (Exception ex) + { + logger.Debug(ex, $"Could not read '{path}'"); + } + return null; + } + + private static int ParseHexId(string value) + { + value = value.Trim(); + if (value.StartsWith("0x", StringComparison.OrdinalIgnoreCase)) + { + value = value[2..]; + } + return int.TryParse(value, NumberStyles.HexNumber, CultureInfo.InvariantCulture, out int id) ? id : 0; + } + + public async Task> GetControls(Camera camera) + { + Guard.IsNotNull(camera); + + // UVC controls are enumerated at stream start by UvcFrameSource.EnumerateControls + // (via libuvc). Return empty list — placeholders will be replaced on Play. + logger.Info($"UVC camera '{camera.Name}' (VID={camera.VendorId} PID={camera.ProductId}) — controls will be enumerated on stream start"); + return await Task.FromResult(new List()); + } + + public void SetControl(Camera camera, ControlType controlType, double value) + { + // UVC control setting is handled via UvcFrameSource + // which has the device open during streaming. + if (camera.APIType is APIType.Uvc) + { + try + { + logger.Info($"Linux UVC set request: camera='{camera.Name}', control={controlType}, value={value}"); + var uvcFrameSource = Ioc.Default.GetRequiredService(); + bool ok = uvcFrameSource.SetControl(controlType.ToString(), (long)value); + if (!ok) + { + logger.Warn($"Failed to set UVC control {controlType}={value} on '{camera.Name}'"); + } + else + { + logger.Info($"Linux UVC set request completed: camera='{camera.Name}', control={controlType}, value={value}"); + } + } + catch (Exception ex) + { + logger.Error(ex, $"Error setting UVC control {controlType} on '{camera.Name}'"); + } + } + } + + public void SetControlAuto(Camera camera, ControlType controlType, bool isAuto) + { + if (camera.APIType is APIType.Uvc) + { + try + { + logger.Info($"Linux UVC auto set request: camera='{camera.Name}', control={controlType}, isAuto={isAuto}"); + var uvcFrameSource = Ioc.Default.GetRequiredService(); + + string autoName = controlType switch + { + ControlType.ExposureTime => "AutoExposure", + ControlType.FocusAbsolute => "AutoFocus", + ControlType.WhiteBalance => "AutoWhiteBalance", + ControlType.Hue => "HueAuto", + ControlType.Contrast => "ContrastAuto", + _ => string.Empty + }; + + if (!string.IsNullOrEmpty(autoName)) + { + bool ok = uvcFrameSource.SetAutoControl(autoName, isAuto); + if (!ok) + { + logger.Warn($"Failed to set UVC auto control {controlType}={isAuto} on '{camera.Name}'"); + } + else + { + logger.Info($"Linux UVC auto set request completed: camera='{camera.Name}', control={controlType}, isAuto={isAuto}"); + } + } + else + { + logger.Warn($"No UVC auto-control mapping for {controlType} on '{camera.Name}'"); + } + } + catch (Exception ex) + { + logger.Error(ex, $"Error setting UVC auto control {controlType} on '{camera.Name}'"); + } + } + } + + public List GetCommandLineParameters(Camera camera, ICommandBuilder? builder) + { + Guard.IsNotNull(camera); + return []; + } + } +} \ No newline at end of file diff --git a/CollimationCircles/Services/Uvc/UvcCameraDetectWindows.cs b/CollimationCircles/Services/Uvc/UvcCameraDetectWindows.cs new file mode 100644 index 0000000..8ad010b --- /dev/null +++ b/CollimationCircles/Services/Uvc/UvcCameraDetectWindows.cs @@ -0,0 +1,430 @@ +using CollimationCircles.Models; +using CommunityToolkit.Diagnostics; +using CommunityToolkit.Mvvm.DependencyInjection; +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using System.Runtime.InteropServices; +using System.Text.RegularExpressions; +using System.Threading.Tasks; + +namespace CollimationCircles.Services.Uvc +{ + /// + /// Detects UVC cameras on Windows by enumerating USB devices via SetupAPI + /// that match the USB Video Class (CC_VIDEO = 0x0E). Returns cameras with + /// APIType.Uvc, handled by UvcFrameSource (via libuvc) for both streaming + /// and control — the same code path as macOS and Linux. + /// + internal class UvcCameraDetectWindows() : ICameraDetect + { + private static readonly NLog.Logger logger = NLog.LogManager.GetCurrentClassLogger(); + + private const int DIGCF_PRESENT = 0x00000002; + private const int DIGCF_DEVICEINTERFACE = 0x00000010; + private const int DIGCF_ALLCLASSES = 0x00000004; + private const int SPDRP_HARDWAREID = 0x00000001; + private const int SPDRP_COMPATIBLEIDS = 0x00000002; + private const int SPDRP_FRIENDLYNAME = 0x0000000C; + private const int SPDRP_DEVICEDESC = 0x00000000; + private const int SPDRP_ENUMERATOR_NAME = 0x00000010; + private const int SPDRP_CLASS = 0x00000007; + private const int INVALID_HANDLE_VALUE = -1; + + // USB Video Class codes + private const int USB_CC_VIDEO = 0x0E; + private const int USB_SUBCLASS_VIDEO_CONTROL = 0x01; + private const int USB_SUBCLASS_VIDEO_STREAMING = 0x02; + + public Dictionary ControlMapping => new() + { + // Controls are enumerated at stream start by UvcFrameSource.EnumerateControls + // (via libuvc), so this mapping is unused. + }; + + public async Task> GetCameras() + { + List cameras = []; + + if (!OperatingSystem.IsWindows()) + { + logger.Debug("UvcCameraDetectWindows.GetCameras: skipped — not Windows"); + return cameras; + } + + logger.Info("UvcCameraDetectWindows.GetCameras: begin"); + + try + { + await Task.Run(() => DetectUvcCameras(cameras)); + } + catch (Exception ex) + { + logger.Error(ex, "Error while detecting UVC cameras on Windows"); + } + + logger.Info($"UvcCameraDetectWindows.GetCameras: returning {cameras.Count} camera(s)"); + return cameras; + } + + private void DetectUvcCameras(List cameras) + { + logger.Debug("UvcCameraDetectWindows.DetectUvcCameras: begin"); + + // Enumerate all USB devices + IntPtr devInfoSet = SetupDiGetClassDevs( + IntPtr.Zero, + "USB", + IntPtr.Zero, + DIGCF_PRESENT | DIGCF_ALLCLASSES); + + if (devInfoSet == IntPtr.Zero || devInfoSet == new IntPtr(INVALID_HANDLE_VALUE)) + { + int err = Marshal.GetLastWin32Error(); + logger.Warn($"UvcCameraDetectWindows: SetupDiGetClassDevs for USB devices failed (error={err})"); + return; + } + + logger.Debug("UvcCameraDetectWindows: SetupDiGetClassDevs succeeded"); + + try + { + int index = 0; + int totalDevices = 0; + int uvcDevices = 0; + var spi = new SP_DEVINFO_DATA(); + spi.cbSize = Marshal.SizeOf(spi); + + for (int memberIndex = 0; SetupDiEnumDeviceInfo(devInfoSet, memberIndex, ref spi); memberIndex++) + { + totalDevices++; + try + { + string? compatibleIds = GetDeviceStringProperty(devInfoSet, ref spi, SPDRP_COMPATIBLEIDS); + if (string.IsNullOrEmpty(compatibleIds)) + { + logger.Debug($"UvcCameraDetectWindows: device[{memberIndex}] has no compatible IDs, skipping"); + continue; + } + + // Check if this USB device matches the Video Class (UVC) + if (!IsUvcDevice(compatibleIds)) + { + logger.Debug($"UvcCameraDetectWindows: device[{memberIndex}] is not UVC (compatible IDs: '{compatibleIds.Replace("\0", "|")}')"); + continue; + } + + uvcDevices++; + logger.Debug($"UvcCameraDetectWindows: device[{memberIndex}] is UVC (compatible IDs: '{compatibleIds.Replace("\0", "|")}')"); + + // Get VID/PID from hardware ID + string? hardwareId = GetDeviceStringProperty(devInfoSet, ref spi, SPDRP_HARDWAREID); + if (string.IsNullOrEmpty(hardwareId)) + { + logger.Debug($"UvcCameraDetectWindows: UVC device[{memberIndex}] has no hardware ID, skipping"); + continue; + } + + logger.Debug($"UvcCameraDetectWindows: UVC device[{memberIndex}] hardware ID: '{hardwareId.Replace("\0", "|")}'"); + + int vendorId = 0; + int productId = 0; + if (!TryExtractVidPid(hardwareId, out vendorId, out productId)) + { + logger.Debug($"UvcCameraDetectWindows: could not extract VID/PID from '{hardwareId.Replace("\0", "|")}'"); + continue; + } + + logger.Debug($"UvcCameraDetectWindows: UVC device[{memberIndex}] VID={vendorId} PID={productId}"); + + // Get the device name + string? deviceName = GetDeviceStringProperty(devInfoSet, ref spi, SPDRP_FRIENDLYNAME); + if (string.IsNullOrWhiteSpace(deviceName)) + { + deviceName = GetDeviceStringProperty(devInfoSet, ref spi, SPDRP_DEVICEDESC); + } + if (string.IsNullOrWhiteSpace(deviceName)) + { + // Fall back to something readable from the hardware ID + deviceName = $"UVC Camera ({vendorId:X4}:{productId:X4})"; + logger.Debug($"UvcCameraDetectWindows: no friendly name, using fallback '{deviceName}'"); + } + + // Get the device instance ID for the path + string? instanceId = GetDeviceInstanceId(devInfoSet, ref spi); + string path = instanceId ?? $"\\\\?\\usb#vid_{vendorId:X4}&pid_{productId:X4}"; + logger.Debug($"UvcCameraDetectWindows: device path = '{path}'"); + + // Avoid exact duplicates (same VID/PID) + if (cameras.Any(c => c.VendorId == vendorId && c.ProductId == productId)) + { + logger.Debug($"UvcCameraDetectWindows: skipping duplicate VID={vendorId} PID={productId}"); + continue; + } + + Camera camera = new() + { + Index = index++, + APIType = APIType.Uvc, + Name = deviceName, + Path = path, + VendorId = vendorId, + ProductId = productId + }; + + camera.Controls = []; + cameras.Add(camera); + logger.Info($"UvcCameraDetectWindows: added UVC camera '{camera.Name}' (VID={vendorId} PID={productId})"); + } + catch (Exception ex) + { + logger.Warn(ex, $"UvcCameraDetectWindows: error processing USB device at index {memberIndex}"); + } + } + + logger.Info($"UvcCameraDetectWindows: scanned {totalDevices} USB devices, found {uvcDevices} UVC, added {cameras.Count} camera(s)"); + } + finally + { + SetupDiDestroyDeviceInfoList(devInfoSet); + logger.Debug("UvcCameraDetectWindows: SetupDiDestroyDeviceInfoList called"); + } + } + + private static bool IsUvcDevice(string compatibleIds) + { + // Check for USB Video Class identifiers in the compatible IDs. + // Windows reports compatible IDs in several formats: + // USB\Class_0E (standard) + // USB\Class_0E&SubClass_01 (standard) + // USB\COMPAT_VID_203a&Class_0e&SubClass_03&Prot_00 (vendor-specific) + // USB\COMPAT_VID_EBA4&Class_0e&SubClass_03&Prot_00 (vendor-specific) + string[] ids = compatibleIds.Split('\0', StringSplitOptions.RemoveEmptyEntries); + bool isUvc = ids.Any(id => + id.StartsWith("USB\\Class_0E", StringComparison.OrdinalIgnoreCase) || + id.Contains("&Class_0e", StringComparison.OrdinalIgnoreCase) || + id.Contains("USB_CC_VIDEO", StringComparison.OrdinalIgnoreCase)); + return isUvc; + } + + /// + /// Extracts VID and PID from a USB hardware ID string. + /// Accepts formats like: + /// USB\VID_046D&PID_082D + /// USB\VID_046D&PID_082D&REV_0100 + /// + private static bool TryExtractVidPid(string hardwareId, out int vendorId, out int productId) + { + vendorId = 0; + productId = 0; + + if (string.IsNullOrWhiteSpace(hardwareId)) + return false; + + // Split on null chars and take the first non-empty line + string firstLine = hardwareId.Split('\0', StringSplitOptions.RemoveEmptyEntries).FirstOrDefault() ?? hardwareId; + + var match = Regex.Match(firstLine, + @"VID[_=](\w{4})[^0-9A-Fa-f]?PID[_=](\w{4})", + RegexOptions.IgnoreCase); + + if (!match.Success) + return false; + + vendorId = int.Parse(match.Groups[1].Value, NumberStyles.HexNumber, CultureInfo.InvariantCulture); + productId = int.Parse(match.Groups[2].Value, NumberStyles.HexNumber, CultureInfo.InvariantCulture); + + return vendorId > 0 && productId > 0; + } + + private static string? GetDeviceStringProperty(IntPtr devInfoSet, ref SP_DEVINFO_DATA devInfoData, int property) + { + // Get the required buffer size first + if (!SetupDiGetDeviceRegistryProperty(devInfoSet, ref devInfoData, property, out int regType, IntPtr.Zero, 0, out int requiredSize)) + { + int err = Marshal.GetLastWin32Error(); + if (err != 122) // ERROR_INSUFFICIENT_BUFFER + return null; + } + + if (requiredSize <= 0) + return null; + + IntPtr buffer = Marshal.AllocHGlobal(requiredSize); + try + { + if (!SetupDiGetDeviceRegistryProperty(devInfoSet, ref devInfoData, property, out regType, buffer, requiredSize, out _)) + return null; + + string result = Marshal.PtrToStringAuto(buffer) ?? string.Empty; + return result; + } + finally + { + Marshal.FreeHGlobal(buffer); + } + } + + private static string? GetDeviceInstanceId(IntPtr devInfoSet, ref SP_DEVINFO_DATA devInfoData) + { + if (!SetupDiGetDeviceInstanceId(devInfoSet, ref devInfoData, IntPtr.Zero, 0, out int requiredSize)) + { + int err = Marshal.GetLastWin32Error(); + if (err != 122) // ERROR_INSUFFICIENT_BUFFER + return null; + } + + if (requiredSize <= 0) + return null; + + IntPtr buffer = Marshal.AllocHGlobal(requiredSize * 2); + try + { + if (!SetupDiGetDeviceInstanceId(devInfoSet, ref devInfoData, buffer, requiredSize, out _)) + return null; + + return Marshal.PtrToStringAuto(buffer); + } + finally + { + Marshal.FreeHGlobal(buffer); + } + } + + public async Task> GetControls(Camera camera) + { + Guard.IsNotNull(camera); + + // UVC controls are enumerated at stream start by UvcFrameSource.EnumerateControls + // (via libuvc). Return empty list — placeholders will be replaced on Play. + logger.Info($"UVC camera '{camera.Name}' (VID={camera.VendorId} PID={camera.ProductId}) — controls will be enumerated on stream start"); + return await Task.FromResult(new List()); + } + + public void SetControl(Camera camera, ControlType controlType, double value) + { + if (camera.APIType is APIType.Uvc) + { + try + { + logger.Info($"Windows UVC set request: camera='{camera.Name}', control={controlType}, value={value}"); + var uvcFrameSource = Ioc.Default.GetRequiredService(); + bool ok = uvcFrameSource.SetControl(controlType.ToString(), (long)value); + if (!ok) + { + logger.Warn($"Failed to set UVC control {controlType}={value} on '{camera.Name}'"); + } + else + { + logger.Info($"Windows UVC set request completed: camera='{camera.Name}', control={controlType}, value={value}"); + } + } + catch (Exception ex) + { + logger.Error(ex, $"Error setting UVC control {controlType} on '{camera.Name}'"); + } + } + } + + public void SetControlAuto(Camera camera, ControlType controlType, bool isAuto) + { + if (camera.APIType is APIType.Uvc) + { + try + { + logger.Info($"Windows UVC auto set request: camera='{camera.Name}', control={controlType}, isAuto={isAuto}"); + var uvcFrameSource = Ioc.Default.GetRequiredService(); + + string autoName = controlType switch + { + ControlType.ExposureTime => "AutoExposure", + ControlType.FocusAbsolute => "AutoFocus", + ControlType.WhiteBalance => "AutoWhiteBalance", + ControlType.Hue => "HueAuto", + ControlType.Contrast => "ContrastAuto", + _ => string.Empty + }; + + if (!string.IsNullOrEmpty(autoName)) + { + bool ok = uvcFrameSource.SetAutoControl(autoName, isAuto); + if (!ok) + { + logger.Warn($"Failed to set UVC auto control {controlType}={isAuto} on '{camera.Name}'"); + } + else + { + logger.Info($"Windows UVC auto set request completed: camera='{camera.Name}', control={controlType}, isAuto={isAuto}"); + } + } + else + { + logger.Warn($"No UVC auto-control mapping for {controlType} on '{camera.Name}'"); + } + } + catch (Exception ex) + { + logger.Error(ex, $"Error setting UVC auto control {controlType} on '{camera.Name}'"); + } + } + } + + public List GetCommandLineParameters(Camera camera, ICommandBuilder? builder) + { + Guard.IsNotNull(camera); + return []; + } + + // ------------------------------------------------------------------- + // SetupAPI P/Invoke + // ------------------------------------------------------------------- + + [DllImport("setupapi.dll", SetLastError = true, CharSet = CharSet.Auto)] + private static extern IntPtr SetupDiGetClassDevs( + IntPtr classGuid, // null = all classes + [MarshalAs(UnmanagedType.LPTStr)] string? enumerator, + IntPtr hwndParent, + int flags); + + [DllImport("setupapi.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool SetupDiEnumDeviceInfo( + IntPtr deviceInfoSet, + int memberIndex, + ref SP_DEVINFO_DATA deviceInfoData); + + [DllImport("setupapi.dll", SetLastError = true, CharSet = CharSet.Auto)] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool SetupDiGetDeviceRegistryProperty( + IntPtr deviceInfoSet, + ref SP_DEVINFO_DATA deviceInfoData, + int property, + out int propertyRegDataType, + IntPtr propertyBuffer, + int propertyBufferSize, + out int requiredSize); + + [DllImport("setupapi.dll", SetLastError = true, CharSet = CharSet.Auto)] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool SetupDiGetDeviceInstanceId( + IntPtr deviceInfoSet, + ref SP_DEVINFO_DATA deviceInfoData, + IntPtr deviceInstanceId, + int deviceInstanceIdSize, + out int requiredSize); + + [DllImport("setupapi.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool SetupDiDestroyDeviceInfoList(IntPtr deviceInfoSet); + + [StructLayout(LayoutKind.Sequential)] + private struct SP_DEVINFO_DATA + { + public int cbSize; + public Guid classGuid; + public int devInst; + public IntPtr reserved; + } + } +} \ No newline at end of file diff --git a/CollimationCircles/StartupOptions.cs b/CollimationCircles/StartupOptions.cs index 2d9a62f..298ef81 100644 --- a/CollimationCircles/StartupOptions.cs +++ b/CollimationCircles/StartupOptions.cs @@ -34,11 +34,19 @@ internal static class StartupOptions /// public static (int VendorId, int ProductId)? RecoverUvcVidPid { get; private set; } + /// + /// Debug mode for UVC camera detection. + /// Command: --debug-uvc + /// Will run camera detection and exit without launching the UI. + /// + public static bool DebugUvc { get; private set; } + public static void Initialize(string[] args) { AutoConnectCameraName = null; AutoConnectCameraVidPid = null; RecoverUvcVidPid = null; + DebugUvc = false; if (args is null || args.Length == 0) { @@ -49,6 +57,13 @@ public static void Initialize(string[] args) { string arg = args[i]; + // --debug-uvc + if (string.Equals(arg, "--debug-uvc", StringComparison.OrdinalIgnoreCase)) + { + DebugUvc = true; + continue; + } + // --camera if (string.Equals(arg, "--camera", StringComparison.OrdinalIgnoreCase)) {