From a757bd320c15efee187623aba6485b9eb8eacc36 Mon Sep 17 00:00:00 2001 From: Mathieu Carbou Date: Fri, 3 Jul 2026 23:38:03 +0200 Subject: [PATCH 1/4] feat: add cross-platform libuvc UVC camera support (Linux + Windows) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduce libuvc-based UVC camera detection and control on Linux and Windows, alongside the existing V4l2 and DShow paths (kept for testing). New files: - Services/LinuxCameraDetect.cs — scans /sys/class/video4linux/, walks sysfs to find USB idVendor/idProduct, returns APIType.Uvc cameras - Services/WindowsUvcCameraDetect.cs — uses SetupAPI to enumerate USB Video Class (CC_VIDEO=0x0E) devices, extracts VID/PID from hardware IDs - Libraries/libusb/win/{x64,arm64}/build.sh — download libusb-1.0.dll via vcpkg at build time Changes: - CameraControlService: dispatch Set/SetAuto/GetCameraList to the new LinuxCameraDetect or WindowsUvcCameraDetect for APIType.Uvc cameras - UvcFrameSource.OpenDevice: guard macOS IOKit kernel driver detach with OperatingSystem.IsMacOS(); skip it on Linux/Windows - UvcFrameSource.CloseDevice, TryRecoverUvcDevice: macOS-only kernel driver restore - CollimationCircles.csproj: add CopyLibUsbWin, CopyLibUvcWin, CopyLibUvcLinux MSBuild targets - .github/workflows/build-and-release.yml: copy libusb + libuvc binaries on Windows and Linux during publish refactor: standardize UVC file naming and extract macOS UVC detection - Rename LinuxCameraDetect.cs → UvcCameraDetectLinux.cs - Rename WindowsUvcCameraDetect.cs → UvcCameraDetectWindows.cs - Create UvcCameraDetectMac.cs in Services/Uvc/ with UVC-specific detection (system_profiler VID/PID parsing) and control routing - Update MacOSCameraDetect.cs to only handle QTCapture cameras (UVC cameras now detected by UvcCameraDetectMac) - Update CameraControlService.cs to use new class names and add macOS UVC detection via UvcCameraDetectMac --- .github/workflows/build-and-release.yml | 10 + CollimationCircles/CollimationCircles.csproj | 48 +++ .../Services/CameraControlService.cs | 71 +++- .../Services/MacOSCameraDetect.cs | 85 +--- .../Services/Uvc/UvcCameraDetectLinux.cs | 293 +++++++++++++ .../Services/Uvc/UvcCameraDetectMac.cs | 260 ++++++++++++ .../Services/Uvc/UvcCameraDetectWindows.cs | 390 ++++++++++++++++++ .../Services/Uvc/UvcFrameSource.cs | 62 +-- 8 files changed, 1098 insertions(+), 121 deletions(-) create mode 100644 CollimationCircles/Services/Uvc/UvcCameraDetectLinux.cs create mode 100644 CollimationCircles/Services/Uvc/UvcCameraDetectMac.cs create mode 100644 CollimationCircles/Services/Uvc/UvcCameraDetectWindows.cs 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/Services/CameraControlService.cs b/CollimationCircles/Services/CameraControlService.cs index 35b2c3d..cef679b 100644 --- a/CollimationCircles/Services/CameraControlService.cs +++ b/CollimationCircles/Services/CameraControlService.cs @@ -1,4 +1,5 @@ using CollimationCircles.Models; +using CollimationCircles.Services.Uvc; using CollimationCircles.Services.Zwo; using CommunityToolkit.Diagnostics; using System; @@ -18,32 +19,48 @@ public void Set(ControlType controlName, double value, Camera camera) logger.Info($"Dispatching camera control set: camera='{camera.Name}', api={camera.APIType}, control={controlName}, value={value}"); - // set camera control for V4L2 (Linux cameras) - if (camera.APIType is APIType.V4l2) + // UVC camera controls (Linux/Windows/macOS) + if (camera.APIType is APIType.Uvc) { - new V4L2CameraDetect().SetControl(camera, controlName, value); + if (OperatingSystem.IsLinux()) + { + new UvcCameraDetectLinux().SetControl(camera, controlName, value); + } + else if (OperatingSystem.IsWindows()) + { + new UvcCameraDetectWindows().SetControl(camera, controlName, value); + } + else if (OperatingSystem.IsMacOS()) + { + new UvcCameraDetectMac().SetControl(camera, controlName, value); + } } - // set camera control for ZWO astro cameras (Windows/macOS) + + // ZWO camera controls (Linux/Windows/macOS) else if (camera.APIType is APIType.Zwo) { new ZWOCameraDetect().SetControl(camera, controlName, value); } - // set camera control for macOS UVC cameras (IOKit + libusb) - else if (camera.APIType is APIType.Uvc) + + // V4L2 camera controls (Linux cameras) + else if (camera.APIType is APIType.V4l2) { - new MacOSCameraDetect().SetControl(camera, controlName, value); + new V4L2CameraDetect().SetControl(camera, controlName, value); } - // set camera control for macOS system cameras (AVFoundation/QTCapture fallback) + + // macOS system cameras (AVFoundation/QTCapture fallback) else if (camera.APIType is APIType.QTCapture) { new MacOSCameraDetect().SetControl(camera, controlName, value); } - // set camera control for DirectShow (Windows) + + // DirectShow cameras (Windows) else if (camera.APIType is APIType.Dshow) { new DShowCameraDetect().SetControl(camera, controlName, value); } - // set camera control for Raspberry PI Camera + + // Raspberry Pi cameras (Linux) else if (camera.APIType is APIType.LibCamera) { new RasPiCameraDetect().SetControl(camera, controlName, value); @@ -56,13 +73,27 @@ public void SetAuto(ControlType controlName, bool isAuto, Camera camera) logger.Info($"Dispatching camera auto-control set: camera='{camera.Name}', api={camera.APIType}, control={controlName}, isAuto={isAuto}, isPlaying={camera.IsPlaying}"); - if (camera.APIType is APIType.Zwo) + // UVC camera auto-controls (Linux/Windows/macOS) + if (camera.APIType is APIType.Uvc) { - new ZWOCameraDetect().SetControlAuto(camera, controlName, isAuto); + if (OperatingSystem.IsLinux()) + { + new UvcCameraDetectLinux().SetControlAuto(camera, controlName, isAuto); + } + else if (OperatingSystem.IsWindows()) + { + new UvcCameraDetectWindows().SetControlAuto(camera, controlName, isAuto); + } + else if (OperatingSystem.IsMacOS()) + { + new UvcCameraDetectMac().SetControlAuto(camera, controlName, isAuto); + } } - else if (camera.APIType is APIType.Uvc) + + // ZWO camera auto-controls (Linux/Windows/macOS) + else if (camera.APIType is APIType.Zwo) { - new MacOSCameraDetect().SetControlAuto(camera, controlName, isAuto); + new ZWOCameraDetect().SetControlAuto(camera, controlName, isAuto); } } @@ -74,12 +105,20 @@ 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()) { var macosCameras = await new MacOSCameraDetect().GetCameras(); cameras.AddRange(macosCameras); + // Also detect UVC cameras via libuvc on macOS + var macosUvcCameras = await new UvcCameraDetectMac().GetCameras(); + cameras.AddRange(macosUvcCameras); + var raspiCameras = await new RasPiCameraDetect().GetCameras(); cameras.AddRange(raspiCameras); @@ -93,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/MacOSCameraDetect.cs b/CollimationCircles/Services/MacOSCameraDetect.cs index 97fa50a..1d8e531 100644 --- a/CollimationCircles/Services/MacOSCameraDetect.cs +++ b/CollimationCircles/Services/MacOSCameraDetect.cs @@ -1,7 +1,5 @@ using CollimationCircles.Models; -using CollimationCircles.Services.Uvc; using CommunityToolkit.Diagnostics; -using CommunityToolkit.Mvvm.DependencyInjection; using System; using System.Collections.Generic; using System.Globalization; @@ -122,9 +120,10 @@ private static IEnumerable ParseSystemProfilerCameras(string json, int s _ = TryExtractVidPid(modelId, out vendorId, out productId); } - // Use Uvc APIType for cameras with vendor/product IDs (real UVC cameras) - // Fall back to QTCapture for built-in/virtual cameras without UVC IDs - APIType apiType = (vendorId > 0 && productId > 0) ? APIType.Uvc : APIType.QTCapture; + // Use QTCapture for all system_profiler cameras. + // UVC cameras (with vendor/product IDs) are now detected by + // UvcCameraDetectMac in Services/Uvc/. + APIType apiType = APIType.QTCapture; yield return new Camera { @@ -333,15 +332,6 @@ public async Task> GetControls(Camera camera) List controls = []; - // For UVC cameras with vendor/product IDs, controls are enumerated at - // stream start by UvcFrameSource.EnumerateControls (via libuvc). - // Return empty list here — placeholders will be replaced on Play. - if (camera.APIType is APIType.Uvc && camera.VendorId > 0 && camera.ProductId > 0) - { - logger.Info($"UVC camera '{camera.Name}' (VID={camera.VendorId} PID={camera.ProductId}) — controls will be enumerated on stream start"); - return controls; - } - // QTCapture cameras: discover controls via AVFoundation (Swift script) if (!OperatingSystem.IsMacOS() || camera.APIType is not APIType.QTCapture) { @@ -501,31 +491,6 @@ public List GetCommandLineParameters(Camera camera, ICommandBuilder? bui public void SetControl(Camera camera, ControlType controlType, double value) { - // For UVC cameras, control setting is handled via UvcFrameSource - // which has the device open during streaming. - if (camera.APIType is APIType.Uvc) - { - try - { - logger.Info($"macOS 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($"macOS 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}'"); - } - return; - } - // QTCapture: AVFoundation mode-only controls (numeric set is not supported) if (!OperatingSystem.IsMacOS() || camera.APIType is not APIType.QTCapture) { @@ -553,48 +518,6 @@ public void SetControl(Camera camera, ControlType controlType, double value) public void SetControlAuto(Camera camera, ControlType controlType, bool isAuto) { - // UVC cameras: route to UvcFrameSource (libuvc) - if (camera.APIType is APIType.Uvc) - { - try - { - logger.Info($"macOS 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($"macOS 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}'"); - } - return; - } - // QTCapture: set auto mode via AVFoundation Swift script if (!OperatingSystem.IsMacOS() || camera.APIType is not APIType.QTCapture) { diff --git a/CollimationCircles/Services/Uvc/UvcCameraDetectLinux.cs b/CollimationCircles/Services/Uvc/UvcCameraDetectLinux.cs new file mode 100644 index 0000000..125f1e8 --- /dev/null +++ b/CollimationCircles/Services/Uvc/UvcCameraDetectLinux.cs @@ -0,0 +1,293 @@ +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()) + { + return cameras; + } + + try + { + await Task.Run(() => DetectUvcCameras(cameras)); + } + catch (Exception ex) + { + logger.Error(ex, "Error while detecting UVC cameras on Linux"); + } + + return cameras; + } + + private void DetectUvcCameras(List cameras) + { + string videoDevicesDir = "/sys/class/video4linux"; + if (!Directory.Exists(videoDevicesDir)) + { + logger.Warn($"/sys/class/video4linux not found — no video devices detected"); + return; + } + + string[] videoDeviceDirs = Directory.GetDirectories(videoDevicesDir); + logger.Info($"Found {videoDeviceDirs.Length} video device(s) in {videoDevicesDir}"); + + int index = 0; + + foreach (string deviceDir in videoDeviceDirs) + { + try + { + // Resolve the device symlink to a real path + string realPath = ResolveSymlink(deviceDir); + if (string.IsNullOrEmpty(realPath)) + { + continue; + } + + // 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; + } + + // 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) + { + 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()); + } + } + + // 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($"Added UVC camera: '{camera.Name}' (VID={vendorId} PID={productId}) at {devPath}"); + } + else + { + logger.Debug($"Skipping non-USB video device '{name}' at {devPath}"); + } + } + catch (Exception ex) + { + logger.Warn(ex, $"Error processing video device '{deviceDir}'"); + } + } + + logger.Info($"Detected {cameras.Count} UVC camera(s) on Linux"); + } + + /// + /// 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/UvcCameraDetectMac.cs b/CollimationCircles/Services/Uvc/UvcCameraDetectMac.cs new file mode 100644 index 0000000..eed6994 --- /dev/null +++ b/CollimationCircles/Services/Uvc/UvcCameraDetectMac.cs @@ -0,0 +1,260 @@ +using CollimationCircles.Models; +using CommunityToolkit.Diagnostics; +using CommunityToolkit.Mvvm.DependencyInjection; +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using System.Text.Json; +using System.Text.RegularExpressions; +using System.Threading.Tasks; + +namespace CollimationCircles.Services.Uvc +{ + /// + /// Detects UVC cameras on macOS by parsing system_profiler SPCameraDataType + /// output and extracting vendor/product IDs from the model-id string. + /// Returns cameras with APIType.Uvc, handled by UvcFrameSource (via libuvc) + /// for both streaming and control — the same code path as Linux and Windows. + /// + internal class UvcCameraDetectMac : 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.IsMacOS()) + { + return cameras; + } + + try + { + var (errorCode, result) = await AppService.StartProcessAsync( + "system_profiler", + ["SPCameraDataType", "-json"]); + + logger.Info($"system_profiler SPCameraDataType -json exit code: {errorCode}"); + + if (errorCode == 0) + { + int addedCount = 0; + + foreach (Camera camera in ParseSystemProfilerCameras(result, cameras.Count)) + { + camera.Controls = await GetControls(camera); + cameras.Add(camera); + logger.Info($"Added UVC camera: '{camera.Name}'"); + addedCount++; + } + + logger.Info($"Parsed {addedCount} UVC cameras from system_profiler"); + } + else + { + logger.Warn("system_profiler SPCameraDataType -json returned a non-zero exit code"); + } + } + catch (Exception ex) + { + logger.Error(ex, "Error while detecting UVC cameras on macOS"); + } + + return cameras; + } + + private static IEnumerable ParseSystemProfilerCameras(string json, int startIndex) + { + using JsonDocument document = JsonDocument.Parse(json); + + if (!document.RootElement.TryGetProperty("SPCameraDataType", out JsonElement camerasElement) || + camerasElement.ValueKind != JsonValueKind.Array) + { + yield break; + } + + int index = startIndex; + + foreach (JsonElement cameraElement in camerasElement.EnumerateArray()) + { + string? name = TryGetString(cameraElement, "_name"); + string? uniqueId = TryGetString(cameraElement, "spcamera_unique-id"); + string? modelId = TryGetString(cameraElement, "spcamera_model-id"); + + if (string.IsNullOrWhiteSpace(name) || string.IsNullOrWhiteSpace(uniqueId)) + continue; + + // Parse vendor/product IDs from model-id string like: + // "UVC Camera VendorID_60324 ProductID_4867" + int vendorId = 0; + int productId = 0; + + if (!string.IsNullOrWhiteSpace(modelId)) + { + _ = TryExtractVidPid(modelId, out vendorId, out productId); + } + + // Only yield cameras with valid vendor/product IDs (real UVC cameras) + if (vendorId <= 0 || productId <= 0) + continue; + + yield return new Camera + { + Index = index++, + APIType = APIType.Uvc, + Name = name, + Path = uniqueId.Trim(), + VendorId = vendorId, + ProductId = productId + }; + } + } + + private static bool TryExtractVidPid(string source, out int vendorId, out int productId) + { + vendorId = 0; + productId = 0; + + if (string.IsNullOrWhiteSpace(source)) + return false; + + var vidMatch = Regex.Match( + source, + @"(?:Vendor\s*ID|VendorID|VID)\s*[_:=-]?\s*(0x[0-9A-Fa-f]+|\d+)", + RegexOptions.IgnoreCase); + var pidMatch = Regex.Match( + source, + @"(?:Product\s*ID|ProductID|PID)\s*[_:=-]?\s*(0x[0-9A-Fa-f]+|\d+)", + RegexOptions.IgnoreCase); + + if (!vidMatch.Success || !pidMatch.Success) + return false; + + if (!TryParseDeviceId(vidMatch.Groups[1].Value, out vendorId)) + return false; + + if (!TryParseDeviceId(pidMatch.Groups[1].Value, out productId)) + return false; + + return vendorId > 0 && productId > 0; + } + + private static bool TryParseDeviceId(string value, out int id) + { + id = 0; + + if (string.IsNullOrWhiteSpace(value)) + return false; + + value = value.Trim(); + + if (value.StartsWith("0x", StringComparison.OrdinalIgnoreCase)) + return int.TryParse(value[2..], NumberStyles.HexNumber, CultureInfo.InvariantCulture, out id); + + return int.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out id); + } + + private static string? TryGetString(JsonElement element, string propertyName) + { + if (!element.TryGetProperty(propertyName, out JsonElement propertyElement) || + propertyElement.ValueKind != JsonValueKind.String) + { + return null; + } + + return propertyElement.GetString(); + } + + 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($"macOS 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($"macOS 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($"macOS 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($"macOS 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..c793661 --- /dev/null +++ b/CollimationCircles/Services/Uvc/UvcCameraDetectWindows.cs @@ -0,0 +1,390 @@ +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()) + { + return cameras; + } + + try + { + await Task.Run(() => DetectUvcCameras(cameras)); + } + catch (Exception ex) + { + logger.Error(ex, "Error while detecting UVC cameras on Windows"); + } + + return cameras; + } + + private void DetectUvcCameras(List cameras) + { + // 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)) + { + logger.Warn("SetupDiGetClassDevs for USB devices failed"); + return; + } + + try + { + int index = 0; + var spi = new SP_DEVINFO_DATA(); + spi.cbSize = Marshal.SizeOf(spi); + + for (int memberIndex = 0; SetupDiEnumDeviceInfo(devInfoSet, memberIndex, ref spi); memberIndex++) + { + try + { + string? compatibleIds = GetDeviceStringProperty(devInfoSet, ref spi, SPDRP_COMPATIBLEIDS); + if (string.IsNullOrEmpty(compatibleIds)) + continue; + + // Check if this USB device matches the Video Class (UVC) + if (!IsUvcDevice(compatibleIds)) + continue; + + // Get VID/PID from hardware ID + string? hardwareId = GetDeviceStringProperty(devInfoSet, ref spi, SPDRP_HARDWAREID); + if (string.IsNullOrEmpty(hardwareId)) + continue; + + int vendorId = 0; + int productId = 0; + if (!TryExtractVidPid(hardwareId, out vendorId, out productId)) + continue; + + // 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})"; + } + + // Get the device instance ID for the path + string? instanceId = GetDeviceInstanceId(devInfoSet, ref spi); + string path = instanceId ?? $"\\\\?\\usb#vid_{vendorId:X4}&pid_{productId:X4}"; + + // Avoid exact duplicates (same VID/PID) + if (cameras.Any(c => c.VendorId == vendorId && c.ProductId == 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($"Added Windows UVC camera: '{camera.Name}' (VID={vendorId} PID={productId})"); + } + catch (Exception ex) + { + logger.Warn(ex, $"Error processing USB device at index {memberIndex}"); + } + } + + logger.Info($"Detected {cameras.Count} UVC camera(s) on Windows"); + } + finally + { + SetupDiDestroyDeviceInfoList(devInfoSet); + } + } + + private static bool IsUvcDevice(string compatibleIds) + { + // Check for USB Video Class identifiers in the compatible IDs + // Format examples: + // USB\Class_0E + // USB\Class_0E&SubClass_01 + // USB\Class_0E&SubClass_02 + string[] ids = compatibleIds.Split('\0', StringSplitOptions.RemoveEmptyEntries); + return ids.Any(id => + id.StartsWith("USB\\Class_0E", StringComparison.OrdinalIgnoreCase) || + id.Contains("USB_CC_VIDEO", StringComparison.OrdinalIgnoreCase)); + } + + /// + /// 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/Services/Uvc/UvcFrameSource.cs b/CollimationCircles/Services/Uvc/UvcFrameSource.cs index d88b683..87b26d9 100644 --- a/CollimationCircles/Services/Uvc/UvcFrameSource.cs +++ b/CollimationCircles/Services/Uvc/UvcFrameSource.cs @@ -150,34 +150,37 @@ private bool OpenDevice(int vendorId, int productId) _lastVendorId = vendorId; _lastProductId = productId; - // Step 1: IOKit SetConfiguration(0) to detach the macOS kernel UVC driver. - // libuvc's libusb_detach_kernel_driver is NOT sufficient on macOS — - // the kernel driver is an IOKit service, not a libusb module. - // SetConfiguration(0) unconfigures the device at the IOKit level, - // which releases the kernel driver from all interfaces. - logger.Debug("OpenDevice: step 1 — IOKit SetConfiguration(0) to detach kernel driver"); - int targetConfig = IokitHelper.SetConfiguration(vendorId, productId, 0); - logger.Debug($"OpenDevice: IokitSetConfiguration(0) returned targetConfig={targetConfig}"); - if (targetConfig == 0) - { - logger.Error("IOKit SetConfiguration(0) failed — cannot detach kernel driver"); - return false; - } - _restoreConfig = targetConfig; + // On macOS, the kernel UVC driver is an IOKit service, not a libusb module. + // libuvc's libusb_detach_kernel_driver is NOT sufficient — we must detach + // the driver via IOKit's SetConfiguration(0) first. + // On Linux, libuvc's libusb_detach_kernel_driver works natively. + if (OperatingSystem.IsMacOS()) + { + logger.Debug("OpenDevice: macOS — IOKit SetConfiguration(0) to detach kernel driver"); + int targetConfig = IokitHelper.SetConfiguration(vendorId, productId, 0); + logger.Debug($"OpenDevice: IokitSetConfiguration(0) returned targetConfig={targetConfig}"); + if (targetConfig == 0) + { + logger.Error("IOKit SetConfiguration(0) failed — cannot detach kernel driver"); + return false; + } + _restoreConfig = targetConfig; - // Brief wait for kernel driver to release interfaces before the retry loop - System.Threading.Thread.Sleep(100); + // Brief wait for kernel driver to release interfaces + System.Threading.Thread.Sleep(100); + } - // Step 2: libuvc init + find + open with retry loop. + // libuvc init + find + open with retry loop. // On macOS there's a race condition: after IOKit SetConfiguration(0) // detaches the kernel driver, the driver can re-attach before // libuvc claims the interface. We retry the IOKit detach + uvc_open - // sequence up to 20 times (matching the old code's strategy). + // sequence up to 20 times. On Linux, retries are rarely needed. int ret = LibUvc.uvc_init(out _ctx, IntPtr.Zero); if (ret != LibUvc.UVC_SUCCESS) { logger.Error($"uvc_init failed: {LibUvc.ErrorName(ret)}"); - IokitHelper.RestoreKernelDriver(vendorId, productId); + if (OperatingSystem.IsMacOS()) + IokitHelper.RestoreKernelDriver(vendorId, productId); return false; } logger.Debug($"OpenDevice: uvc_init success, ctx={_ctx}"); @@ -185,8 +188,8 @@ private bool OpenDevice(int vendorId, int productId) bool opened = false; for (int attempt = 0; attempt < 20; attempt++) { - // Re-detach kernel driver on each attempt (the driver may have re-attached) - if (attempt > 0) + // On macOS, re-detach kernel driver on each attempt (the driver may have re-attached) + if (attempt > 0 && OperatingSystem.IsMacOS()) { logger.Debug($"OpenDevice: attempt {attempt} — re-detaching kernel driver via IOKit SetConfiguration(0)"); IokitHelper.SetConfiguration(vendorId, productId, 0); @@ -220,7 +223,8 @@ private bool OpenDevice(int vendorId, int productId) logger.Error($"uvc_open failed after 20 attempts: {LibUvc.ErrorName(ret)}"); LibUvc.uvc_exit(_ctx); _ctx = IntPtr.Zero; - IokitHelper.RestoreKernelDriver(vendorId, productId); + if (OperatingSystem.IsMacOS()) + IokitHelper.RestoreKernelDriver(vendorId, productId); return false; } @@ -247,9 +251,9 @@ private void CloseDevice() _ctx = IntPtr.Zero; } - // Restore the kernel driver so the camera works normally again - // (e.g. in AVFoundation / FaceTime / other apps) - if (_lastVendorId > 0 && _lastProductId > 0) + // Restore the kernel driver on macOS so the camera works normally again + // (e.g. in AVFoundation / FaceTime / other apps). Not needed on Linux. + if (_lastVendorId > 0 && _lastProductId > 0 && OperatingSystem.IsMacOS()) { IokitHelper.RestoreKernelDriver(_lastVendorId, _lastProductId); } @@ -835,14 +839,20 @@ private void OnFrameCallback(IntPtr framePtr, IntPtr userPtr) // ------------------------------------------------------------------- /// - /// Restores the macOS kernel UVC driver by re-activating the device + /// Restores the kernel UVC driver by re-activating the device /// configuration via IOKit SetConfiguration(1). Useful after a crash /// that left the device detached from the kernel driver. + /// Only applies on macOS where IOKit-based detachment is used. /// internal static bool TryRecoverUvcDevice(int vendorId, int productId) { if (vendorId <= 0 || productId <= 0) return false; + if (!OperatingSystem.IsMacOS()) + { + return false; + } + try { logger.Info($"TryRecoverUvcDevice: VID={vendorId} PID={productId}"); From f2e27330b71051ddc7e77d4c693e8ff5306bea0e Mon Sep 17 00:00:00 2001 From: Simon Date: Sat, 4 Jul 2026 10:59:26 +0200 Subject: [PATCH 2/4] Fix for detecting UVC camera on Windows --- CollimationCircles/Program.cs | 48 +++++++++++ .../Services/Uvc/UvcCameraDetectWindows.cs | 79 +++++++++++++++++-- CollimationCircles/StartupOptions.cs | 15 ++++ 3 files changed, 135 insertions(+), 7 deletions(-) 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/Uvc/UvcCameraDetectWindows.cs b/CollimationCircles/Services/Uvc/UvcCameraDetectWindows.cs index c793661..adb5170 100644 --- a/CollimationCircles/Services/Uvc/UvcCameraDetectWindows.cs +++ b/CollimationCircles/Services/Uvc/UvcCameraDetectWindows.cs @@ -66,6 +66,8 @@ public async Task> GetCameras() private void DetectUvcCameras(List cameras) { + logger.Debug("Starting UVC camera detection..."); + // Enumerate all USB devices IntPtr devInfoSet = SetupDiGetClassDevs( IntPtr.Zero, @@ -82,30 +84,54 @@ private void DetectUvcCameras(List cameras) try { int index = 0; + int totalDevicesEnumerated = 0; var spi = new SP_DEVINFO_DATA(); spi.cbSize = Marshal.SizeOf(spi); for (int memberIndex = 0; SetupDiEnumDeviceInfo(devInfoSet, memberIndex, ref spi); memberIndex++) { + totalDevicesEnumerated++; try { + logger.Debug($"[Device {memberIndex}] Processing USB device..."); + string? compatibleIds = GetDeviceStringProperty(devInfoSet, ref spi, SPDRP_COMPATIBLEIDS); + logger.Debug($"[Device {memberIndex}] Compatible IDs: {(string.IsNullOrEmpty(compatibleIds) ? "" : compatibleIds)}"); + if (string.IsNullOrEmpty(compatibleIds)) + { + logger.Debug($"[Device {memberIndex}] Skipped: No compatible IDs"); continue; + } // Check if this USB device matches the Video Class (UVC) if (!IsUvcDevice(compatibleIds)) + { + logger.Debug($"[Device {memberIndex}] Skipped: Not a UVC device"); continue; + } + + logger.Debug($"[Device {memberIndex}] Recognized as UVC device"); // Get VID/PID from hardware ID string? hardwareId = GetDeviceStringProperty(devInfoSet, ref spi, SPDRP_HARDWAREID); + logger.Debug($"[Device {memberIndex}] Hardware ID: {(string.IsNullOrEmpty(hardwareId) ? "" : hardwareId)}"); + if (string.IsNullOrEmpty(hardwareId)) + { + logger.Debug($"[Device {memberIndex}] Skipped: No hardware ID"); continue; + } int vendorId = 0; int productId = 0; if (!TryExtractVidPid(hardwareId, out vendorId, out productId)) + { + logger.Debug($"[Device {memberIndex}] Skipped: Could not extract VID/PID from '{hardwareId}'"); continue; + } + + logger.Debug($"[Device {memberIndex}] Extracted VID: {vendorId:X4}, PID: {productId:X4}"); // Get the device name string? deviceName = GetDeviceStringProperty(devInfoSet, ref spi, SPDRP_FRIENDLYNAME); @@ -119,13 +145,20 @@ private void DetectUvcCameras(List cameras) deviceName = $"UVC Camera ({vendorId:X4}:{productId:X4})"; } + logger.Debug($"[Device {memberIndex}] Device Name: {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($"[Device {memberIndex}] Instance ID: {(instanceId ?? "")}"); + // Avoid exact duplicates (same VID/PID) if (cameras.Any(c => c.VendorId == vendorId && c.ProductId == productId)) + { + logger.Debug($"[Device {memberIndex}] Skipped: Duplicate VID/PID already in list"); continue; + } Camera camera = new() { @@ -139,7 +172,7 @@ private void DetectUvcCameras(List cameras) camera.Controls = []; cameras.Add(camera); - logger.Info($"Added Windows UVC camera: '{camera.Name}' (VID={vendorId} PID={productId})"); + logger.Info($"[Device {memberIndex}] Added Windows UVC camera: '{camera.Name}' (VID={vendorId:X4} PID={productId:X4})"); } catch (Exception ex) { @@ -147,7 +180,7 @@ private void DetectUvcCameras(List cameras) } } - logger.Info($"Detected {cameras.Count} UVC camera(s) on Windows"); + logger.Info($"USB device enumeration complete: {totalDevicesEnumerated} devices processed, {cameras.Count} UVC camera(s) detected"); } finally { @@ -158,14 +191,34 @@ private void DetectUvcCameras(List cameras) private static bool IsUvcDevice(string compatibleIds) { // Check for USB Video Class identifiers in the compatible IDs - // Format examples: - // USB\Class_0E + // Format examples from Windows device enumeration: + // USB\COMPAT_VID_046d&Class_0e&SubClass_01&Prot_00 + // USB\COMPAT_VID_046d&Class_0e&SubClass_02&Prot_00 + // USB\Class_0E (legacy format, less common) // USB\Class_0E&SubClass_01 - // USB\Class_0E&SubClass_02 string[] ids = compatibleIds.Split('\0', StringSplitOptions.RemoveEmptyEntries); - return ids.Any(id => + + logger.Debug($"Checking {ids.Length} compatible ID entries:"); + foreach (var id in ids) + { + // Check for: + // 1. USB\Class_0E (original code's expectation) + // 2. USB\...&Class_0e&... (Windows device instance format with COMPAT_VID prefix) + bool matches = id.StartsWith("USB\\Class_0E", StringComparison.OrdinalIgnoreCase) || + id.Contains("&Class_0e&", StringComparison.OrdinalIgnoreCase) || + id.Contains("&Class_0E&", StringComparison.OrdinalIgnoreCase) || + id.Contains("USB_CC_VIDEO", StringComparison.OrdinalIgnoreCase); + logger.Debug($" - '{id}' -> {(matches ? "MATCH" : "no match")}"); + } + + bool result = ids.Any(id => id.StartsWith("USB\\Class_0E", StringComparison.OrdinalIgnoreCase) || + id.Contains("&Class_0e&", StringComparison.OrdinalIgnoreCase) || + id.Contains("&Class_0E&", StringComparison.OrdinalIgnoreCase) || id.Contains("USB_CC_VIDEO", StringComparison.OrdinalIgnoreCase)); + + logger.Debug($"IsUvcDevice result: {result}"); + return result; } /// @@ -184,18 +237,30 @@ private static bool TryExtractVidPid(string hardwareId, out int vendorId, out in // Split on null chars and take the first non-empty line string firstLine = hardwareId.Split('\0', StringSplitOptions.RemoveEmptyEntries).FirstOrDefault() ?? hardwareId; + logger.Debug($"Extracting VID/PID from: '{firstLine}'"); var match = Regex.Match(firstLine, @"VID[_=](\w{4})[^0-9A-Fa-f]?PID[_=](\w{4})", RegexOptions.IgnoreCase); if (!match.Success) + { + logger.Debug($"VID/PID regex did not match. Pattern: VID[_=](\\w{{4}})[^0-9A-Fa-f]?PID[_=](\\w{{4}})"); 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; + logger.Debug($"Extracted VID: 0x{vendorId:X4}, PID: 0x{productId:X4}"); + + if (!(vendorId > 0 && productId > 0)) + { + logger.Debug($"VID or PID is invalid (VID={vendorId}, PID={productId})"); + return false; + } + + return true; } private static string? GetDeviceStringProperty(IntPtr devInfoSet, ref SP_DEVINFO_DATA devInfoData, int property) 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)) { From 051cdeac7b857ea9a67267254e24fd3f60645335 Mon Sep 17 00:00:00 2001 From: Mathieu Carbou Date: Fri, 3 Jul 2026 23:38:03 +0200 Subject: [PATCH 3/4] feat: add cross-platform libuvc UVC camera support (Linux + Windows) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduce libuvc-based UVC camera detection and control on Linux and Windows, alongside the existing V4l2 and DShow paths (kept for testing). New files: - Services/LinuxCameraDetect.cs — scans /sys/class/video4linux/, walks sysfs to find USB idVendor/idProduct, returns APIType.Uvc cameras - Services/WindowsUvcCameraDetect.cs — uses SetupAPI to enumerate USB Video Class (CC_VIDEO=0x0E) devices, extracts VID/PID from hardware IDs - Libraries/libusb/win/{x64,arm64}/build.sh — download libusb-1.0.dll via vcpkg at build time Changes: - CameraControlService: dispatch Set/SetAuto/GetCameraList to the new LinuxCameraDetect or WindowsUvcCameraDetect for APIType.Uvc cameras - UvcFrameSource.OpenDevice: guard macOS IOKit kernel driver detach with OperatingSystem.IsMacOS(); skip it on Linux/Windows - UvcFrameSource.CloseDevice, TryRecoverUvcDevice: macOS-only kernel driver restore - CollimationCircles.csproj: add CopyLibUsbWin, CopyLibUvcWin, CopyLibUvcLinux MSBuild targets - .github/workflows/build-and-release.yml: copy libusb + libuvc binaries on Windows and Linux during publish refactor: standardize UVC file naming and extract macOS UVC detection - Rename LinuxCameraDetect.cs → UvcCameraDetectLinux.cs - Rename WindowsUvcCameraDetect.cs → UvcCameraDetectWindows.cs - Create UvcCameraDetectMac.cs in Services/Uvc/ with UVC-specific detection (system_profiler VID/PID parsing) and control routing - Update MacOSCameraDetect.cs to only handle QTCapture cameras (UVC cameras now detected by UvcCameraDetectMac) - Update CameraControlService.cs to use new class names and add macOS UVC detection via UvcCameraDetectMac --- .github/workflows/build-and-release.yml | 10 + CollimationCircles/CollimationCircles.csproj | 48 ++ .../Services/CameraControlService.cs | 24 + .../Services/Uvc/UvcCameraDetectLinux.cs | 313 +++++++++++++ .../Services/Uvc/UvcCameraDetectWindows.cs | 430 ++++++++++++++++++ 5 files changed, 825 insertions(+) create mode 100644 CollimationCircles/Services/Uvc/UvcCameraDetectLinux.cs create mode 100644 CollimationCircles/Services/Uvc/UvcCameraDetectWindows.cs 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/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 From b638ae4a367cf5396a275170a258ed1f50c24d98 Mon Sep 17 00:00:00 2001 From: Mathieu Carbou Date: Fri, 3 Jul 2026 23:38:03 +0200 Subject: [PATCH 4/4] feat: add cross-platform libuvc UVC camera support (Linux + Windows) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduce libuvc-based UVC camera detection and control on Linux and Windows, alongside the existing V4l2 and DShow paths (kept for testing). New files: - Services/LinuxCameraDetect.cs — scans /sys/class/video4linux/, walks sysfs to find USB idVendor/idProduct, returns APIType.Uvc cameras - Services/WindowsUvcCameraDetect.cs — uses SetupAPI to enumerate USB Video Class (CC_VIDEO=0x0E) devices, extracts VID/PID from hardware IDs - Libraries/libusb/win/{x64,arm64}/build.sh — download libusb-1.0.dll via vcpkg at build time Changes: - CameraControlService: dispatch Set/SetAuto/GetCameraList to the new LinuxCameraDetect or WindowsUvcCameraDetect for APIType.Uvc cameras - UvcFrameSource.OpenDevice: guard macOS IOKit kernel driver detach with OperatingSystem.IsMacOS(); skip it on Linux/Windows - UvcFrameSource.CloseDevice, TryRecoverUvcDevice: macOS-only kernel driver restore - CollimationCircles.csproj: add CopyLibUsbWin, CopyLibUvcWin, CopyLibUvcLinux MSBuild targets - .github/workflows/build-and-release.yml: copy libusb + libuvc binaries on Windows and Linux during publish refactor: standardize UVC file naming and extract macOS UVC detection - Rename LinuxCameraDetect.cs → UvcCameraDetectLinux.cs - Rename WindowsUvcCameraDetect.cs → UvcCameraDetectWindows.cs - Create UvcCameraDetectMac.cs in Services/Uvc/ with UVC-specific detection (system_profiler VID/PID parsing) and control routing - Update MacOSCameraDetect.cs to only handle QTCapture cameras (UVC cameras now detected by UvcCameraDetectMac) - Update CameraControlService.cs to use new class names and add macOS UVC detection via UvcCameraDetectMac --- .github/workflows/build-and-release.yml | 10 + CollimationCircles/CollimationCircles.csproj | 48 ++ CollimationCircles/Program.cs | 48 ++ .../Services/CameraControlService.cs | 24 + .../Services/Uvc/UvcCameraDetectLinux.cs | 313 +++++++++++++ .../Services/Uvc/UvcCameraDetectWindows.cs | 430 ++++++++++++++++++ CollimationCircles/StartupOptions.cs | 15 + 7 files changed, 888 insertions(+) create mode 100644 CollimationCircles/Services/Uvc/UvcCameraDetectLinux.cs create mode 100644 CollimationCircles/Services/Uvc/UvcCameraDetectWindows.cs 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)) {