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))
{