From 40cf58a85b2cfef2f5d9f88a31484f29c217f9fe Mon Sep 17 00:00:00 2001 From: Mathieu Carbou Date: Sat, 4 Jul 2026 15:24:26 +0200 Subject: [PATCH] macos(libuvc): Little refatcoring to furtehr isolate UVC camera from the rest --- .../Services/CameraControlService.cs | 47 +++- .../Services/MacOSCameraDetect.cs | 85 +----- .../Services/Uvc/UvcCameraDetectMac.cs | 260 ++++++++++++++++++ .../Services/Uvc/UvcFrameSource.cs | 62 +++-- 4 files changed, 333 insertions(+), 121 deletions(-) create mode 100644 CollimationCircles/Services/Uvc/UvcCameraDetectMac.cs diff --git a/CollimationCircles/Services/CameraControlService.cs b/CollimationCircles/Services/CameraControlService.cs index 35b2c3d..3fadae4 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,40 @@ 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.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 +65,19 @@ 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.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); } } @@ -80,6 +95,10 @@ public async Task> GetCameraList() 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); 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/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/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}");