From 179334936bfbb32bb20d8068066f80ac8828dcec Mon Sep 17 00:00:00 2001 From: Lucas Lepski Date: Sat, 6 Dec 2025 13:17:52 +0100 Subject: [PATCH 01/45] add guard for cancel --- Drivers/CameraDriver.cs | 40 ++++++++++++++++++++++++++++++++++++---- 1 file changed, 36 insertions(+), 4 deletions(-) diff --git a/Drivers/CameraDriver.cs b/Drivers/CameraDriver.cs index f2d6826..a104f83 100644 --- a/Drivers/CameraDriver.cs +++ b/Drivers/CameraDriver.cs @@ -47,6 +47,7 @@ public class CameraDriver : BaseINPC, ICamera { private short _readoutModeForSnapImages; private short _readoutModeForNormalImages; private AsyncObservableCollection _binningModes; + private readonly object _captureLock = new object(); public CameraDriver(IProfileService profileService, IExposureDataFactory exposureDataFactory, SonyDevice device) { _profileService = profileService; @@ -91,6 +92,39 @@ private void NotifyGainPropertiesChanged() { RaisePropertyChanged(nameof(Gains)); } + private bool TryCancelCapture(string reason) { + if (_camera == null) { + return false; + } + + lock (_captureLock) { + try { + SonyDriver driver = SonyDriver.GetInstance(); + uint status; + + try { + status = driver.GetCaptureStatus(_camera.Handle); + } catch (Exception ex) { + Logger.Warning($"Skipping cancel ({reason}) because capture status could not be read: {ex.Message}"); + return false; + } + + uint[] cancellableStates = { CAPTURE_CAPTURING, CAPTURE_STARTING, CAPTURE_READING, CAPTURE_PROCESSING }; + if (!cancellableStates.Contains(status)) { + Logger.Debug($"Skip cancel ({reason}); capture status is {status}"); + return false; + } + + Logger.Info($"Issuing cancel ({reason}); capture status is {status}"); + driver.CancelCapture(_camera.Handle); + return true; + } catch (Exception ex) { + Logger.Error($"CancelCapture failed ({reason})", ex); + return false; + } + } + } + #endregion #region Supported Properties @@ -525,7 +559,7 @@ public void StartExposure(CaptureSequence sequence) { } // Tell the camera to cancel capture, we do this every time regardless - this will reset the status to be non-complete - driver.CancelCapture(_camera.Handle); + TryCancelCapture("start exposure reset"); double exposureTime = sequence.ExposureTime; driver.StartCapture(_camera.Handle, (float)exposureTime); //); @@ -537,9 +571,7 @@ public void StopExposure() { } public void AbortExposure() { - if (_camera != null) { - SonyDriver.GetInstance().CancelCapture(_camera.Handle); - } + TryCancelCapture("abort request"); } public async Task WaitUntilExposureIsReady(CancellationToken token) { From 44f5e4eef6d39f1d1f88cee93574e6bb4dcb57f8 Mon Sep 17 00:00:00 2001 From: Lucas Lepski Date: Sun, 7 Dec 2025 19:08:34 +0100 Subject: [PATCH 02/45] Serialize capture cancel handling --- Drivers/CameraDriver.cs | 56 ++++++++++++++++++++++++++++------------- 1 file changed, 39 insertions(+), 17 deletions(-) diff --git a/Drivers/CameraDriver.cs b/Drivers/CameraDriver.cs index a104f83..1e9193b 100644 --- a/Drivers/CameraDriver.cs +++ b/Drivers/CameraDriver.cs @@ -100,12 +100,7 @@ private bool TryCancelCapture(string reason) { lock (_captureLock) { try { SonyDriver driver = SonyDriver.GetInstance(); - uint status; - - try { - status = driver.GetCaptureStatus(_camera.Handle); - } catch (Exception ex) { - Logger.Warning($"Skipping cancel ({reason}) because capture status could not be read: {ex.Message}"); + if (!TryGetCaptureStatusLocked(driver, out var status, reason)) { return false; } @@ -125,6 +120,17 @@ private bool TryCancelCapture(string reason) { } } + private bool TryGetCaptureStatusLocked(SonyDriver driver, out uint status, string reason) { + try { + status = driver.GetCaptureStatus(_camera.Handle); + return true; + } catch (Exception ex) { + Logger.Warning($"Unable to get capture status ({reason}): {ex.Message}"); + status = CAPTURE_FAILED; + return false; + } + } + #endregion #region Supported Properties @@ -551,18 +557,25 @@ public void SetupDialog() { public void StartExposure(CaptureSequence sequence) { if (_camera != null) { SonyDriver driver = SonyDriver.GetInstance(); - uint captureStatus = driver.GetCaptureStatus(_camera.Handle); - - if (captureStatus == CAPTURE_CAPTURING || captureStatus == CAPTURE_PROCESSING || captureStatus == CAPTURE_STARTING || - captureStatus == CAPTURE_READING || captureStatus == CAPTURE_PROCESSING) { - Notification.ShowWarning("Another exposure still in progress. Cancelling it to start another."); + bool shouldCancel = false; + lock (_captureLock) { + if (!TryGetCaptureStatusLocked(driver, out var captureStatus, "start exposure preflight")) { + Logger.Warning("Starting exposure without capture status due to read failure."); + } else if (captureStatus == CAPTURE_CAPTURING || captureStatus == CAPTURE_PROCESSING || captureStatus == CAPTURE_STARTING || + captureStatus == CAPTURE_READING || captureStatus == CAPTURE_PROCESSING) { + Notification.ShowWarning("Another exposure still in progress. Cancelling it to start another."); + shouldCancel = true; + } } - // Tell the camera to cancel capture, we do this every time regardless - this will reset the status to be non-complete - TryCancelCapture("start exposure reset"); + if (shouldCancel) { + TryCancelCapture("start exposure reset"); + } - double exposureTime = sequence.ExposureTime; - driver.StartCapture(_camera.Handle, (float)exposureTime); //); + lock (_captureLock) { + double exposureTime = sequence.ExposureTime; + driver.StartCapture(_camera.Handle, (float)exposureTime); //); + } } } @@ -581,13 +594,22 @@ public async Task WaitUntilExposureIsReady(CancellationToken token) { SonyDriver driver = SonyDriver.GetInstance(); try { - uint captureStatus = driver.GetCaptureStatus(_camera.Handle); + uint captureStatus; + lock (_captureLock) { + if (!TryGetCaptureStatusLocked(driver, out captureStatus, "wait begin")) { + throw new SonyException("Problem while waiting for image to be ready (status unavailable)"); + } + } Logger.Info( $"Waiting for image to be ready, current state is {captureStatus}, completion states are {String.Join(", ", completionStates)}"); while (!completionStates.Contains(captureStatus)) { await CoreUtil.Wait(TimeSpan.FromMilliseconds(100), token); - captureStatus = driver.GetCaptureStatus(_camera.Handle); + lock (_captureLock) { + if (!TryGetCaptureStatusLocked(driver, out captureStatus, "wait poll")) { + throw new SonyException("Problem while waiting for image to be ready (status unavailable)"); + } + } } Logger.Info($"Wait for image ready complete, completion state is {captureStatus}"); From 161eff2e228e6fb8cf2763570b9b1facae72696d Mon Sep 17 00:00:00 2001 From: Lucas Lepski Date: Sun, 7 Dec 2025 19:16:35 +0100 Subject: [PATCH 03/45] disable native cancel --- Drivers/CameraDriver.cs | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/Drivers/CameraDriver.cs b/Drivers/CameraDriver.cs index 1e9193b..a1815c4 100644 --- a/Drivers/CameraDriver.cs +++ b/Drivers/CameraDriver.cs @@ -38,6 +38,7 @@ public class CameraDriver : BaseINPC, ICamera { private const uint CAPTURE_STARTING = 0x8001; private const uint CAPTURE_READING = 0x8002; private const uint CAPTURE_PROCESSING = 0x8003; + private const bool ENABLE_NATIVE_CANCEL = false; // native CancelCapture is unstable on some bodies private SonyCameraInfo _camera = null; private SonyDevice _device = null; @@ -97,6 +98,11 @@ private bool TryCancelCapture(string reason) { return false; } + if (!ENABLE_NATIVE_CANCEL) { + Logger.Info($"Native cancel disabled; skipping cancel ({reason})"); + return false; + } + lock (_captureLock) { try { SonyDriver driver = SonyDriver.GetInstance(); @@ -584,7 +590,7 @@ public void StopExposure() { } public void AbortExposure() { - TryCancelCapture("abort request"); + Logger.Info("AbortExposure requested; native cancel disabled; letting capture finish."); } public async Task WaitUntilExposureIsReady(CancellationToken token) { @@ -605,6 +611,11 @@ public async Task WaitUntilExposureIsReady(CancellationToken token) { while (!completionStates.Contains(captureStatus)) { await CoreUtil.Wait(TimeSpan.FromMilliseconds(100), token); + if (token.IsCancellationRequested) { + Logger.Info("WaitUntilExposureIsReady cancelled by token; exiting without native cancel."); + return; + } + lock (_captureLock) { if (!TryGetCaptureStatusLocked(driver, out captureStatus, "wait poll")) { throw new SonyException("Problem while waiting for image to be ready (status unavailable)"); @@ -613,6 +624,8 @@ public async Task WaitUntilExposureIsReady(CancellationToken token) { } Logger.Info($"Wait for image ready complete, completion state is {captureStatus}"); + } catch (TaskCanceledException) { + Logger.Info("WaitUntilExposureIsReady cancelled by token; exiting without native cancel."); } catch (Exception ex) { Logger.Error("WaitUntilExposureIsReady got exception", ex); throw new SonyException("Problem while waiting for image to be ready (see log)"); From dacaeee012666e3dae5a1b2445c9718d0c7415d8 Mon Sep 17 00:00:00 2001 From: Lucas Lepski Date: Sun, 7 Dec 2025 19:34:49 +0100 Subject: [PATCH 04/45] add option to toggle native cancel --- Drivers/CameraDriver.cs | 13 +++++++++---- Drivers/CameraProvider.cs | 16 +++++++++++++++- Options.xaml | 17 ++++++----------- SonyCameraPlugin.cs | 12 ++++++++++++ 4 files changed, 42 insertions(+), 16 deletions(-) diff --git a/Drivers/CameraDriver.cs b/Drivers/CameraDriver.cs index a1815c4..e95442b 100644 --- a/Drivers/CameraDriver.cs +++ b/Drivers/CameraDriver.cs @@ -38,7 +38,7 @@ public class CameraDriver : BaseINPC, ICamera { private const uint CAPTURE_STARTING = 0x8001; private const uint CAPTURE_READING = 0x8002; private const uint CAPTURE_PROCESSING = 0x8003; - private const bool ENABLE_NATIVE_CANCEL = false; // native CancelCapture is unstable on some bodies + private readonly bool _enableNativeCancel; private SonyCameraInfo _camera = null; private SonyDevice _device = null; @@ -50,10 +50,11 @@ public class CameraDriver : BaseINPC, ICamera { private AsyncObservableCollection _binningModes; private readonly object _captureLock = new object(); - public CameraDriver(IProfileService profileService, IExposureDataFactory exposureDataFactory, SonyDevice device) { + public CameraDriver(IProfileService profileService, IExposureDataFactory exposureDataFactory, SonyDevice device, bool enableNativeCancel) { _profileService = profileService; _exposureDataFactory = exposureDataFactory; _device = device; + _enableNativeCancel = enableNativeCancel; } #region Internal Helpers @@ -98,7 +99,7 @@ private bool TryCancelCapture(string reason) { return false; } - if (!ENABLE_NATIVE_CANCEL) { + if (!_enableNativeCancel) { Logger.Info($"Native cancel disabled; skipping cancel ({reason})"); return false; } @@ -590,7 +591,11 @@ public void StopExposure() { } public void AbortExposure() { - Logger.Info("AbortExposure requested; native cancel disabled; letting capture finish."); + if (_enableNativeCancel) { + TryCancelCapture("abort request"); + } else { + Logger.Info("AbortExposure requested; native cancel disabled; letting capture finish."); + } } public async Task WaitUntilExposureIsReady(CancellationToken token) { diff --git a/Drivers/CameraProvider.cs b/Drivers/CameraProvider.cs index 7c78287..d2a0eff 100644 --- a/Drivers/CameraProvider.cs +++ b/Drivers/CameraProvider.cs @@ -2,6 +2,7 @@ using NINA.Equipment.Interfaces.ViewModel; using NINA.Equipment.Interfaces; using NINA.Profile.Interfaces; +using NINA.Profile; using System; using System.Collections.Generic; using System.Linq; @@ -9,6 +10,8 @@ using System.Threading; using System.Threading.Tasks; using System.ComponentModel.Composition; +using System.Reflection; +using System.Runtime.InteropServices; using NINA.Image.Interfaces; using NINA.WPF.Base.Mediator; using Sony; @@ -24,11 +27,15 @@ public class CameraProvider : IEquipmentProvider { private IProfileService profileService; private IExposureDataFactory exposureDataFactory; SonyDriver driver; + private readonly PluginOptionsAccessor pluginSettings; + private static readonly Guid PluginGuid = + Guid.Parse(((GuidAttribute)Attribute.GetCustomAttribute(typeof(CameraProvider).Assembly, typeof(GuidAttribute))).Value); [ImportingConstructor] public CameraProvider(IProfileService profileService, IExposureDataFactory exposureDataFactory) { this.profileService = profileService; this.exposureDataFactory = exposureDataFactory; + this.pluginSettings = new PluginOptionsAccessor(profileService, PluginGuid); if (!DllLoader.IsX86()) { try { @@ -43,6 +50,13 @@ public CameraProvider(IProfileService profileService, IExposureDataFactory expos public IList GetEquipment() { var devices = new List(); + bool enableNativeCancel = false; + try { + var raw = pluginSettings.GetValueString("EnableNativeCancel", bool.FalseString); + enableNativeCancel = bool.TryParse(raw, out var parsed) && parsed; + } catch (Exception ex) { + Logger.Warning($"Unable to read EnableNativeCancel setting; defaulting to false. {ex.Message}"); + } if (this.driver != null) { try { @@ -50,7 +64,7 @@ public IList GetEquipment() { foreach (var sonyDevice in driver.Cameras()) { count++; - devices.Add(new CameraDriver(profileService, exposureDataFactory, sonyDevice)); + devices.Add(new CameraDriver(profileService, exposureDataFactory, sonyDevice, enableNativeCancel)); } Logger.Info($"Found {count} Sony Cameras"); diff --git a/Options.xaml b/Options.xaml index 893755b..5cb60ae 100644 --- a/Options.xaml +++ b/Options.xaml @@ -6,15 +6,10 @@ - - - - - - - - - - + + + - \ No newline at end of file + diff --git a/SonyCameraPlugin.cs b/SonyCameraPlugin.cs index 6b7cfc0..b64a645 100644 --- a/SonyCameraPlugin.cs +++ b/SonyCameraPlugin.cs @@ -72,6 +72,7 @@ public override Task Teardown() { private void ProfileService_ProfileChanged(object sender, EventArgs e) { // Rase the event that this profile specific value has been changed due to the profile switch RaisePropertyChanged(nameof(ProfileSpecificNotificationMessage)); + RaisePropertyChanged(nameof(EnableNativeCancel)); } private Task ImageSaveMediator_BeforeImageSaved(object sender, BeforeImageSavedEventArgs e) { @@ -132,6 +133,17 @@ public string ProfileSpecificNotificationMessage { } } + public bool EnableNativeCancel { + get { + var raw = pluginSettings.GetValueString(nameof(EnableNativeCancel), bool.FalseString); + return bool.TryParse(raw, out var enabled) && enabled; + } + set { + pluginSettings.SetValueString(nameof(EnableNativeCancel), value.ToString()); + RaisePropertyChanged(); + } + } + public event PropertyChangedEventHandler PropertyChanged; protected void RaisePropertyChanged([CallerMemberName] string propertyName = null) { this.PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName)); From 66e83ae5c491b8db1c0be40c39cf12ac5c709948 Mon Sep 17 00:00:00 2001 From: Lucas Lepski Date: Sun, 7 Dec 2025 19:52:47 +0100 Subject: [PATCH 05/45] add text to checkobox --- Options.xaml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/Options.xaml b/Options.xaml index 5cb60ae..b6b5aa3 100644 --- a/Options.xaml +++ b/Options.xaml @@ -7,7 +7,10 @@ - + + From b8bd05272ebd9960038fce15298e4407760aaa0b Mon Sep 17 00:00:00 2001 From: Lucas Lepski Date: Sun, 7 Dec 2025 20:04:25 +0100 Subject: [PATCH 06/45] Add native cancel option and improve soft cancel handling --- Drivers/CameraDriver.cs | 14 +++++++++++--- Options.xaml | 2 +- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/Drivers/CameraDriver.cs b/Drivers/CameraDriver.cs index e95442b..20942d6 100644 --- a/Drivers/CameraDriver.cs +++ b/Drivers/CameraDriver.cs @@ -39,6 +39,7 @@ public class CameraDriver : BaseINPC, ICamera { private const uint CAPTURE_READING = 0x8002; private const uint CAPTURE_PROCESSING = 0x8003; private readonly bool _enableNativeCancel; + private bool _softCancelRequested; private SonyCameraInfo _camera = null; private SonyDevice _device = null; @@ -55,6 +56,7 @@ public CameraDriver(IProfileService profileService, IExposureDataFactory exposur _exposureDataFactory = exposureDataFactory; _device = device; _enableNativeCancel = enableNativeCancel; + _softCancelRequested = false; } #region Internal Helpers @@ -566,6 +568,7 @@ public void StartExposure(CaptureSequence sequence) { SonyDriver driver = SonyDriver.GetInstance(); bool shouldCancel = false; lock (_captureLock) { + _softCancelRequested = false; if (!TryGetCaptureStatusLocked(driver, out var captureStatus, "start exposure preflight")) { Logger.Warning("Starting exposure without capture status due to read failure."); } else if (captureStatus == CAPTURE_CAPTURING || captureStatus == CAPTURE_PROCESSING || captureStatus == CAPTURE_STARTING || @@ -594,6 +597,7 @@ public void AbortExposure() { if (_enableNativeCancel) { TryCancelCapture("abort request"); } else { + _softCancelRequested = true; Logger.Info("AbortExposure requested; native cancel disabled; letting capture finish."); } } @@ -616,9 +620,8 @@ public async Task WaitUntilExposureIsReady(CancellationToken token) { while (!completionStates.Contains(captureStatus)) { await CoreUtil.Wait(TimeSpan.FromMilliseconds(100), token); - if (token.IsCancellationRequested) { - Logger.Info("WaitUntilExposureIsReady cancelled by token; exiting without native cancel."); - return; + if (!_enableNativeCancel && token.IsCancellationRequested) { + _softCancelRequested = true; } lock (_captureLock) { @@ -629,8 +632,13 @@ public async Task WaitUntilExposureIsReady(CancellationToken token) { } Logger.Info($"Wait for image ready complete, completion state is {captureStatus}"); + if (_softCancelRequested || token.IsCancellationRequested) { + _softCancelRequested = false; + throw new TaskCanceledException("Exposure cancelled by user (soft cancel)."); + } } catch (TaskCanceledException) { Logger.Info("WaitUntilExposureIsReady cancelled by token; exiting without native cancel."); + throw; } catch (Exception ex) { Logger.Error("WaitUntilExposureIsReady got exception", ex); throw new SonyException("Problem while waiting for image to be ready (see log)"); diff --git a/Options.xaml b/Options.xaml index b6b5aa3..369b42b 100644 --- a/Options.xaml +++ b/Options.xaml @@ -7,7 +7,7 @@ - + Date: Sun, 7 Dec 2025 20:22:31 +0100 Subject: [PATCH 07/45] Guard start when camera reports busy to avoid native crashes --- Drivers/CameraDriver.cs | 20 ++++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/Drivers/CameraDriver.cs b/Drivers/CameraDriver.cs index 20942d6..9396b04 100644 --- a/Drivers/CameraDriver.cs +++ b/Drivers/CameraDriver.cs @@ -571,10 +571,22 @@ public void StartExposure(CaptureSequence sequence) { _softCancelRequested = false; if (!TryGetCaptureStatusLocked(driver, out var captureStatus, "start exposure preflight")) { Logger.Warning("Starting exposure without capture status due to read failure."); - } else if (captureStatus == CAPTURE_CAPTURING || captureStatus == CAPTURE_PROCESSING || captureStatus == CAPTURE_STARTING || - captureStatus == CAPTURE_READING || captureStatus == CAPTURE_PROCESSING) { - Notification.ShowWarning("Another exposure still in progress. Cancelling it to start another."); - shouldCancel = true; + } else { + uint[] idleStates = { CAPTURE_CREATED, CAPTURE_CANCELLED, CAPTURE_COMPLETE, CAPTURE_FAILED }; + uint[] busyStates = { CAPTURE_CAPTURING, CAPTURE_PROCESSING, CAPTURE_STARTING, CAPTURE_READING }; + + if (busyStates.Contains(captureStatus)) { + Notification.ShowWarning("Camera is still busy with a previous exposure. Skipping new start."); + if (_enableNativeCancel) { + shouldCancel = true; + } else { + // Do not attempt to start while camera is busy when native cancel is disabled + return; + } + } else if (!idleStates.Contains(captureStatus)) { + Logger.Warning($"Unexpected capture status {captureStatus} before start; skipping start."); + return; + } } } From 96980bb2bb66943116e83d6533133ce3a4dc114d Mon Sep 17 00:00:00 2001 From: Lucas Lepski Date: Sun, 7 Dec 2025 20:31:12 +0100 Subject: [PATCH 08/45] Do not start new exposure while camera is busy --- Drivers/CameraDriver.cs | 20 +++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/Drivers/CameraDriver.cs b/Drivers/CameraDriver.cs index 9396b04..8bdd091 100644 --- a/Drivers/CameraDriver.cs +++ b/Drivers/CameraDriver.cs @@ -566,7 +566,8 @@ public void SetupDialog() { public void StartExposure(CaptureSequence sequence) { if (_camera != null) { SonyDriver driver = SonyDriver.GetInstance(); - bool shouldCancel = false; + bool canStart = false; + bool requestCancel = false; lock (_captureLock) { _softCancelRequested = false; if (!TryGetCaptureStatusLocked(driver, out var captureStatus, "start exposure preflight")) { @@ -577,21 +578,22 @@ public void StartExposure(CaptureSequence sequence) { if (busyStates.Contains(captureStatus)) { Notification.ShowWarning("Camera is still busy with a previous exposure. Skipping new start."); - if (_enableNativeCancel) { - shouldCancel = true; - } else { - // Do not attempt to start while camera is busy when native cancel is disabled - return; - } + requestCancel = _enableNativeCancel; } else if (!idleStates.Contains(captureStatus)) { Logger.Warning($"Unexpected capture status {captureStatus} before start; skipping start."); - return; + } else { + canStart = true; } } } - if (shouldCancel) { + if (requestCancel) { TryCancelCapture("start exposure reset"); + return; + } + + if (!canStart) { + return; } lock (_captureLock) { From e8e4dbd7e36dd671c836640a13933cbba5e955f5 Mon Sep 17 00:00:00 2001 From: Lucas Lepski Date: Mon, 8 Dec 2025 11:18:19 +0100 Subject: [PATCH 09/45] move to constants --- Drivers/CameraDriver.cs | 39 ++++++++++++++------------------------- 1 file changed, 14 insertions(+), 25 deletions(-) diff --git a/Drivers/CameraDriver.cs b/Drivers/CameraDriver.cs index 8bdd091..145d8c7 100644 --- a/Drivers/CameraDriver.cs +++ b/Drivers/CameraDriver.cs @@ -38,6 +38,8 @@ public class CameraDriver : BaseINPC, ICamera { private const uint CAPTURE_STARTING = 0x8001; private const uint CAPTURE_READING = 0x8002; private const uint CAPTURE_PROCESSING = 0x8003; + private static readonly uint[] IDLE_STATES = { CAPTURE_CREATED, CAPTURE_CANCELLED, CAPTURE_COMPLETE, CAPTURE_FAILED }; + private static readonly uint[] BUSY_STATES = { CAPTURE_CAPTURING, CAPTURE_PROCESSING, CAPTURE_STARTING, CAPTURE_READING }; private readonly bool _enableNativeCancel; private bool _softCancelRequested; @@ -113,8 +115,7 @@ private bool TryCancelCapture(string reason) { return false; } - uint[] cancellableStates = { CAPTURE_CAPTURING, CAPTURE_STARTING, CAPTURE_READING, CAPTURE_PROCESSING }; - if (!cancellableStates.Contains(status)) { + if (!BUSY_STATES.Contains(status)) { Logger.Debug($"Skip cancel ({reason}); capture status is {status}"); return false; } @@ -566,39 +567,27 @@ public void SetupDialog() { public void StartExposure(CaptureSequence sequence) { if (_camera != null) { SonyDriver driver = SonyDriver.GetInstance(); - bool canStart = false; - bool requestCancel = false; lock (_captureLock) { _softCancelRequested = false; if (!TryGetCaptureStatusLocked(driver, out var captureStatus, "start exposure preflight")) { - Logger.Warning("Starting exposure without capture status due to read failure."); + throw new SonyException("Cannot start exposure: capture status unavailable."); } else { - uint[] idleStates = { CAPTURE_CREATED, CAPTURE_CANCELLED, CAPTURE_COMPLETE, CAPTURE_FAILED }; - uint[] busyStates = { CAPTURE_CAPTURING, CAPTURE_PROCESSING, CAPTURE_STARTING, CAPTURE_READING }; - - if (busyStates.Contains(captureStatus)) { - Notification.ShowWarning("Camera is still busy with a previous exposure. Skipping new start."); - requestCancel = _enableNativeCancel; - } else if (!idleStates.Contains(captureStatus)) { - Logger.Warning($"Unexpected capture status {captureStatus} before start; skipping start."); - } else { - canStart = true; + if (BUSY_STATES.Contains(captureStatus)) { + if (_enableNativeCancel) { + TryCancelCapture("start exposure reset"); + } + throw new SonyException("Cannot start exposure: Camera is still busy with a previous exposure."); } - } - } - if (requestCancel) { - TryCancelCapture("start exposure reset"); - return; - } - - if (!canStart) { - return; + if (!IDLE_STATES.Contains(captureStatus)) { + throw new SonyException($"Cannot start exposure: Camera in unexpected capture status ({captureStatus})."); + } + } } lock (_captureLock) { double exposureTime = sequence.ExposureTime; - driver.StartCapture(_camera.Handle, (float)exposureTime); //); + driver.StartCapture(_camera.Handle, (float)exposureTime); } } } From daafce987a772f76007fa76ab75fd598742493a8 Mon Sep 17 00:00:00 2001 From: Lucas Lepski Date: Mon, 8 Dec 2025 11:31:33 +0100 Subject: [PATCH 10/45] replace SonyException with TaskCanceledException --- Drivers/CameraDriver.cs | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/Drivers/CameraDriver.cs b/Drivers/CameraDriver.cs index 145d8c7..8f83deb 100644 --- a/Drivers/CameraDriver.cs +++ b/Drivers/CameraDriver.cs @@ -570,17 +570,20 @@ public void StartExposure(CaptureSequence sequence) { lock (_captureLock) { _softCancelRequested = false; if (!TryGetCaptureStatusLocked(driver, out var captureStatus, "start exposure preflight")) { - throw new SonyException("Cannot start exposure: capture status unavailable."); + Logger.Warning("Cannot start exposure: capture status unavailable."); + throw new TaskCanceledException("Cannot start exposure: capture status unavailable."); } else { if (BUSY_STATES.Contains(captureStatus)) { if (_enableNativeCancel) { TryCancelCapture("start exposure reset"); } - throw new SonyException("Cannot start exposure: Camera is still busy with a previous exposure."); + Notification.ShowWarning("Camera is still busy with a previous exposure. Skipping new start."); + throw new TaskCanceledException("Cannot start exposure: Camera is still busy with a previous exposure."); } if (!IDLE_STATES.Contains(captureStatus)) { - throw new SonyException($"Cannot start exposure: Camera in unexpected capture status ({captureStatus})."); + Logger.Warning($"Cannot start exposure: Camera in unexpected capture status ({captureStatus})."); + throw new TaskCanceledException($"Cannot start exposure: Camera in unexpected capture status ({captureStatus})."); } } } From 29dcfb0606e90711abcf53d42292c1554b5e236b Mon Sep 17 00:00:00 2001 From: Lucas Lepski Date: Mon, 8 Dec 2025 11:48:49 +0100 Subject: [PATCH 11/45] Add native cancel hint and start/abort safeguards --- CHANGELOG.md | 25 +- Drivers/CameraDriver.cs | 1263 ++++++++++++++++++++---------------- Properties/AssemblyInfo.cs | 6 +- README.md | 3 +- 4 files changed, 725 insertions(+), 572 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 870006f..8eb3bb6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,20 +1,31 @@ -# Sony Camera Plugin +# Sony Camera Plugin + +## 1.0.0.5 + +- Added native cancel option in plugin settings and defaulted to off to avoid crashes. +- Serialized capture cancel/start/status checks and treated busy starts as soft cancels. +- Prevented new exposures from starting when the camera is busy; improved abort behavior without native cancel. ## 1.0.0.4 -* Added the `UpdateSubSampleArea` implementation and bumped the minimum NINA version so the plugin loads in 3.2. -* Restored the ISO/Gain UI by notifying NINA whenever the camera connection updates ISO data and by handling cameras without ISO option lists. -* Probed the registry ISO property (`0xFFFE`) so older Sony bodies still populate the gain dropdown. -* Logged clearer errors when gain min/max cannot be determined and improved the GitHub workflow to publish the release DLL artifact. + +- Added the `UpdateSubSampleArea` implementation and bumped the minimum NINA version so the plugin loads in 3.2. +- Restored the ISO/Gain UI by notifying NINA whenever the camera connection updates ISO data and by handling cameras without ISO option lists. +- Probed the registry ISO property (`0xFFFE`) so older Sony bodies still populate the gain dropdown. +- Logged clearer errors when gain min/max cannot be determined and improved the GitHub workflow to publish the release DLL artifact. ## 1.0.0.3 + Updated to support new device property "DisplayName" required in NINA 3 ## 1.0.0.2 + Rebuilt using .NET Core 8 for NINA 3 ## 1.0.0.1 + Initial release - Supports all functionality provided by the ASCOM version, plus: -* LiveView support -* Actual ISO values displayed in Gain drop-down vs a numeric index (1, 2, 3, etc) + +- LiveView support +- Actual ISO values displayed in Gain drop-down vs a numeric index (1, 2, 3, etc) Note that NINA saves the raw ARW files and does not generate FITS files for DSLRs (per the note in NINA: Options.Imaging). diff --git a/Drivers/CameraDriver.cs b/Drivers/CameraDriver.cs index 8f83deb..5f62182 100644 --- a/Drivers/CameraDriver.cs +++ b/Drivers/CameraDriver.cs @@ -1,724 +1,863 @@ -using System; -using System.Collections.Generic; -using System.IO; -using System.Linq; -using System.Text; -using System.Threading; -using System.Threading.Tasks; -using System.Windows.Media.Imaging; -using FTD2XX_NET; -using NINA.Core.Enum; -using NINA.Core.Model.Equipment; -using NINA.Core.Utility; -using NINA.Core.Utility.Notification; -using NINA.Equipment.Interfaces; -using NINA.Equipment.Interfaces.Mediator; -using NINA.Equipment.Model; -using NINA.Equipment.SDK.CameraSDKs.ASTPANSDK; -using NINA.Equipment.Utility; -using NINA.Image.ImageData; -using NINA.Image.Interfaces; -using NINA.Profile; -using NINA.Profile.Interfaces; -using Sony; - -namespace NINA.RetroKiwi.Plugin.SonyCamera.Drivers { - public class CameraDriver : BaseINPC, ICamera { - // Some camera settings we are interested in - private const uint PROPID_BATTERY = 53784; - private const uint PROPID_ISO = 0xD21E; // Actual ISO currently set - private const uint PROPID_ISOS = 0xFFFE; // Registry-backed list of learnt ISOs (may be empty until learnt) - - // Capture Status - private const uint CAPTURE_CREATED = 0x0000; - private const uint CAPTURE_CAPTURING = 0x0001; - private const uint CAPTURE_FAILED = 0x0002; - private const uint CAPTURE_CANCELLED = 0x0003; - private const uint CAPTURE_COMPLETE = 0x0004; - private const uint CAPTURE_STARTING = 0x8001; - private const uint CAPTURE_READING = 0x8002; - private const uint CAPTURE_PROCESSING = 0x8003; - private static readonly uint[] IDLE_STATES = { CAPTURE_CREATED, CAPTURE_CANCELLED, CAPTURE_COMPLETE, CAPTURE_FAILED }; - private static readonly uint[] BUSY_STATES = { CAPTURE_CAPTURING, CAPTURE_PROCESSING, CAPTURE_STARTING, CAPTURE_READING }; - private readonly bool _enableNativeCancel; - private bool _softCancelRequested; - - private SonyCameraInfo _camera = null; - private SonyDevice _device = null; - private IProfileService _profileService; - private readonly IExposureDataFactory _exposureDataFactory; - private bool _liveViewEnabled; - private short _readoutModeForSnapImages; - private short _readoutModeForNormalImages; - private AsyncObservableCollection _binningModes; - private readonly object _captureLock = new object(); - - public CameraDriver(IProfileService profileService, IExposureDataFactory exposureDataFactory, SonyDevice device, bool enableNativeCancel) { - _profileService = profileService; - _exposureDataFactory = exposureDataFactory; - _device = device; - _enableNativeCancel = enableNativeCancel; - _softCancelRequested = false; - } - - #region Internal Helpers - - private PropertyValue GetPropertyValue(uint id) { - return SonyDriver.GetInstance().GetProperty(_camera.Handle, id); - } - - private IReadOnlyList GetAvailableIsoOptions() { - if (_camera == null) { - return Array.Empty(); - } - - uint[] propertyCandidates = { PROPID_ISOS, PROPID_ISO }; - - foreach (var propertyId in propertyCandidates) { - try { - var options = _camera.GetPropertyInfo(propertyId)?.Options()?.Where(o => o.Value <= 0x00FFFFFF).ToList(); - if (options != null && options.Count > 0) { - return options; - } - } catch (Exception ex) { - Logger.Warning($"Unable to enumerate ISO options for property 0x{propertyId:X}: {ex.Message}"); - } - } +namespace NINA.RetroKiwi.Plugin.SonyCamera.Drivers; - Logger.Warning("Camera did not report any ISO options via known properties (registry ISO list may be empty until the camera learns it)."); - return Array.Empty(); - } +public class CameraDriver : BaseINPC, ICamera +{ + #region constants + private const uint CAPTURE_CANCELLED = 0x0003; + private const uint CAPTURE_CAPTURING = 0x0001; + private const uint CAPTURE_COMPLETE = 0x0004; - private void NotifyGainPropertiesChanged() { - RaisePropertyChanged(nameof(CanGetGain)); - RaisePropertyChanged(nameof(CanSetGain)); - RaisePropertyChanged(nameof(GainMin)); - RaisePropertyChanged(nameof(GainMax)); - RaisePropertyChanged(nameof(Gain)); - RaisePropertyChanged(nameof(Gains)); - } + // Registry-backed list of learnt ISOs (may be empty until learnt) - private bool TryCancelCapture(string reason) { - if (_camera == null) { - return false; - } + // Capture Status + private const uint CAPTURE_CREATED = 0x0000; - if (!_enableNativeCancel) { - Logger.Info($"Native cancel disabled; skipping cancel ({reason})"); - return false; - } + private const uint CAPTURE_FAILED = 0x0002; + private const uint CAPTURE_PROCESSING = 0x8003; + private const uint CAPTURE_READING = 0x8002; + private const uint CAPTURE_STARTING = 0x8001; - lock (_captureLock) { - try { - SonyDriver driver = SonyDriver.GetInstance(); - if (!TryGetCaptureStatusLocked(driver, out var status, reason)) { - return false; - } + // Some camera settings we are interested in + private const uint PROPID_BATTERY = 53784; - if (!BUSY_STATES.Contains(status)) { - Logger.Debug($"Skip cancel ({reason}); capture status is {status}"); - return false; - } + private const uint PROPID_ISO = 0xD21E; - Logger.Info($"Issuing cancel ({reason}); capture status is {status}"); - driver.CancelCapture(_camera.Handle); - return true; - } catch (Exception ex) { - Logger.Error($"CancelCapture failed ({reason})", ex); - return false; - } - } - } + // Actual ISO currently set + private const uint PROPID_ISOS = 0xFFFE; - private bool TryGetCaptureStatusLocked(SonyDriver driver, out uint status, string reason) { - try { - status = driver.GetCaptureStatus(_camera.Handle); - return true; - } catch (Exception ex) { - Logger.Warning($"Unable to get capture status ({reason}): {ex.Message}"); - status = CAPTURE_FAILED; - return false; - } - } - - #endregion + #endregion - #region Supported Properties + #region properties - public bool HasShutter => true; + public int BatteryLevel + { + get + { + if (_camera != null) + { + return (int)GetPropertyValue(PROPID_BATTERY).Value; + } + else + { + return 0; + } + } + } - // Although the driver supports camera temperature, it gets it from the ARW's - // metadata after a photo is taken, because this code doesn't request processed - // ARW, the temp cannot be determined. - public double Temperature { - get => double.NaN; - /*{ + public short BayerOffsetX { get => 1; set => throw new NotImplementedException(); } + public short BayerOffsetY { get => 1; set => throw new NotImplementedException(); } - if (_camera != null) { - PropertyValue value = GetPropertyValue(PROPID_TEMPERATURE); + public AsyncObservableCollection BinningModes + { + get + { + if (_binningModes == null) + { + _binningModes = new AsyncObservableCollection(); + _binningModes.Add(new BinningMode(1, 1)); + } - return (value.Value) / 10.0; - } else { - return double.NaN; - } - }*/ + return _binningModes; } + } - public short BinX { get => 1; set => throw new NotImplementedException(); } - public short BinY { get => 1; set => throw new NotImplementedException(); } + public short BinX { get => 1; set => throw new NotImplementedException(); } + public short BinY { get => 1; set => throw new NotImplementedException(); } - public string SensorName { - get { - if (_camera != null) { - return _camera.SensorName; - } else { - return string.Empty; - } + public int BitDepth + { + get + { + if (_camera != null) + { + return _camera.BitsPerPixel; + } + else + { + return 0; } } + } - public SensorType SensorType { get => SensorType.RGGB; set => throw new NotImplementedException(); } - - public short BayerOffsetX { get => 1; set => throw new NotImplementedException(); } - - public short BayerOffsetY { get => 1; set => throw new NotImplementedException(); } + public CameraStates CameraState => CameraStates.NoState; - public int CameraXSize { - get { - if (_camera != null) { - return _camera.ImageSize.Width; - } - else { - return 0; - } + public int CameraXSize + { + get + { + if (_camera != null) + { + return _camera.ImageSize.Width; } - } - - public int CameraYSize { - get { - if (_camera != null) { - return _camera.ImageSize.Height; - } else { - return 0; - } + else + { + return 0; } } + } - public double ExposureMin { - get { - if (_camera != null) { - return _camera.ExposureMin; - } else { - return double.NaN; - } + public int CameraYSize + { + get + { + if (_camera != null) + { + return _camera.ImageSize.Height; } - } - - public double ExposureMax { - get { - if (_camera != null) { - return _camera.ExposureMax; - } else { - return double.NaN; - } + else + { + return 0; } } + } - public short MaxBinX { get => 1; set => throw new NotImplementedException(); } + public bool CanGetGain => GetAvailableIsoOptions().Any(); + public bool CanSetGain => CanGetGain; + public bool CanSetOffset => false; + public bool CanSetTemperature => false; + public bool CanSetUSBLimit => false; - public short MaxBinY { get => 1; set => throw new NotImplementedException(); } + // TODO - public double PixelSizeX { - get { - if (_camera != null) { - return _camera.PixelWidth; - } else { - return double.NaN; - } + public bool CanShowLiveView + { + get + { + if (_camera != null) + { + return _camera.SupportsPreview(); } - } - - public double PixelSizeY { - get { - if (_camera != null) { - return _camera.PixelHeight; - } else { - return double.NaN; - } + else + { + return false; } } + } - public bool CanSetTemperature => false; - - public CameraStates CameraState => CameraStates.NoState; // TODO + public bool CanSubSample => false; + public string Category { get => "Sony"; } - public bool CanShowLiveView { - get { - if (_camera != null) { - return _camera.SupportsPreview(); - } else { - return false; - } - } + public bool Connected + { + get + { + return _camera != null; } + } - public bool LiveViewEnabled { - get => _liveViewEnabled; - set { - _liveViewEnabled = value; - RaisePropertyChanged(); - } + public bool CoolerOn + { + get => false; + set + { } + } - public bool HasBattery => true; + public double CoolerPower => double.NaN; - public int BatteryLevel { - get { - if (_camera != null) { - return (int)GetPropertyValue(PROPID_BATTERY).Value; - } else { - return 0; - } + public string Description + { + get + { + if (_camera != null) + { + return _camera.GetDescription(); + } + else + { + return _device.GetDescription(); } } + } - public int BitDepth { - get { - if (_camera != null) { - return _camera.BitsPerPixel; - } else { - return 0; - } - } + public bool DewHeaterOn + { + get => false; + set + { } + } - public bool CanGetGain => GetAvailableIsoOptions().Any(); + public string DisplayName + { + get => _device.Model; + set => throw new NotImplementedException(); + } - public bool CanSetGain => CanGetGain; + public string DriverInfo => "https://retro.kiwi"; + public string DriverVersion => string.Empty; + public double ElectronsPerADU => double.NaN; + public bool EnableSubSample { get; set; } - public int GainMax { - get { - var isoOptions = GetAvailableIsoOptions(); - if (!isoOptions.Any()) { - if (_camera != null) { - Logger.Error("Problem getting gain max: camera did not report ISO options."); - } - return -1; - } - - return (int)isoOptions.Last().Value; + public double ExposureMax + { + get + { + if (_camera != null) + { + return _camera.ExposureMax; + } + else + { + return double.NaN; } } + } - public int GainMin { - get { - var isoOptions = GetAvailableIsoOptions(); - if (!isoOptions.Any()) { - if (_camera != null) { - Logger.Error("Problem getting gain min: camera did not report ISO options."); - } - return -1; - } - - return (int)isoOptions.Min(o => o.Value); + public double ExposureMin + { + get + { + if (_camera != null) + { + return _camera.ExposureMin; + } + else + { + return double.NaN; } } + } - public int Gain { - get { - if (_camera != null) { - try { - PropertyValue value = GetPropertyValue(PROPID_ISO); - - return (int)(value.Value == 0xffffff ? 0 : value.Value); - } catch (Exception ex) { - Logger.Error("Problem getting gain", ex); - return -1; - } - } else { + public int Gain + { + get + { + if (_camera != null) + { + try + { + PropertyValue value = GetPropertyValue(PROPID_ISO); + + return (int)(value.Value == 0xffffff ? 0 : value.Value); + } + catch (Exception ex) + { + Logger.Error("Problem getting gain", ex); return -1; } } - - set { - if (_camera != null) { - try { - SonyDriver.GetInstance().SetProperty(_camera.Handle, PROPID_ISO, (uint)value); - RaisePropertyChanged(nameof(Gain)); - } catch (Exception ex) { - Logger.Error($"Problem setting gain to {value}", ex); - } - } + else + { + return -1; } } - public IList Gains { - get { - List gains = new List(); - - foreach (var iso in GetAvailableIsoOptions()) { - if (iso.Value == 0xffffff) { - gains.Add(0); // AUTO - } else { - gains.Add((int)iso.Value); - } + set + { + if (_camera != null) + { + try + { + SonyDriver.GetInstance().SetProperty(_camera.Handle, PROPID_ISO, (uint)value); + RaisePropertyChanged(nameof(Gain)); + } + catch (Exception ex) + { + Logger.Error($"Problem setting gain to {value}", ex); } - - return gains; } } + } - public string Id => "Sony"; - - public string Name { - get => _device.Model; - set => throw new NotImplementedException(); - } + public int GainMax + { + get + { + var isoOptions = GetAvailableIsoOptions(); + if (!isoOptions.Any()) + { + if (_camera != null) + { + Logger.Error("Problem getting gain max: camera did not report ISO options."); + } + return -1; + } - public string DisplayName { - get => _device.Model; - set => throw new NotImplementedException(); + return (int)isoOptions.Last().Value; } + } - public string Category { get => "Sony"; } - - public bool Connected { - get { - return _camera != null; + public int GainMin + { + get + { + var isoOptions = GetAvailableIsoOptions(); + if (!isoOptions.Any()) + { + if (_camera != null) + { + Logger.Error("Problem getting gain min: camera did not report ISO options."); + } + return -1; } + + return (int)isoOptions.Min(o => o.Value); } + } - public string Description { - get { - if (_camera != null) { - return _camera.GetDescription(); - } else { - return _device.GetDescription(); + public IList Gains + { + get + { + List gains = new List(); + + foreach (var iso in GetAvailableIsoOptions()) + { + if (iso.Value == 0xffffff) + { + gains.Add(0); // AUTO + } + else + { + gains.Add((int)iso.Value); } } + + return gains; } + } - public string DriverInfo => "https://retro.kiwi"; + public bool HasBattery => true; + public bool HasDewHeater => false; - public string DriverVersion => string.Empty; + // TODO!!! WE NEED ONE + public bool HasSetupDialog => false; - public double TemperatureSetPoint { - get => double.NaN; + public bool HasShutter => true; + public string Id => "Sony"; - set { - } + public bool LiveViewEnabled + { + get => _liveViewEnabled; + set + { + _liveViewEnabled = value; + RaisePropertyChanged(); } + } - public bool CanSubSample => false; - - public bool EnableSubSample { get; set; } - - public int SubSampleX { get; set; } - - public int SubSampleY { get; set; } + public short MaxBinX { get => 1; set => throw new NotImplementedException(); } + public short MaxBinY { get => 1; set => throw new NotImplementedException(); } - public int SubSampleWidth { get; set; } + public string Name + { + get => _device.Model; + set => throw new NotImplementedException(); + } - public int SubSampleHeight { get; set; } + public int Offset { get => -1; set => throw new NotImplementedException(); } + public int OffsetMax => 0; + public int OffsetMin => 0; - public bool CoolerOn { - get => false; - set { + public double PixelSizeX + { + get + { + if (_camera != null) + { + return _camera.PixelWidth; + } + else + { + return double.NaN; } } + } - public double CoolerPower => double.NaN; - - public bool HasDewHeater => false; - - public bool DewHeaterOn { - get => false; - set { + public double PixelSizeY + { + get + { + if (_camera != null) + { + return _camera.PixelHeight; + } + else + { + return double.NaN; } } + } - public bool CanSetOffset => false; + public short ReadoutMode + { + get => 0; + set { } + } - public int Offset { get => -1; set => throw new NotImplementedException(); } + public short ReadoutModeForNormalImages + { + get => _readoutModeForNormalImages; + set + { + _readoutModeForNormalImages = value; + RaisePropertyChanged(); + } + } - public int OffsetMin => 0; + public short ReadoutModeForSnapImages + { + get => _readoutModeForSnapImages; + set + { + _readoutModeForSnapImages = value; + RaisePropertyChanged(); + } + } - public int OffsetMax => 0; + public IList ReadoutModes => new List { "Default" }; - public bool CanSetUSBLimit => false; + public string SensorName + { + get + { + if (_camera != null) + { + return _camera.SensorName; + } + else + { + return string.Empty; + } + } + } - public int USBLimit { get => -1; set => throw new NotImplementedException(); } + public SensorType SensorType { get => SensorType.RGGB; set => throw new NotImplementedException(); } + public int SubSampleHeight { get; set; } + public int SubSampleWidth { get; set; } + public int SubSampleX { get; set; } + public int SubSampleY { get; set; } + public IList SupportedActions => new List(); + + // Although the driver supports camera temperature, it gets it from the ARW's + // metadata after a photo is taken, because this code doesn't request processed + // ARW, the temp cannot be determined. + public double Temperature + { + get => double.NaN; + /*{ - public int USBLimitMin => -1; + if (_camera != null) { + PropertyValue value = GetPropertyValue(PROPID_TEMPERATURE); - public int USBLimitMax => -1; + return (value.Value) / 10.0; + } else { + return double.NaN; + } + }*/ + } - public int USBLimitStep => -1; + public double TemperatureSetPoint + { + get => double.NaN; - public double ElectronsPerADU => double.NaN; + set + { + } + } - public IList ReadoutModes => new List { "Default" }; + public int USBLimit { get => -1; set => throw new NotImplementedException(); } + public int USBLimitMax => -1; + public int USBLimitMin => -1; + public int USBLimitStep => -1; + #endregion + + #region fields + private AsyncObservableCollection _binningModes; + private SonyCameraInfo _camera = null; + private readonly object _captureLock = new object(); + private SonyDevice _device = null; + private readonly bool _enableNativeCancel; + private readonly IExposureDataFactory _exposureDataFactory; + private bool _liveViewEnabled; + private IProfileService _profileService; + private short _readoutModeForNormalImages; + private short _readoutModeForSnapImages; + private bool _softCancelRequested; + private static readonly uint[] BUSY_STATES = { CAPTURE_CAPTURING, CAPTURE_PROCESSING, CAPTURE_STARTING, CAPTURE_READING }; + private static readonly uint[] CANCELLABLE_STATES = BUSY_STATES; + private static readonly uint[] IDLE_STATES = { CAPTURE_CREATED, CAPTURE_CANCELLED, CAPTURE_COMPLETE, CAPTURE_FAILED }; + #endregion + + public CameraDriver(IProfileService profileService, IExposureDataFactory exposureDataFactory, SonyDevice device, bool enableNativeCancel) + { + _profileService = profileService; + _exposureDataFactory = exposureDataFactory; + _device = device; + _enableNativeCancel = enableNativeCancel; + _softCancelRequested = false; + } - public short ReadoutMode { - get => 0; - set { } - } + #region methods - public short ReadoutModeForSnapImages { - get => _readoutModeForSnapImages; - set { - _readoutModeForSnapImages = value; - RaisePropertyChanged(); - } + public void AbortExposure() + { + if (_enableNativeCancel) + { + TryCancelCapture("abort request"); } - - public short ReadoutModeForNormalImages { - get => _readoutModeForNormalImages; - set { - _readoutModeForNormalImages = value; - RaisePropertyChanged(); - } + else + { + _softCancelRequested = true; + Logger.Info("AbortExposure requested; native cancel disabled; letting capture finish."); } + } - public AsyncObservableCollection BinningModes { - get { - if (_binningModes == null) { - _binningModes = new AsyncObservableCollection(); - _binningModes.Add(new BinningMode(1, 1)); - } + public string Action(string actionName, string actionParameters) + { + throw new NotImplementedException(); + } - return _binningModes; + public Task Connect(CancellationToken token) + { + return Task.Run(() => + { + try + { + _camera = SonyDriver.GetInstance().OpenCamera(_device.Id); + } + catch (Exception ex) + { + Logger.Error(ex); + _camera = null; } - } - #endregion + NotifyGainPropertiesChanged(); + return _camera != null; + }); + } - #region Supported Methods + public void Disconnect() + { + if (_camera != null) + { + try + { + SonyDriver.GetInstance().CloseCamera(_camera.Handle); + } + catch (Exception ex) + { + Logger.Error(ex); + } - public void StartLiveView(CaptureSequence sequence) { - LiveViewEnabled = true; + _camera = null; + NotifyGainPropertiesChanged(); } + } - public void StopLiveView() { - LiveViewEnabled = false; - } + public Task DownloadExposure(CancellationToken token) + { + return Task.Run(() => + { + byte[] rawImageData = SonyDriver.GetInstance().GetLastImage(); + + var metaData = new ImageMetaData(); + + return _exposureDataFactory.CreateRAWExposureData( + converter: _profileService.ActiveProfile.CameraSettings.RawConverter, + rawBytes: rawImageData, + rawType: "arw", + bitDepth: this.BitDepth, + metaData: metaData); + }); + } - public Task Connect(CancellationToken token) { - return Task.Run(() => { - try { - _camera = SonyDriver.GetInstance().OpenCamera(_device.Id); - } catch (Exception ex) { - Logger.Error(ex); - _camera = null; - } + public Task DownloadLiveView(CancellationToken token) + { + return Task.Run(() => + { + using (var memStream = new MemoryStream(SonyDriver.GetInstance().GetLiveView(_camera.Handle))) + { + memStream.Position = 0; - NotifyGainPropertiesChanged(); - return _camera != null; - }); - } + JpegBitmapDecoder decoder = + new JpegBitmapDecoder(memStream, BitmapCreateOptions.IgnoreColorProfile, BitmapCacheOption.OnLoad); - public void Disconnect() { - if (_camera != null) { - try { - SonyDriver.GetInstance().CloseCamera(_camera.Handle); - } catch (Exception ex) { - Logger.Error(ex); - } + FormatConvertedBitmap bitmap = new FormatConvertedBitmap(); + bitmap.BeginInit(); + bitmap.Source = decoder.Frames[0]; + bitmap.DestinationFormat = System.Windows.Media.PixelFormats.Gray16; + bitmap.EndInit(); - _camera = null; - NotifyGainPropertiesChanged(); + ushort[] outArray = new ushort[bitmap.PixelWidth * bitmap.PixelHeight]; + bitmap.CopyPixels(outArray, 2 * bitmap.PixelWidth, 0); + + var metaData = new ImageMetaData(); + + return _exposureDataFactory.CreateImageArrayExposureData( + input: outArray, + width: bitmap.PixelWidth, + height: bitmap.PixelHeight, + bitDepth: 16, + isBayered: false, + metaData: metaData); } - } + }); + } - public Task DownloadLiveView(CancellationToken token) { - return Task.Run(() => { - using (var memStream = new MemoryStream(SonyDriver.GetInstance().GetLiveView(_camera.Handle))) { - memStream.Position = 0; + public void SendCommandBlind(string command, bool raw = true) + { + throw new NotImplementedException(); + } - JpegBitmapDecoder decoder = - new JpegBitmapDecoder(memStream, BitmapCreateOptions.IgnoreColorProfile, BitmapCacheOption.OnLoad); + public bool SendCommandBool(string command, bool raw = true) + { + throw new NotImplementedException(); + } - FormatConvertedBitmap bitmap = new FormatConvertedBitmap(); - bitmap.BeginInit(); - bitmap.Source = decoder.Frames[0]; - bitmap.DestinationFormat = System.Windows.Media.PixelFormats.Gray16; - bitmap.EndInit(); + public string SendCommandString(string command, bool raw = true) + { + throw new NotImplementedException(); + } - ushort[] outArray = new ushort[bitmap.PixelWidth * bitmap.PixelHeight]; - bitmap.CopyPixels(outArray, 2 * bitmap.PixelWidth, 0); + public void SetBinning(short x, short y) + { + // Ignore + } - var metaData = new ImageMetaData(); + public void SetupDialog() + { + throw new NotImplementedException(); + } - return _exposureDataFactory.CreateImageArrayExposureData( - input: outArray, - width: bitmap.PixelWidth, - height: bitmap.PixelHeight, - bitDepth: 16, - isBayered: false, - metaData: metaData); + public void StartExposure(CaptureSequence sequence) + { + if (_camera != null) + { + SonyDriver driver = SonyDriver.GetInstance(); + lock (_captureLock) + { + _softCancelRequested = false; + if (!TryGetCaptureStatusLocked(driver, out var captureStatus, "start exposure preflight")) + { + Logger.Warning("Cannot start exposure: capture status unavailable."); + throw new TaskCanceledException("Cannot start exposure: capture status unavailable."); } - }); - } - public void SetupDialog() { - throw new NotImplementedException(); - } - public void StartExposure(CaptureSequence sequence) { - if (_camera != null) { - SonyDriver driver = SonyDriver.GetInstance(); - lock (_captureLock) { - _softCancelRequested = false; - if (!TryGetCaptureStatusLocked(driver, out var captureStatus, "start exposure preflight")) { - Logger.Warning("Cannot start exposure: capture status unavailable."); - throw new TaskCanceledException("Cannot start exposure: capture status unavailable."); - } else { - if (BUSY_STATES.Contains(captureStatus)) { - if (_enableNativeCancel) { - TryCancelCapture("start exposure reset"); - } - Notification.ShowWarning("Camera is still busy with a previous exposure. Skipping new start."); - throw new TaskCanceledException("Cannot start exposure: Camera is still busy with a previous exposure."); - } - if (!IDLE_STATES.Contains(captureStatus)) { - Logger.Warning($"Cannot start exposure: Camera in unexpected capture status ({captureStatus})."); - throw new TaskCanceledException($"Cannot start exposure: Camera in unexpected capture status ({captureStatus})."); - } + + + if (BUSY_STATES.Contains(captureStatus)) + { + if (_enableNativeCancel) + { + TryCancelCapture("start exposure reset"); } + Notification.ShowWarning("Camera is still busy with a previous exposure. Skipping new start."); + throw new TaskCanceledException("Cannot start exposure: Camera is still busy with a previous exposure."); } - lock (_captureLock) { - double exposureTime = sequence.ExposureTime; - driver.StartCapture(_camera.Handle, (float)exposureTime); + if (!IDLE_STATES.Contains(captureStatus)) + { + Logger.Warning($"Cannot start exposure: Camera in unexpected capture status ({captureStatus})."); + throw new TaskCanceledException($"Cannot start exposure: Camera in unexpected capture status ({captureStatus})."); } + + double exposureTime = sequence.ExposureTime; + driver.StartCapture(_camera.Handle, (float)exposureTime); } } + } - public void StopExposure() { - AbortExposure(); - } + public void StartLiveView(CaptureSequence sequence) + { + LiveViewEnabled = true; + } - public void AbortExposure() { - if (_enableNativeCancel) { - TryCancelCapture("abort request"); - } else { - _softCancelRequested = true; - Logger.Info("AbortExposure requested; native cancel disabled; letting capture finish."); - } + public void StopExposure() + { + AbortExposure(); + } + + public void StopLiveView() + { + LiveViewEnabled = false; + } + + public void UpdateSubSampleArea() + { + if (_camera == null) + { + EnableSubSample = false; + SubSampleX = 0; + SubSampleY = 0; + SubSampleWidth = 0; + SubSampleHeight = 0; + return; } - public async Task WaitUntilExposureIsReady(CancellationToken token) { - using (token.Register(AbortExposure)) { - uint[] completionStates = { CAPTURE_CANCELLED, CAPTURE_COMPLETE, CAPTURE_FAILED }; + if (EnableSubSample && !CanSubSample) + { + Logger.Warning("Sub-sampling requested but not supported for Sony cameras. Falling back to full frame."); + EnableSubSample = false; + } - SonyDriver driver = SonyDriver.GetInstance(); + // Sony cameras currently expose the entire frame, so always reset to the sensor dimensions. + SubSampleX = 0; + SubSampleY = 0; + SubSampleWidth = _camera.ImageSize.Width; + SubSampleHeight = _camera.ImageSize.Height; + } - try { - uint captureStatus; - lock (_captureLock) { - if (!TryGetCaptureStatusLocked(driver, out captureStatus, "wait begin")) { - throw new SonyException("Problem while waiting for image to be ready (status unavailable)"); - } + public async Task WaitUntilExposureIsReady(CancellationToken token) + { + using (token.Register(AbortExposure)) + { + uint[] completionStates = { CAPTURE_CANCELLED, CAPTURE_COMPLETE, CAPTURE_FAILED }; + + SonyDriver driver = SonyDriver.GetInstance(); + + try + { + uint captureStatus; + lock (_captureLock) + { + if (!TryGetCaptureStatusLocked(driver, out captureStatus, "wait begin")) + { + throw new SonyException("Problem while waiting for image to be ready (status unavailable)"); + } + } + Logger.Info( + $"Waiting for image to be ready, current state is {captureStatus}, completion states are {String.Join(", ", completionStates)}"); + + while (!completionStates.Contains(captureStatus)) + { + await CoreUtil.Wait(TimeSpan.FromMilliseconds(100), token); + if (!_enableNativeCancel && token.IsCancellationRequested) + { + _softCancelRequested = true; } - Logger.Info( - $"Waiting for image to be ready, current state is {captureStatus}, completion states are {String.Join(", ", completionStates)}"); - - while (!completionStates.Contains(captureStatus)) { - await CoreUtil.Wait(TimeSpan.FromMilliseconds(100), token); - if (!_enableNativeCancel && token.IsCancellationRequested) { - _softCancelRequested = true; - } - lock (_captureLock) { - if (!TryGetCaptureStatusLocked(driver, out captureStatus, "wait poll")) { - throw new SonyException("Problem while waiting for image to be ready (status unavailable)"); - } + lock (_captureLock) + { + if (!TryGetCaptureStatusLocked(driver, out captureStatus, "wait poll")) + { + throw new SonyException("Problem while waiting for image to be ready (status unavailable)"); } } + } - Logger.Info($"Wait for image ready complete, completion state is {captureStatus}"); - if (_softCancelRequested || token.IsCancellationRequested) { - _softCancelRequested = false; - throw new TaskCanceledException("Exposure cancelled by user (soft cancel)."); - } - } catch (TaskCanceledException) { - Logger.Info("WaitUntilExposureIsReady cancelled by token; exiting without native cancel."); - throw; - } catch (Exception ex) { - Logger.Error("WaitUntilExposureIsReady got exception", ex); - throw new SonyException("Problem while waiting for image to be ready (see log)"); + Logger.Info($"Wait for image ready complete, completion state is {captureStatus}"); + if (_softCancelRequested || token.IsCancellationRequested) + { + _softCancelRequested = false; + throw new TaskCanceledException("Exposure cancelled by user (soft cancel)."); } } + catch (TaskCanceledException) + { + Logger.Info("WaitUntilExposureIsReady cancelled by token; exiting without native cancel."); + throw; + } + catch (Exception ex) + { + Logger.Error("WaitUntilExposureIsReady got exception", ex); + throw new SonyException("Problem while waiting for image to be ready (see log)"); + } } + } - public Task DownloadExposure(CancellationToken token) { - return Task.Run(() => { - byte[] rawImageData = SonyDriver.GetInstance().GetLastImage(); + private IReadOnlyList GetAvailableIsoOptions() + { + if (_camera == null) + { + return Array.Empty(); + } - var metaData = new ImageMetaData(); + uint[] propertyCandidates = { PROPID_ISOS, PROPID_ISO }; - return _exposureDataFactory.CreateRAWExposureData( - converter: _profileService.ActiveProfile.CameraSettings.RawConverter, - rawBytes: rawImageData, - rawType: "arw", - bitDepth: this.BitDepth, - metaData: metaData); - }); + foreach (var propertyId in propertyCandidates) + { + try + { + var options = _camera.GetPropertyInfo(propertyId)?.Options()?.Where(o => o.Value <= 0x00FFFFFF).ToList(); + if (options != null && options.Count > 0) + { + return options; + } + } + catch (Exception ex) + { + Logger.Warning($"Unable to enumerate ISO options for property 0x{propertyId:X}: {ex.Message}"); + } } - #endregion - #region Unsupported Methods + Logger.Warning("Camera did not report any ISO options via known properties (registry ISO list may be empty until the camera learns it)."); + return Array.Empty(); + } - public string Action(string actionName, string actionParameters) { - throw new NotImplementedException(); - } + private PropertyValue GetPropertyValue(uint id) + { + return SonyDriver.GetInstance().GetProperty(_camera.Handle, id); + } + private void NotifyGainPropertiesChanged() + { + RaisePropertyChanged(nameof(CanGetGain)); + RaisePropertyChanged(nameof(CanSetGain)); + RaisePropertyChanged(nameof(GainMin)); + RaisePropertyChanged(nameof(GainMax)); + RaisePropertyChanged(nameof(Gain)); + RaisePropertyChanged(nameof(Gains)); + } - public void SendCommandBlind(string command, bool raw = true) { - throw new NotImplementedException(); + private bool TryCancelCapture(string reason) + { + if (_camera == null) + { + return false; } - public bool SendCommandBool(string command, bool raw = true) { - throw new NotImplementedException(); + if (!_enableNativeCancel) + { + Logger.Info($"Native cancel disabled; skipping cancel ({reason})"); + return false; } - public string SendCommandString(string command, bool raw = true) { - throw new NotImplementedException(); - } + lock (_captureLock) + { + try + { + SonyDriver driver = SonyDriver.GetInstance(); + if (!TryGetCaptureStatusLocked(driver, out var status, reason)) + { + return false; + } - public void SetBinning(short x, short y) { - // Ignore - } - - public void UpdateSubSampleArea() { - if (_camera == null) { - EnableSubSample = false; - SubSampleX = 0; - SubSampleY = 0; - SubSampleWidth = 0; - SubSampleHeight = 0; - return; - } + if (!CANCELLABLE_STATES.Contains(status)) + { + Logger.Debug($"Skip cancel ({reason}); capture status is {status}"); + return false; + } - if (EnableSubSample && !CanSubSample) { - Logger.Warning("Sub-sampling requested but not supported for Sony cameras. Falling back to full frame."); - EnableSubSample = false; + Logger.Info($"Issuing cancel ({reason}); capture status is {status}"); + driver.CancelCapture(_camera.Handle); + return true; + } + catch (Exception ex) + { + Logger.Error($"CancelCapture failed ({reason})", ex); + return false; } - - // Sony cameras currently expose the entire frame, so always reset to the sensor dimensions. - SubSampleX = 0; - SubSampleY = 0; - SubSampleWidth = _camera.ImageSize.Width; - SubSampleHeight = _camera.ImageSize.Height; } + } - #endregion - - - // TODO!!! WE NEED ONE - public bool HasSetupDialog => false; - - public IList SupportedActions => new List(); + private bool TryGetCaptureStatusLocked(SonyDriver driver, out uint status, string reason) + { + try + { + status = driver.GetCaptureStatus(_camera.Handle); + return true; + } + catch (Exception ex) + { + Logger.Warning($"Unable to get capture status ({reason}): {ex.Message}"); + status = CAPTURE_FAILED; + return false; + } } -} + + #endregion +} \ No newline at end of file diff --git a/Properties/AssemblyInfo.cs b/Properties/AssemblyInfo.cs index ad54320..c4d01b7 100644 --- a/Properties/AssemblyInfo.cs +++ b/Properties/AssemblyInfo.cs @@ -8,8 +8,8 @@ // [MANDATORY] The assembly versioning //Should be incremented for each new release build of a plugin -[assembly: AssemblyVersion("1.0.0.4")] -[assembly: AssemblyFileVersion("1.0.0.4")] +[assembly: AssemblyVersion("1.0.0.5")] +[assembly: AssemblyFileVersion("1.0.0.5")] // [MANDATORY] The name of your plugin [assembly: AssemblyTitle("Sony Camera Plugin")] @@ -33,6 +33,8 @@ [assembly: AssemblyMetadata("LicenseURL", "https://www.mozilla.org/en-US/MPL/2.0/")] // The repository where your pluggin is hosted [assembly: AssemblyMetadata("Repository", "https://github.com/dougforpres/NINASonyCameraPlugin")] +// Note on native cancel: stability depends on camera model and Windows driver stack; leave disabled if aborts crash. +[assembly: AssemblyMetadata("NativeCancelHint", "Native CancelCapture stability varies by camera model and Windows driver stack; disable if aborts crash.")] // The following attributes are optional for the official manifest meta data diff --git a/README.md b/README.md index a98c5bd..254ec52 100644 --- a/README.md +++ b/README.md @@ -31,6 +31,7 @@ https://github.com/dougforpres/NINASonyCameraPlugin/releases - Binning and sub-sampling are not supported; captures use the full sensor frame. - For exposures <= 30s the driver chooses the nearest built-in shutter speed; longer exposures fall back to Bulb. - Powering off/unplugging the camera while connected can crash NINA via Windows' MTP stack (PortableDeviceApi.dll access violation); disconnect in NINA first to avoid it. +- The “Enable native cancel” option calls the camera’s CancelCapture; stability varies by body and Windows driver stack. Leave it off if you see crashes when aborting exposures. ## Support @@ -43,7 +44,7 @@ https://github.com/dougforpres/NINASonyCameraPlugin/releases ## Metadata -- Version: 1.0.0.4 +- Version: 1.0.0.5 - Author: Doug Henderson - Contributors: Lucas Lepski [@ShurkanTwo](https://github.com/shurkanTwo) - Minimum NINA version: 3.2.0.3001 From 1d7cdab8083624020defca86ed59b3568b06d69b Mon Sep 17 00:00:00 2001 From: Lucas Lepski Date: Mon, 8 Dec 2025 11:53:20 +0100 Subject: [PATCH 12/45] undo csharpening --- Drivers/CameraDriver.cs | 1262 +++++++++++++++++---------------------- 1 file changed, 561 insertions(+), 701 deletions(-) diff --git a/Drivers/CameraDriver.cs b/Drivers/CameraDriver.cs index 5f62182..c34a917 100644 --- a/Drivers/CameraDriver.cs +++ b/Drivers/CameraDriver.cs @@ -1,863 +1,723 @@ -namespace NINA.RetroKiwi.Plugin.SonyCamera.Drivers; - -public class CameraDriver : BaseINPC, ICamera -{ - #region constants - private const uint CAPTURE_CANCELLED = 0x0003; - private const uint CAPTURE_CAPTURING = 0x0001; - private const uint CAPTURE_COMPLETE = 0x0004; - - // Registry-backed list of learnt ISOs (may be empty until learnt) - - // Capture Status - private const uint CAPTURE_CREATED = 0x0000; +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using System.Windows.Media.Imaging; +using FTD2XX_NET; +using NINA.Core.Enum; +using NINA.Core.Model.Equipment; +using NINA.Core.Utility; +using NINA.Core.Utility.Notification; +using NINA.Equipment.Interfaces; +using NINA.Equipment.Interfaces.Mediator; +using NINA.Equipment.Model; +using NINA.Equipment.SDK.CameraSDKs.ASTPANSDK; +using NINA.Equipment.Utility; +using NINA.Image.ImageData; +using NINA.Image.Interfaces; +using NINA.Profile; +using NINA.Profile.Interfaces; +using Sony; + +namespace NINA.RetroKiwi.Plugin.SonyCamera.Drivers { + public class CameraDriver : BaseINPC, ICamera { + // Some camera settings we are interested in + private const uint PROPID_BATTERY = 53784; + private const uint PROPID_ISO = 0xD21E; // Actual ISO currently set + private const uint PROPID_ISOS = 0xFFFE; // Registry-backed list of learnt ISOs (may be empty until learnt) + + // Capture Status + private const uint CAPTURE_CREATED = 0x0000; + private const uint CAPTURE_CAPTURING = 0x0001; + private const uint CAPTURE_FAILED = 0x0002; + private const uint CAPTURE_CANCELLED = 0x0003; + private const uint CAPTURE_COMPLETE = 0x0004; + private const uint CAPTURE_STARTING = 0x8001; + private const uint CAPTURE_READING = 0x8002; + private const uint CAPTURE_PROCESSING = 0x8003; + private static readonly uint[] IDLE_STATES = { CAPTURE_CREATED, CAPTURE_CANCELLED, CAPTURE_COMPLETE, CAPTURE_FAILED }; + private static readonly uint[] BUSY_STATES = { CAPTURE_CAPTURING, CAPTURE_PROCESSING, CAPTURE_STARTING, CAPTURE_READING }; + private static readonly uint[] CANCELLABLE_STATES = BUSY_STATES; + private readonly bool _enableNativeCancel; + private bool _softCancelRequested; + + private SonyCameraInfo _camera = null; + private SonyDevice _device = null; + private IProfileService _profileService; + private readonly IExposureDataFactory _exposureDataFactory; + private bool _liveViewEnabled; + private short _readoutModeForSnapImages; + private short _readoutModeForNormalImages; + private AsyncObservableCollection _binningModes; + private readonly object _captureLock = new object(); + + public CameraDriver(IProfileService profileService, IExposureDataFactory exposureDataFactory, SonyDevice device, bool enableNativeCancel) { + _profileService = profileService; + _exposureDataFactory = exposureDataFactory; + _device = device; + _enableNativeCancel = enableNativeCancel; + _softCancelRequested = false; + } + + #region Internal Helpers + + private PropertyValue GetPropertyValue(uint id) { + return SonyDriver.GetInstance().GetProperty(_camera.Handle, id); + } + + private IReadOnlyList GetAvailableIsoOptions() { + if (_camera == null) { + return Array.Empty(); + } + + uint[] propertyCandidates = { PROPID_ISOS, PROPID_ISO }; + + foreach (var propertyId in propertyCandidates) { + try { + var options = _camera.GetPropertyInfo(propertyId)?.Options()?.Where(o => o.Value <= 0x00FFFFFF).ToList(); + if (options != null && options.Count > 0) { + return options; + } + } catch (Exception ex) { + Logger.Warning($"Unable to enumerate ISO options for property 0x{propertyId:X}: {ex.Message}"); + } + } - private const uint CAPTURE_FAILED = 0x0002; - private const uint CAPTURE_PROCESSING = 0x8003; - private const uint CAPTURE_READING = 0x8002; - private const uint CAPTURE_STARTING = 0x8001; + Logger.Warning("Camera did not report any ISO options via known properties (registry ISO list may be empty until the camera learns it)."); + return Array.Empty(); + } - // Some camera settings we are interested in - private const uint PROPID_BATTERY = 53784; + private void NotifyGainPropertiesChanged() { + RaisePropertyChanged(nameof(CanGetGain)); + RaisePropertyChanged(nameof(CanSetGain)); + RaisePropertyChanged(nameof(GainMin)); + RaisePropertyChanged(nameof(GainMax)); + RaisePropertyChanged(nameof(Gain)); + RaisePropertyChanged(nameof(Gains)); + } - private const uint PROPID_ISO = 0xD21E; + private bool TryCancelCapture(string reason) { + if (_camera == null) { + return false; + } - // Actual ISO currently set - private const uint PROPID_ISOS = 0xFFFE; + if (!_enableNativeCancel) { + Logger.Info($"Native cancel disabled; skipping cancel ({reason})"); + return false; + } - #endregion + lock (_captureLock) { + try { + SonyDriver driver = SonyDriver.GetInstance(); + if (!TryGetCaptureStatusLocked(driver, out var status, reason)) { + return false; + } - #region properties + if (!CANCELLABLE_STATES.Contains(status)) { + Logger.Debug($"Skip cancel ({reason}); capture status is {status}"); + return false; + } - public int BatteryLevel - { - get - { - if (_camera != null) - { - return (int)GetPropertyValue(PROPID_BATTERY).Value; + Logger.Info($"Issuing cancel ({reason}); capture status is {status}"); + driver.CancelCapture(_camera.Handle); + return true; + } catch (Exception ex) { + Logger.Error($"CancelCapture failed ({reason})", ex); + return false; + } } - else - { - return 0; + } + + private bool TryGetCaptureStatusLocked(SonyDriver driver, out uint status, string reason) { + try { + status = driver.GetCaptureStatus(_camera.Handle); + return true; + } catch (Exception ex) { + Logger.Warning($"Unable to get capture status ({reason}): {ex.Message}"); + status = CAPTURE_FAILED; + return false; } } - } - public short BayerOffsetX { get => 1; set => throw new NotImplementedException(); } - public short BayerOffsetY { get => 1; set => throw new NotImplementedException(); } + #endregion - public AsyncObservableCollection BinningModes - { - get - { - if (_binningModes == null) - { - _binningModes = new AsyncObservableCollection(); - _binningModes.Add(new BinningMode(1, 1)); - } + #region Supported Properties + + public bool HasShutter => true; + + // Although the driver supports camera temperature, it gets it from the ARW's + // metadata after a photo is taken, because this code doesn't request processed + // ARW, the temp cannot be determined. + public double Temperature { + get => double.NaN; + /*{ + + if (_camera != null) { + PropertyValue value = GetPropertyValue(PROPID_TEMPERATURE); - return _binningModes; + return (value.Value) / 10.0; + } else { + return double.NaN; + } + }*/ } - } - public short BinX { get => 1; set => throw new NotImplementedException(); } - public short BinY { get => 1; set => throw new NotImplementedException(); } + public short BinX { get => 1; set => throw new NotImplementedException(); } + public short BinY { get => 1; set => throw new NotImplementedException(); } - public int BitDepth - { - get - { - if (_camera != null) - { - return _camera.BitsPerPixel; - } - else - { - return 0; + public string SensorName { + get { + if (_camera != null) { + return _camera.SensorName; + } else { + return string.Empty; + } } } - } - public CameraStates CameraState => CameraStates.NoState; + public SensorType SensorType { get => SensorType.RGGB; set => throw new NotImplementedException(); } + + public short BayerOffsetX { get => 1; set => throw new NotImplementedException(); } + + public short BayerOffsetY { get => 1; set => throw new NotImplementedException(); } - public int CameraXSize - { - get - { - if (_camera != null) - { - return _camera.ImageSize.Width; + public int CameraXSize { + get { + if (_camera != null) { + return _camera.ImageSize.Width; + } + else { + return 0; + } } - else - { - return 0; + } + + public int CameraYSize { + get { + if (_camera != null) { + return _camera.ImageSize.Height; + } else { + return 0; + } } } - } - public int CameraYSize - { - get - { - if (_camera != null) - { - return _camera.ImageSize.Height; + public double ExposureMin { + get { + if (_camera != null) { + return _camera.ExposureMin; + } else { + return double.NaN; + } } - else - { - return 0; + } + + public double ExposureMax { + get { + if (_camera != null) { + return _camera.ExposureMax; + } else { + return double.NaN; + } } } - } - public bool CanGetGain => GetAvailableIsoOptions().Any(); - public bool CanSetGain => CanGetGain; - public bool CanSetOffset => false; - public bool CanSetTemperature => false; - public bool CanSetUSBLimit => false; + public short MaxBinX { get => 1; set => throw new NotImplementedException(); } - // TODO + public short MaxBinY { get => 1; set => throw new NotImplementedException(); } - public bool CanShowLiveView - { - get - { - if (_camera != null) - { - return _camera.SupportsPreview(); + public double PixelSizeX { + get { + if (_camera != null) { + return _camera.PixelWidth; + } else { + return double.NaN; + } } - else - { - return false; + } + + public double PixelSizeY { + get { + if (_camera != null) { + return _camera.PixelHeight; + } else { + return double.NaN; + } } } - } - public bool CanSubSample => false; - public string Category { get => "Sony"; } + public bool CanSetTemperature => false; - public bool Connected - { - get - { - return _camera != null; + public CameraStates CameraState => CameraStates.NoState; // TODO + + public bool CanShowLiveView { + get { + if (_camera != null) { + return _camera.SupportsPreview(); + } else { + return false; + } + } } - } - public bool CoolerOn - { - get => false; - set - { + public bool LiveViewEnabled { + get => _liveViewEnabled; + set { + _liveViewEnabled = value; + RaisePropertyChanged(); + } } - } - public double CoolerPower => double.NaN; + public bool HasBattery => true; - public string Description - { - get - { - if (_camera != null) - { - return _camera.GetDescription(); - } - else - { - return _device.GetDescription(); + public int BatteryLevel { + get { + if (_camera != null) { + return (int)GetPropertyValue(PROPID_BATTERY).Value; + } else { + return 0; + } } } - } - public bool DewHeaterOn - { - get => false; - set - { + public int BitDepth { + get { + if (_camera != null) { + return _camera.BitsPerPixel; + } else { + return 0; + } + } } - } - public string DisplayName - { - get => _device.Model; - set => throw new NotImplementedException(); - } + public bool CanGetGain => GetAvailableIsoOptions().Any(); - public string DriverInfo => "https://retro.kiwi"; - public string DriverVersion => string.Empty; - public double ElectronsPerADU => double.NaN; - public bool EnableSubSample { get; set; } + public bool CanSetGain => CanGetGain; - public double ExposureMax - { - get - { - if (_camera != null) - { - return _camera.ExposureMax; - } - else - { - return double.NaN; - } - } - } + public int GainMax { + get { + var isoOptions = GetAvailableIsoOptions(); + if (!isoOptions.Any()) { + if (_camera != null) { + Logger.Error("Problem getting gain max: camera did not report ISO options."); + } + return -1; + } - public double ExposureMin - { - get - { - if (_camera != null) - { - return _camera.ExposureMin; - } - else - { - return double.NaN; + return (int)isoOptions.Last().Value; } } - } - public int Gain - { - get - { - if (_camera != null) - { - try - { - PropertyValue value = GetPropertyValue(PROPID_ISO); - - return (int)(value.Value == 0xffffff ? 0 : value.Value); - } - catch (Exception ex) - { - Logger.Error("Problem getting gain", ex); + public int GainMin { + get { + var isoOptions = GetAvailableIsoOptions(); + if (!isoOptions.Any()) { + if (_camera != null) { + Logger.Error("Problem getting gain min: camera did not report ISO options."); + } return -1; } - } - else - { - return -1; + + return (int)isoOptions.Min(o => o.Value); } } - set - { - if (_camera != null) - { - try - { - SonyDriver.GetInstance().SetProperty(_camera.Handle, PROPID_ISO, (uint)value); - RaisePropertyChanged(nameof(Gain)); + public int Gain { + get { + if (_camera != null) { + try { + PropertyValue value = GetPropertyValue(PROPID_ISO); + + return (int)(value.Value == 0xffffff ? 0 : value.Value); + } catch (Exception ex) { + Logger.Error("Problem getting gain", ex); + return -1; + } + } else { + return -1; } - catch (Exception ex) - { - Logger.Error($"Problem setting gain to {value}", ex); + } + + set { + if (_camera != null) { + try { + SonyDriver.GetInstance().SetProperty(_camera.Handle, PROPID_ISO, (uint)value); + RaisePropertyChanged(nameof(Gain)); + } catch (Exception ex) { + Logger.Error($"Problem setting gain to {value}", ex); + } } } } - } - public int GainMax - { - get - { - var isoOptions = GetAvailableIsoOptions(); - if (!isoOptions.Any()) - { - if (_camera != null) - { - Logger.Error("Problem getting gain max: camera did not report ISO options."); + public IList Gains { + get { + List gains = new List(); + + foreach (var iso in GetAvailableIsoOptions()) { + if (iso.Value == 0xffffff) { + gains.Add(0); // AUTO + } else { + gains.Add((int)iso.Value); + } } - return -1; + + return gains; } + } - return (int)isoOptions.Last().Value; + public string Id => "Sony"; + + public string Name { + get => _device.Model; + set => throw new NotImplementedException(); } - } - public int GainMin - { - get - { - var isoOptions = GetAvailableIsoOptions(); - if (!isoOptions.Any()) - { - if (_camera != null) - { - Logger.Error("Problem getting gain min: camera did not report ISO options."); - } - return -1; - } + public string DisplayName { + get => _device.Model; + set => throw new NotImplementedException(); + } - return (int)isoOptions.Min(o => o.Value); + public string Category { get => "Sony"; } + + public bool Connected { + get { + return _camera != null; + } } - } - public IList Gains - { - get - { - List gains = new List(); - - foreach (var iso in GetAvailableIsoOptions()) - { - if (iso.Value == 0xffffff) - { - gains.Add(0); // AUTO - } - else - { - gains.Add((int)iso.Value); + public string Description { + get { + if (_camera != null) { + return _camera.GetDescription(); + } else { + return _device.GetDescription(); } } - - return gains; } - } - public bool HasBattery => true; - public bool HasDewHeater => false; + public string DriverInfo => "https://retro.kiwi"; - // TODO!!! WE NEED ONE - public bool HasSetupDialog => false; + public string DriverVersion => string.Empty; - public bool HasShutter => true; - public string Id => "Sony"; + public double TemperatureSetPoint { + get => double.NaN; - public bool LiveViewEnabled - { - get => _liveViewEnabled; - set - { - _liveViewEnabled = value; - RaisePropertyChanged(); + set { + } } - } - public short MaxBinX { get => 1; set => throw new NotImplementedException(); } - public short MaxBinY { get => 1; set => throw new NotImplementedException(); } + public bool CanSubSample => false; - public string Name - { - get => _device.Model; - set => throw new NotImplementedException(); - } + public bool EnableSubSample { get; set; } - public int Offset { get => -1; set => throw new NotImplementedException(); } - public int OffsetMax => 0; - public int OffsetMin => 0; + public int SubSampleX { get; set; } - public double PixelSizeX - { - get - { - if (_camera != null) - { - return _camera.PixelWidth; - } - else - { - return double.NaN; - } - } - } + public int SubSampleY { get; set; } - public double PixelSizeY - { - get - { - if (_camera != null) - { - return _camera.PixelHeight; - } - else - { - return double.NaN; - } - } - } + public int SubSampleWidth { get; set; } - public short ReadoutMode - { - get => 0; - set { } - } + public int SubSampleHeight { get; set; } - public short ReadoutModeForNormalImages - { - get => _readoutModeForNormalImages; - set - { - _readoutModeForNormalImages = value; - RaisePropertyChanged(); + public bool CoolerOn { + get => false; + set { + } } - } - public short ReadoutModeForSnapImages - { - get => _readoutModeForSnapImages; - set - { - _readoutModeForSnapImages = value; - RaisePropertyChanged(); - } - } + public double CoolerPower => double.NaN; - public IList ReadoutModes => new List { "Default" }; + public bool HasDewHeater => false; - public string SensorName - { - get - { - if (_camera != null) - { - return _camera.SensorName; - } - else - { - return string.Empty; + public bool DewHeaterOn { + get => false; + set { } } - } - public SensorType SensorType { get => SensorType.RGGB; set => throw new NotImplementedException(); } - public int SubSampleHeight { get; set; } - public int SubSampleWidth { get; set; } - public int SubSampleX { get; set; } - public int SubSampleY { get; set; } - public IList SupportedActions => new List(); - - // Although the driver supports camera temperature, it gets it from the ARW's - // metadata after a photo is taken, because this code doesn't request processed - // ARW, the temp cannot be determined. - public double Temperature - { - get => double.NaN; - /*{ + public bool CanSetOffset => false; - if (_camera != null) { - PropertyValue value = GetPropertyValue(PROPID_TEMPERATURE); + public int Offset { get => -1; set => throw new NotImplementedException(); } - return (value.Value) / 10.0; - } else { - return double.NaN; - } - }*/ - } + public int OffsetMin => 0; - public double TemperatureSetPoint - { - get => double.NaN; + public int OffsetMax => 0; - set - { - } - } + public bool CanSetUSBLimit => false; - public int USBLimit { get => -1; set => throw new NotImplementedException(); } - public int USBLimitMax => -1; - public int USBLimitMin => -1; - public int USBLimitStep => -1; - #endregion - - #region fields - private AsyncObservableCollection _binningModes; - private SonyCameraInfo _camera = null; - private readonly object _captureLock = new object(); - private SonyDevice _device = null; - private readonly bool _enableNativeCancel; - private readonly IExposureDataFactory _exposureDataFactory; - private bool _liveViewEnabled; - private IProfileService _profileService; - private short _readoutModeForNormalImages; - private short _readoutModeForSnapImages; - private bool _softCancelRequested; - private static readonly uint[] BUSY_STATES = { CAPTURE_CAPTURING, CAPTURE_PROCESSING, CAPTURE_STARTING, CAPTURE_READING }; - private static readonly uint[] CANCELLABLE_STATES = BUSY_STATES; - private static readonly uint[] IDLE_STATES = { CAPTURE_CREATED, CAPTURE_CANCELLED, CAPTURE_COMPLETE, CAPTURE_FAILED }; - #endregion - - public CameraDriver(IProfileService profileService, IExposureDataFactory exposureDataFactory, SonyDevice device, bool enableNativeCancel) - { - _profileService = profileService; - _exposureDataFactory = exposureDataFactory; - _device = device; - _enableNativeCancel = enableNativeCancel; - _softCancelRequested = false; - } + public int USBLimit { get => -1; set => throw new NotImplementedException(); } - #region methods + public int USBLimitMin => -1; - public void AbortExposure() - { - if (_enableNativeCancel) - { - TryCancelCapture("abort request"); - } - else - { - _softCancelRequested = true; - Logger.Info("AbortExposure requested; native cancel disabled; letting capture finish."); - } - } + public int USBLimitMax => -1; - public string Action(string actionName, string actionParameters) - { - throw new NotImplementedException(); - } + public int USBLimitStep => -1; - public Task Connect(CancellationToken token) - { - return Task.Run(() => - { - try - { - _camera = SonyDriver.GetInstance().OpenCamera(_device.Id); - } - catch (Exception ex) - { - Logger.Error(ex); - _camera = null; - } + public double ElectronsPerADU => double.NaN; - NotifyGainPropertiesChanged(); - return _camera != null; - }); - } + public IList ReadoutModes => new List { "Default" }; + + public short ReadoutMode { + get => 0; + set { } + } - public void Disconnect() - { - if (_camera != null) - { - try - { - SonyDriver.GetInstance().CloseCamera(_camera.Handle); + public short ReadoutModeForSnapImages { + get => _readoutModeForSnapImages; + set { + _readoutModeForSnapImages = value; + RaisePropertyChanged(); } - catch (Exception ex) - { - Logger.Error(ex); + } + + public short ReadoutModeForNormalImages { + get => _readoutModeForNormalImages; + set { + _readoutModeForNormalImages = value; + RaisePropertyChanged(); } + } - _camera = null; - NotifyGainPropertiesChanged(); + public AsyncObservableCollection BinningModes { + get { + if (_binningModes == null) { + _binningModes = new AsyncObservableCollection(); + _binningModes.Add(new BinningMode(1, 1)); + } + + return _binningModes; + } } - } - public Task DownloadExposure(CancellationToken token) - { - return Task.Run(() => - { - byte[] rawImageData = SonyDriver.GetInstance().GetLastImage(); - - var metaData = new ImageMetaData(); - - return _exposureDataFactory.CreateRAWExposureData( - converter: _profileService.ActiveProfile.CameraSettings.RawConverter, - rawBytes: rawImageData, - rawType: "arw", - bitDepth: this.BitDepth, - metaData: metaData); - }); - } + #endregion - public Task DownloadLiveView(CancellationToken token) - { - return Task.Run(() => - { - using (var memStream = new MemoryStream(SonyDriver.GetInstance().GetLiveView(_camera.Handle))) - { - memStream.Position = 0; + #region Supported Methods - JpegBitmapDecoder decoder = - new JpegBitmapDecoder(memStream, BitmapCreateOptions.IgnoreColorProfile, BitmapCacheOption.OnLoad); + public void StartLiveView(CaptureSequence sequence) { + LiveViewEnabled = true; + } - FormatConvertedBitmap bitmap = new FormatConvertedBitmap(); - bitmap.BeginInit(); - bitmap.Source = decoder.Frames[0]; - bitmap.DestinationFormat = System.Windows.Media.PixelFormats.Gray16; - bitmap.EndInit(); + public void StopLiveView() { + LiveViewEnabled = false; + } - ushort[] outArray = new ushort[bitmap.PixelWidth * bitmap.PixelHeight]; - bitmap.CopyPixels(outArray, 2 * bitmap.PixelWidth, 0); + public Task Connect(CancellationToken token) { + return Task.Run(() => { + try { + _camera = SonyDriver.GetInstance().OpenCamera(_device.Id); + } catch (Exception ex) { + Logger.Error(ex); + _camera = null; + } - var metaData = new ImageMetaData(); + NotifyGainPropertiesChanged(); + return _camera != null; + }); + } - return _exposureDataFactory.CreateImageArrayExposureData( - input: outArray, - width: bitmap.PixelWidth, - height: bitmap.PixelHeight, - bitDepth: 16, - isBayered: false, - metaData: metaData); + public void Disconnect() { + if (_camera != null) { + try { + SonyDriver.GetInstance().CloseCamera(_camera.Handle); + } catch (Exception ex) { + Logger.Error(ex); + } + + _camera = null; + NotifyGainPropertiesChanged(); } - }); - } + } - public void SendCommandBlind(string command, bool raw = true) - { - throw new NotImplementedException(); - } + public Task DownloadLiveView(CancellationToken token) { + return Task.Run(() => { + using (var memStream = new MemoryStream(SonyDriver.GetInstance().GetLiveView(_camera.Handle))) { + memStream.Position = 0; - public bool SendCommandBool(string command, bool raw = true) - { - throw new NotImplementedException(); - } + JpegBitmapDecoder decoder = + new JpegBitmapDecoder(memStream, BitmapCreateOptions.IgnoreColorProfile, BitmapCacheOption.OnLoad); - public string SendCommandString(string command, bool raw = true) - { - throw new NotImplementedException(); - } + FormatConvertedBitmap bitmap = new FormatConvertedBitmap(); + bitmap.BeginInit(); + bitmap.Source = decoder.Frames[0]; + bitmap.DestinationFormat = System.Windows.Media.PixelFormats.Gray16; + bitmap.EndInit(); - public void SetBinning(short x, short y) - { - // Ignore - } + ushort[] outArray = new ushort[bitmap.PixelWidth * bitmap.PixelHeight]; + bitmap.CopyPixels(outArray, 2 * bitmap.PixelWidth, 0); - public void SetupDialog() - { - throw new NotImplementedException(); - } + var metaData = new ImageMetaData(); - public void StartExposure(CaptureSequence sequence) - { - if (_camera != null) - { - SonyDriver driver = SonyDriver.GetInstance(); - lock (_captureLock) - { - _softCancelRequested = false; - if (!TryGetCaptureStatusLocked(driver, out var captureStatus, "start exposure preflight")) - { - Logger.Warning("Cannot start exposure: capture status unavailable."); - throw new TaskCanceledException("Cannot start exposure: capture status unavailable."); + return _exposureDataFactory.CreateImageArrayExposureData( + input: outArray, + width: bitmap.PixelWidth, + height: bitmap.PixelHeight, + bitDepth: 16, + isBayered: false, + metaData: metaData); } + }); + } + public void SetupDialog() { + throw new NotImplementedException(); + } + public void StartExposure(CaptureSequence sequence) { + if (_camera != null) { + SonyDriver driver = SonyDriver.GetInstance(); + lock (_captureLock) { + _softCancelRequested = false; + if (!TryGetCaptureStatusLocked(driver, out var captureStatus, "start exposure preflight")) { + Logger.Warning("Cannot start exposure: capture status unavailable."); + throw new TaskCanceledException("Cannot start exposure: capture status unavailable."); + } + if (BUSY_STATES.Contains(captureStatus)) { + if (_enableNativeCancel) { + TryCancelCapture("start exposure reset"); + } + Notification.ShowWarning("Camera is still busy with a previous exposure. Skipping new start."); + throw new TaskCanceledException("Cannot start exposure: Camera is still busy with a previous exposure."); + } - - if (BUSY_STATES.Contains(captureStatus)) - { - if (_enableNativeCancel) - { - TryCancelCapture("start exposure reset"); + if (!IDLE_STATES.Contains(captureStatus)) { + Logger.Warning($"Cannot start exposure: Camera in unexpected capture status ({captureStatus})."); + throw new TaskCanceledException($"Cannot start exposure: Camera in unexpected capture status ({captureStatus})."); } - Notification.ShowWarning("Camera is still busy with a previous exposure. Skipping new start."); - throw new TaskCanceledException("Cannot start exposure: Camera is still busy with a previous exposure."); - } - if (!IDLE_STATES.Contains(captureStatus)) - { - Logger.Warning($"Cannot start exposure: Camera in unexpected capture status ({captureStatus})."); - throw new TaskCanceledException($"Cannot start exposure: Camera in unexpected capture status ({captureStatus})."); + double exposureTime = sequence.ExposureTime; + driver.StartCapture(_camera.Handle, (float)exposureTime); } - - double exposureTime = sequence.ExposureTime; - driver.StartCapture(_camera.Handle, (float)exposureTime); } } - } - - public void StartLiveView(CaptureSequence sequence) - { - LiveViewEnabled = true; - } - - public void StopExposure() - { - AbortExposure(); - } - - public void StopLiveView() - { - LiveViewEnabled = false; - } - public void UpdateSubSampleArea() - { - if (_camera == null) - { - EnableSubSample = false; - SubSampleX = 0; - SubSampleY = 0; - SubSampleWidth = 0; - SubSampleHeight = 0; - return; + public void StopExposure() { + AbortExposure(); } - if (EnableSubSample && !CanSubSample) - { - Logger.Warning("Sub-sampling requested but not supported for Sony cameras. Falling back to full frame."); - EnableSubSample = false; + public void AbortExposure() { + if (_enableNativeCancel) { + TryCancelCapture("abort request"); + } else { + _softCancelRequested = true; + Logger.Info("AbortExposure requested; native cancel disabled; letting capture finish."); + } } - // Sony cameras currently expose the entire frame, so always reset to the sensor dimensions. - SubSampleX = 0; - SubSampleY = 0; - SubSampleWidth = _camera.ImageSize.Width; - SubSampleHeight = _camera.ImageSize.Height; - } + public async Task WaitUntilExposureIsReady(CancellationToken token) { + using (token.Register(AbortExposure)) { + uint[] completionStates = { CAPTURE_CANCELLED, CAPTURE_COMPLETE, CAPTURE_FAILED }; - public async Task WaitUntilExposureIsReady(CancellationToken token) - { - using (token.Register(AbortExposure)) - { - uint[] completionStates = { CAPTURE_CANCELLED, CAPTURE_COMPLETE, CAPTURE_FAILED }; - - SonyDriver driver = SonyDriver.GetInstance(); - - try - { - uint captureStatus; - lock (_captureLock) - { - if (!TryGetCaptureStatusLocked(driver, out captureStatus, "wait begin")) - { - throw new SonyException("Problem while waiting for image to be ready (status unavailable)"); - } - } - Logger.Info( - $"Waiting for image to be ready, current state is {captureStatus}, completion states are {String.Join(", ", completionStates)}"); - - while (!completionStates.Contains(captureStatus)) - { - await CoreUtil.Wait(TimeSpan.FromMilliseconds(100), token); - if (!_enableNativeCancel && token.IsCancellationRequested) - { - _softCancelRequested = true; - } + SonyDriver driver = SonyDriver.GetInstance(); - lock (_captureLock) - { - if (!TryGetCaptureStatusLocked(driver, out captureStatus, "wait poll")) - { + try { + uint captureStatus; + lock (_captureLock) { + if (!TryGetCaptureStatusLocked(driver, out captureStatus, "wait begin")) { throw new SonyException("Problem while waiting for image to be ready (status unavailable)"); } } - } + Logger.Info( + $"Waiting for image to be ready, current state is {captureStatus}, completion states are {String.Join(", ", completionStates)}"); - Logger.Info($"Wait for image ready complete, completion state is {captureStatus}"); - if (_softCancelRequested || token.IsCancellationRequested) - { - _softCancelRequested = false; - throw new TaskCanceledException("Exposure cancelled by user (soft cancel)."); + while (!completionStates.Contains(captureStatus)) { + await CoreUtil.Wait(TimeSpan.FromMilliseconds(100), token); + if (!_enableNativeCancel && token.IsCancellationRequested) { + _softCancelRequested = true; + } + + lock (_captureLock) { + if (!TryGetCaptureStatusLocked(driver, out captureStatus, "wait poll")) { + throw new SonyException("Problem while waiting for image to be ready (status unavailable)"); + } + } + } + + Logger.Info($"Wait for image ready complete, completion state is {captureStatus}"); + if (_softCancelRequested || token.IsCancellationRequested) { + _softCancelRequested = false; + throw new TaskCanceledException("Exposure cancelled by user (soft cancel)."); + } + } catch (TaskCanceledException) { + Logger.Info("WaitUntilExposureIsReady cancelled by token; exiting without native cancel."); + throw; + } catch (Exception ex) { + Logger.Error("WaitUntilExposureIsReady got exception", ex); + throw new SonyException("Problem while waiting for image to be ready (see log)"); } } - catch (TaskCanceledException) - { - Logger.Info("WaitUntilExposureIsReady cancelled by token; exiting without native cancel."); - throw; - } - catch (Exception ex) - { - Logger.Error("WaitUntilExposureIsReady got exception", ex); - throw new SonyException("Problem while waiting for image to be ready (see log)"); - } } - } - private IReadOnlyList GetAvailableIsoOptions() - { - if (_camera == null) - { - return Array.Empty(); - } + public Task DownloadExposure(CancellationToken token) { + return Task.Run(() => { + byte[] rawImageData = SonyDriver.GetInstance().GetLastImage(); - uint[] propertyCandidates = { PROPID_ISOS, PROPID_ISO }; + var metaData = new ImageMetaData(); - foreach (var propertyId in propertyCandidates) - { - try - { - var options = _camera.GetPropertyInfo(propertyId)?.Options()?.Where(o => o.Value <= 0x00FFFFFF).ToList(); - if (options != null && options.Count > 0) - { - return options; - } - } - catch (Exception ex) - { - Logger.Warning($"Unable to enumerate ISO options for property 0x{propertyId:X}: {ex.Message}"); - } + return _exposureDataFactory.CreateRAWExposureData( + converter: _profileService.ActiveProfile.CameraSettings.RawConverter, + rawBytes: rawImageData, + rawType: "arw", + bitDepth: this.BitDepth, + metaData: metaData); + }); } + #endregion - Logger.Warning("Camera did not report any ISO options via known properties (registry ISO list may be empty until the camera learns it)."); - return Array.Empty(); - } + #region Unsupported Methods - private PropertyValue GetPropertyValue(uint id) - { - return SonyDriver.GetInstance().GetProperty(_camera.Handle, id); - } + public string Action(string actionName, string actionParameters) { + throw new NotImplementedException(); + } - private void NotifyGainPropertiesChanged() - { - RaisePropertyChanged(nameof(CanGetGain)); - RaisePropertyChanged(nameof(CanSetGain)); - RaisePropertyChanged(nameof(GainMin)); - RaisePropertyChanged(nameof(GainMax)); - RaisePropertyChanged(nameof(Gain)); - RaisePropertyChanged(nameof(Gains)); - } - private bool TryCancelCapture(string reason) - { - if (_camera == null) - { - return false; + public void SendCommandBlind(string command, bool raw = true) { + throw new NotImplementedException(); } - if (!_enableNativeCancel) - { - Logger.Info($"Native cancel disabled; skipping cancel ({reason})"); - return false; + public bool SendCommandBool(string command, bool raw = true) { + throw new NotImplementedException(); } - lock (_captureLock) - { - try - { - SonyDriver driver = SonyDriver.GetInstance(); - if (!TryGetCaptureStatusLocked(driver, out var status, reason)) - { - return false; - } - - if (!CANCELLABLE_STATES.Contains(status)) - { - Logger.Debug($"Skip cancel ({reason}); capture status is {status}"); - return false; - } + public string SendCommandString(string command, bool raw = true) { + throw new NotImplementedException(); + } - Logger.Info($"Issuing cancel ({reason}); capture status is {status}"); - driver.CancelCapture(_camera.Handle); - return true; + public void SetBinning(short x, short y) { + // Ignore + } + + public void UpdateSubSampleArea() { + if (_camera == null) { + EnableSubSample = false; + SubSampleX = 0; + SubSampleY = 0; + SubSampleWidth = 0; + SubSampleHeight = 0; + return; } - catch (Exception ex) - { - Logger.Error($"CancelCapture failed ({reason})", ex); - return false; + + if (EnableSubSample && !CanSubSample) { + Logger.Warning("Sub-sampling requested but not supported for Sony cameras. Falling back to full frame."); + EnableSubSample = false; } - } - } - private bool TryGetCaptureStatusLocked(SonyDriver driver, out uint status, string reason) - { - try - { - status = driver.GetCaptureStatus(_camera.Handle); - return true; - } - catch (Exception ex) - { - Logger.Warning($"Unable to get capture status ({reason}): {ex.Message}"); - status = CAPTURE_FAILED; - return false; + // Sony cameras currently expose the entire frame, so always reset to the sensor dimensions. + SubSampleX = 0; + SubSampleY = 0; + SubSampleWidth = _camera.ImageSize.Width; + SubSampleHeight = _camera.ImageSize.Height; } - } - #endregion -} \ No newline at end of file + #endregion + + + // TODO!!! WE NEED ONE + public bool HasSetupDialog => false; + + public IList SupportedActions => new List(); + } +} From 886f2c2e6a265bb1b43becc4f9c5adfbc752eced Mon Sep 17 00:00:00 2001 From: Lucas Lepski Date: Mon, 8 Dec 2025 12:16:09 +0100 Subject: [PATCH 13/45] Handle start/reset and soft cancel --- Drivers/CameraDriver.cs | 32 ++++++++++++++++++++++++++++---- 1 file changed, 28 insertions(+), 4 deletions(-) diff --git a/Drivers/CameraDriver.cs b/Drivers/CameraDriver.cs index c34a917..8dffb51 100644 --- a/Drivers/CameraDriver.cs +++ b/Drivers/CameraDriver.cs @@ -569,6 +569,7 @@ public void StartExposure(CaptureSequence sequence) { if (_camera != null) { SonyDriver driver = SonyDriver.GetInstance(); lock (_captureLock) { + bool issuedCancel = false; _softCancelRequested = false; if (!TryGetCaptureStatusLocked(driver, out var captureStatus, "start exposure preflight")) { Logger.Warning("Cannot start exposure: capture status unavailable."); @@ -577,10 +578,22 @@ public void StartExposure(CaptureSequence sequence) { if (BUSY_STATES.Contains(captureStatus)) { if (_enableNativeCancel) { - TryCancelCapture("start exposure reset"); + try { + Logger.Info($"Cancelling existing capture before starting new one; status {captureStatus}"); + driver.CancelCapture(_camera.Handle); + issuedCancel = true; + if (!TryGetCaptureStatusLocked(driver, out captureStatus, "start exposure post-cancel")) { + Logger.Warning("Cannot start exposure: capture status unavailable after cancel."); + throw new TaskCanceledException("Cannot start exposure: capture status unavailable after cancel."); + } + } catch (Exception ex) { + Logger.Error("CancelCapture failed before start", ex); + } + } + if (BUSY_STATES.Contains(captureStatus)) { + Notification.ShowWarning("Camera is still busy with a previous exposure. Skipping new start."); + throw new TaskCanceledException("Cannot start exposure: Camera is still busy with a previous exposure."); } - Notification.ShowWarning("Camera is still busy with a previous exposure. Skipping new start."); - throw new TaskCanceledException("Cannot start exposure: Camera is still busy with a previous exposure."); } if (!IDLE_STATES.Contains(captureStatus)) { @@ -588,6 +601,16 @@ public void StartExposure(CaptureSequence sequence) { throw new TaskCanceledException($"Cannot start exposure: Camera in unexpected capture status ({captureStatus})."); } + // Reset capture state for bodies that require a pre-start cancel, but only when native cancel is enabled. + if (_enableNativeCancel && !issuedCancel) { + try { + driver.CancelCapture(_camera.Handle); + issuedCancel = true; + } catch (Exception ex) { + Logger.Warning($"Pre-start CancelCapture failed; continuing start. {ex.Message}"); + } + } + double exposureTime = sequence.ExposureTime; driver.StartCapture(_camera.Handle, (float)exposureTime); } @@ -624,7 +647,8 @@ public async Task WaitUntilExposureIsReady(CancellationToken token) { $"Waiting for image to be ready, current state is {captureStatus}, completion states are {String.Join(", ", completionStates)}"); while (!completionStates.Contains(captureStatus)) { - await CoreUtil.Wait(TimeSpan.FromMilliseconds(100), token); + var waitToken = _enableNativeCancel ? token : CancellationToken.None; + await CoreUtil.Wait(TimeSpan.FromMilliseconds(100), waitToken); if (!_enableNativeCancel && token.IsCancellationRequested) { _softCancelRequested = true; } From d035092914073db54ad75d59d2225bc89464e8a5 Mon Sep 17 00:00:00 2001 From: Lucas Lepski Date: Mon, 8 Dec 2025 12:19:38 +0100 Subject: [PATCH 14/45] Simplify cancel constants and setting lookup --- Drivers/CameraDriver.cs | 3 +-- Drivers/CameraProvider.cs | 2 +- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/Drivers/CameraDriver.cs b/Drivers/CameraDriver.cs index 8dffb51..4242e01 100644 --- a/Drivers/CameraDriver.cs +++ b/Drivers/CameraDriver.cs @@ -40,7 +40,6 @@ public class CameraDriver : BaseINPC, ICamera { private const uint CAPTURE_PROCESSING = 0x8003; private static readonly uint[] IDLE_STATES = { CAPTURE_CREATED, CAPTURE_CANCELLED, CAPTURE_COMPLETE, CAPTURE_FAILED }; private static readonly uint[] BUSY_STATES = { CAPTURE_CAPTURING, CAPTURE_PROCESSING, CAPTURE_STARTING, CAPTURE_READING }; - private static readonly uint[] CANCELLABLE_STATES = BUSY_STATES; private readonly bool _enableNativeCancel; private bool _softCancelRequested; @@ -116,7 +115,7 @@ private bool TryCancelCapture(string reason) { return false; } - if (!CANCELLABLE_STATES.Contains(status)) { + if (!BUSY_STATES.Contains(status)) { Logger.Debug($"Skip cancel ({reason}); capture status is {status}"); return false; } diff --git a/Drivers/CameraProvider.cs b/Drivers/CameraProvider.cs index d2a0eff..6c872fc 100644 --- a/Drivers/CameraProvider.cs +++ b/Drivers/CameraProvider.cs @@ -52,7 +52,7 @@ public IList GetEquipment() { var devices = new List(); bool enableNativeCancel = false; try { - var raw = pluginSettings.GetValueString("EnableNativeCancel", bool.FalseString); + var raw = pluginSettings.GetValueString(nameof(EnableNativeCancel), bool.FalseString); enableNativeCancel = bool.TryParse(raw, out var parsed) && parsed; } catch (Exception ex) { Logger.Warning($"Unable to read EnableNativeCancel setting; defaulting to false. {ex.Message}"); From e0e8601759703d70fc37a808a832d61946e909ad Mon Sep 17 00:00:00 2001 From: Lucas Lepski Date: Mon, 8 Dec 2025 12:22:33 +0100 Subject: [PATCH 15/45] Simplify StartExposure cancel path --- Drivers/CameraDriver.cs | 18 +++++------------- 1 file changed, 5 insertions(+), 13 deletions(-) diff --git a/Drivers/CameraDriver.cs b/Drivers/CameraDriver.cs index 4242e01..ee9af82 100644 --- a/Drivers/CameraDriver.cs +++ b/Drivers/CameraDriver.cs @@ -568,7 +568,6 @@ public void StartExposure(CaptureSequence sequence) { if (_camera != null) { SonyDriver driver = SonyDriver.GetInstance(); lock (_captureLock) { - bool issuedCancel = false; _softCancelRequested = false; if (!TryGetCaptureStatusLocked(driver, out var captureStatus, "start exposure preflight")) { Logger.Warning("Cannot start exposure: capture status unavailable."); @@ -577,16 +576,10 @@ public void StartExposure(CaptureSequence sequence) { if (BUSY_STATES.Contains(captureStatus)) { if (_enableNativeCancel) { - try { - Logger.Info($"Cancelling existing capture before starting new one; status {captureStatus}"); - driver.CancelCapture(_camera.Handle); - issuedCancel = true; - if (!TryGetCaptureStatusLocked(driver, out captureStatus, "start exposure post-cancel")) { - Logger.Warning("Cannot start exposure: capture status unavailable after cancel."); - throw new TaskCanceledException("Cannot start exposure: capture status unavailable after cancel."); - } - } catch (Exception ex) { - Logger.Error("CancelCapture failed before start", ex); + TryCancelCapture("start exposure reset"); + if (!TryGetCaptureStatusLocked(driver, out captureStatus, "start exposure post-cancel")) { + Logger.Warning("Cannot start exposure: capture status unavailable after cancel."); + throw new TaskCanceledException("Cannot start exposure: capture status unavailable after cancel."); } } if (BUSY_STATES.Contains(captureStatus)) { @@ -601,10 +594,9 @@ public void StartExposure(CaptureSequence sequence) { } // Reset capture state for bodies that require a pre-start cancel, but only when native cancel is enabled. - if (_enableNativeCancel && !issuedCancel) { + if (_enableNativeCancel) { try { driver.CancelCapture(_camera.Handle); - issuedCancel = true; } catch (Exception ex) { Logger.Warning($"Pre-start CancelCapture failed; continuing start. {ex.Message}"); } From be446160705ba8b1e1d84eed0e0fa786d1535fad Mon Sep 17 00:00:00 2001 From: Lucas Lepski Date: Mon, 8 Dec 2025 12:25:55 +0100 Subject: [PATCH 16/45] move to static var --- Drivers/CameraDriver.cs | 529 +++++++++++++++++++++++++++------------- 1 file changed, 357 insertions(+), 172 deletions(-) diff --git a/Drivers/CameraDriver.cs b/Drivers/CameraDriver.cs index ee9af82..44fde91 100644 --- a/Drivers/CameraDriver.cs +++ b/Drivers/CameraDriver.cs @@ -22,24 +22,27 @@ using NINA.Profile.Interfaces; using Sony; -namespace NINA.RetroKiwi.Plugin.SonyCamera.Drivers { - public class CameraDriver : BaseINPC, ICamera { +namespace NINA.RetroKiwi.Plugin.SonyCamera.Drivers +{ + public class CameraDriver : BaseINPC, ICamera + { // Some camera settings we are interested in private const uint PROPID_BATTERY = 53784; private const uint PROPID_ISO = 0xD21E; // Actual ISO currently set private const uint PROPID_ISOS = 0xFFFE; // Registry-backed list of learnt ISOs (may be empty until learnt) // Capture Status - private const uint CAPTURE_CREATED = 0x0000; - private const uint CAPTURE_CAPTURING = 0x0001; - private const uint CAPTURE_FAILED = 0x0002; - private const uint CAPTURE_CANCELLED = 0x0003; - private const uint CAPTURE_COMPLETE = 0x0004; - private const uint CAPTURE_STARTING = 0x8001; - private const uint CAPTURE_READING = 0x8002; + private const uint CAPTURE_CREATED = 0x0000; + private const uint CAPTURE_CAPTURING = 0x0001; + private const uint CAPTURE_FAILED = 0x0002; + private const uint CAPTURE_CANCELLED = 0x0003; + private const uint CAPTURE_COMPLETE = 0x0004; + private const uint CAPTURE_STARTING = 0x8001; + private const uint CAPTURE_READING = 0x8002; private const uint CAPTURE_PROCESSING = 0x8003; private static readonly uint[] IDLE_STATES = { CAPTURE_CREATED, CAPTURE_CANCELLED, CAPTURE_COMPLETE, CAPTURE_FAILED }; private static readonly uint[] BUSY_STATES = { CAPTURE_CAPTURING, CAPTURE_PROCESSING, CAPTURE_STARTING, CAPTURE_READING }; + private static readonly uint[] COMPLETION_STATES = { CAPTURE_CANCELLED, CAPTURE_COMPLETE, CAPTURE_FAILED }; private readonly bool _enableNativeCancel; private bool _softCancelRequested; @@ -53,7 +56,8 @@ public class CameraDriver : BaseINPC, ICamera { private AsyncObservableCollection _binningModes; private readonly object _captureLock = new object(); - public CameraDriver(IProfileService profileService, IExposureDataFactory exposureDataFactory, SonyDevice device, bool enableNativeCancel) { + public CameraDriver(IProfileService profileService, IExposureDataFactory exposureDataFactory, SonyDevice device, bool enableNativeCancel) + { _profileService = profileService; _exposureDataFactory = exposureDataFactory; _device = device; @@ -63,24 +67,32 @@ public CameraDriver(IProfileService profileService, IExposureDataFactory exposur #region Internal Helpers - private PropertyValue GetPropertyValue(uint id) { + private PropertyValue GetPropertyValue(uint id) + { return SonyDriver.GetInstance().GetProperty(_camera.Handle, id); } - private IReadOnlyList GetAvailableIsoOptions() { - if (_camera == null) { + private IReadOnlyList GetAvailableIsoOptions() + { + if (_camera == null) + { return Array.Empty(); } uint[] propertyCandidates = { PROPID_ISOS, PROPID_ISO }; - foreach (var propertyId in propertyCandidates) { - try { + foreach (var propertyId in propertyCandidates) + { + try + { var options = _camera.GetPropertyInfo(propertyId)?.Options()?.Where(o => o.Value <= 0x00FFFFFF).ToList(); - if (options != null && options.Count > 0) { + if (options != null && options.Count > 0) + { return options; } - } catch (Exception ex) { + } + catch (Exception ex) + { Logger.Warning($"Unable to enumerate ISO options for property 0x{propertyId:X}: {ex.Message}"); } } @@ -89,7 +101,8 @@ private IReadOnlyList GetAvailableIsoOptions() { return Array.Empty(); } - private void NotifyGainPropertiesChanged() { + private void NotifyGainPropertiesChanged() + { RaisePropertyChanged(nameof(CanGetGain)); RaisePropertyChanged(nameof(CanSetGain)); RaisePropertyChanged(nameof(GainMin)); @@ -98,24 +111,31 @@ private void NotifyGainPropertiesChanged() { RaisePropertyChanged(nameof(Gains)); } - private bool TryCancelCapture(string reason) { - if (_camera == null) { + private bool TryCancelCapture(string reason) + { + if (_camera == null) + { return false; } - if (!_enableNativeCancel) { + if (!_enableNativeCancel) + { Logger.Info($"Native cancel disabled; skipping cancel ({reason})"); return false; } - lock (_captureLock) { - try { + lock (_captureLock) + { + try + { SonyDriver driver = SonyDriver.GetInstance(); - if (!TryGetCaptureStatusLocked(driver, out var status, reason)) { + if (!TryGetCaptureStatusLocked(driver, out var status, reason)) + { return false; } - if (!BUSY_STATES.Contains(status)) { + if (!BUSY_STATES.Contains(status)) + { Logger.Debug($"Skip cancel ({reason}); capture status is {status}"); return false; } @@ -123,18 +143,24 @@ private bool TryCancelCapture(string reason) { Logger.Info($"Issuing cancel ({reason}); capture status is {status}"); driver.CancelCapture(_camera.Handle); return true; - } catch (Exception ex) { + } + catch (Exception ex) + { Logger.Error($"CancelCapture failed ({reason})", ex); return false; } } } - private bool TryGetCaptureStatusLocked(SonyDriver driver, out uint status, string reason) { - try { + private bool TryGetCaptureStatusLocked(SonyDriver driver, out uint status, string reason) + { + try + { status = driver.GetCaptureStatus(_camera.Handle); return true; - } catch (Exception ex) { + } + catch (Exception ex) + { Logger.Warning($"Unable to get capture status ({reason}): {ex.Message}"); status = CAPTURE_FAILED; return false; @@ -150,7 +176,8 @@ private bool TryGetCaptureStatusLocked(SonyDriver driver, out uint status, strin // Although the driver supports camera temperature, it gets it from the ARW's // metadata after a photo is taken, because this code doesn't request processed // ARW, the temp cannot be determined. - public double Temperature { + public double Temperature + { get => double.NaN; /*{ @@ -167,11 +194,16 @@ public double Temperature { public short BinX { get => 1; set => throw new NotImplementedException(); } public short BinY { get => 1; set => throw new NotImplementedException(); } - public string SensorName { - get { - if (_camera != null) { + public string SensorName + { + get + { + if (_camera != null) + { return _camera.SensorName; - } else { + } + else + { return string.Empty; } } @@ -183,42 +215,61 @@ public string SensorName { public short BayerOffsetY { get => 1; set => throw new NotImplementedException(); } - public int CameraXSize { - get { - if (_camera != null) { + public int CameraXSize + { + get + { + if (_camera != null) + { return _camera.ImageSize.Width; } - else { + else + { return 0; } } } - public int CameraYSize { - get { - if (_camera != null) { + public int CameraYSize + { + get + { + if (_camera != null) + { return _camera.ImageSize.Height; - } else { + } + else + { return 0; } } } - public double ExposureMin { - get { - if (_camera != null) { + public double ExposureMin + { + get + { + if (_camera != null) + { return _camera.ExposureMin; - } else { + } + else + { return double.NaN; } } } - public double ExposureMax { - get { - if (_camera != null) { + public double ExposureMax + { + get + { + if (_camera != null) + { return _camera.ExposureMax; - } else { + } + else + { return double.NaN; } } @@ -228,21 +279,31 @@ public double ExposureMax { public short MaxBinY { get => 1; set => throw new NotImplementedException(); } - public double PixelSizeX { - get { - if (_camera != null) { + public double PixelSizeX + { + get + { + if (_camera != null) + { return _camera.PixelWidth; - } else { + } + else + { return double.NaN; } } } - public double PixelSizeY { - get { - if (_camera != null) { + public double PixelSizeY + { + get + { + if (_camera != null) + { return _camera.PixelHeight; - } else { + } + else + { return double.NaN; } } @@ -252,19 +313,26 @@ public double PixelSizeY { public CameraStates CameraState => CameraStates.NoState; // TODO - public bool CanShowLiveView { - get { - if (_camera != null) { + public bool CanShowLiveView + { + get + { + if (_camera != null) + { return _camera.SupportsPreview(); - } else { + } + else + { return false; } } } - public bool LiveViewEnabled { + public bool LiveViewEnabled + { get => _liveViewEnabled; - set { + set + { _liveViewEnabled = value; RaisePropertyChanged(); } @@ -272,21 +340,31 @@ public bool LiveViewEnabled { public bool HasBattery => true; - public int BatteryLevel { - get { - if (_camera != null) { + public int BatteryLevel + { + get + { + if (_camera != null) + { return (int)GetPropertyValue(PROPID_BATTERY).Value; - } else { + } + else + { return 0; } } } - public int BitDepth { - get { - if (_camera != null) { + public int BitDepth + { + get + { + if (_camera != null) + { return _camera.BitsPerPixel; - } else { + } + else + { return 0; } } @@ -296,11 +374,15 @@ public int BitDepth { public bool CanSetGain => CanGetGain; - public int GainMax { - get { + public int GainMax + { + get + { var isoOptions = GetAvailableIsoOptions(); - if (!isoOptions.Any()) { - if (_camera != null) { + if (!isoOptions.Any()) + { + if (_camera != null) + { Logger.Error("Problem getting gain max: camera did not report ISO options."); } return -1; @@ -310,11 +392,15 @@ public int GainMax { } } - public int GainMin { - get { + public int GainMin + { + get + { var isoOptions = GetAvailableIsoOptions(); - if (!isoOptions.Any()) { - if (_camera != null) { + if (!isoOptions.Any()) + { + if (_camera != null) + { Logger.Error("Problem getting gain min: camera did not report ISO options."); } return -1; @@ -324,42 +410,61 @@ public int GainMin { } } - public int Gain { - get { - if (_camera != null) { - try { + public int Gain + { + get + { + if (_camera != null) + { + try + { PropertyValue value = GetPropertyValue(PROPID_ISO); return (int)(value.Value == 0xffffff ? 0 : value.Value); - } catch (Exception ex) { + } + catch (Exception ex) + { Logger.Error("Problem getting gain", ex); return -1; } - } else { + } + else + { return -1; } } - set { - if (_camera != null) { - try { + set + { + if (_camera != null) + { + try + { SonyDriver.GetInstance().SetProperty(_camera.Handle, PROPID_ISO, (uint)value); RaisePropertyChanged(nameof(Gain)); - } catch (Exception ex) { + } + catch (Exception ex) + { Logger.Error($"Problem setting gain to {value}", ex); } } } } - public IList Gains { - get { + public IList Gains + { + get + { List gains = new List(); - foreach (var iso in GetAvailableIsoOptions()) { - if (iso.Value == 0xffffff) { + foreach (var iso in GetAvailableIsoOptions()) + { + if (iso.Value == 0xffffff) + { gains.Add(0); // AUTO - } else { + } + else + { gains.Add((int)iso.Value); } } @@ -370,29 +475,38 @@ public IList Gains { public string Id => "Sony"; - public string Name { + public string Name + { get => _device.Model; set => throw new NotImplementedException(); } - public string DisplayName { + public string DisplayName + { get => _device.Model; set => throw new NotImplementedException(); } public string Category { get => "Sony"; } - public bool Connected { - get { + public bool Connected + { + get + { return _camera != null; } } - public string Description { - get { - if (_camera != null) { + public string Description + { + get + { + if (_camera != null) + { return _camera.GetDescription(); - } else { + } + else + { return _device.GetDescription(); } } @@ -402,10 +516,12 @@ public string Description { public string DriverVersion => string.Empty; - public double TemperatureSetPoint { + public double TemperatureSetPoint + { get => double.NaN; - set { + set + { } } @@ -421,9 +537,11 @@ public double TemperatureSetPoint { public int SubSampleHeight { get; set; } - public bool CoolerOn { + public bool CoolerOn + { get => false; - set { + set + { } } @@ -431,9 +549,11 @@ public bool CoolerOn { public bool HasDewHeater => false; - public bool DewHeaterOn { + public bool DewHeaterOn + { get => false; - set { + set + { } } @@ -459,30 +579,38 @@ public bool DewHeaterOn { public IList ReadoutModes => new List { "Default" }; - public short ReadoutMode { + public short ReadoutMode + { get => 0; set { } } - public short ReadoutModeForSnapImages { + public short ReadoutModeForSnapImages + { get => _readoutModeForSnapImages; - set { + set + { _readoutModeForSnapImages = value; RaisePropertyChanged(); } } - public short ReadoutModeForNormalImages { + public short ReadoutModeForNormalImages + { get => _readoutModeForNormalImages; - set { + set + { _readoutModeForNormalImages = value; RaisePropertyChanged(); } } - public AsyncObservableCollection BinningModes { - get { - if (_binningModes == null) { + public AsyncObservableCollection BinningModes + { + get + { + if (_binningModes == null) + { _binningModes = new AsyncObservableCollection(); _binningModes.Add(new BinningMode(1, 1)); } @@ -495,19 +623,26 @@ public AsyncObservableCollection BinningModes { #region Supported Methods - public void StartLiveView(CaptureSequence sequence) { + public void StartLiveView(CaptureSequence sequence) + { LiveViewEnabled = true; } - public void StopLiveView() { + public void StopLiveView() + { LiveViewEnabled = false; } - public Task Connect(CancellationToken token) { - return Task.Run(() => { - try { + public Task Connect(CancellationToken token) + { + return Task.Run(() => + { + try + { _camera = SonyDriver.GetInstance().OpenCamera(_device.Id); - } catch (Exception ex) { + } + catch (Exception ex) + { Logger.Error(ex); _camera = null; } @@ -517,11 +652,16 @@ public Task Connect(CancellationToken token) { }); } - public void Disconnect() { - if (_camera != null) { - try { + public void Disconnect() + { + if (_camera != null) + { + try + { SonyDriver.GetInstance().CloseCamera(_camera.Handle); - } catch (Exception ex) { + } + catch (Exception ex) + { Logger.Error(ex); } @@ -530,9 +670,12 @@ public void Disconnect() { } } - public Task DownloadLiveView(CancellationToken token) { - return Task.Run(() => { - using (var memStream = new MemoryStream(SonyDriver.GetInstance().GetLiveView(_camera.Handle))) { + public Task DownloadLiveView(CancellationToken token) + { + return Task.Run(() => + { + using (var memStream = new MemoryStream(SonyDriver.GetInstance().GetLiveView(_camera.Handle))) + { memStream.Position = 0; JpegBitmapDecoder decoder = @@ -560,44 +703,58 @@ public Task DownloadLiveView(CancellationToken token) { }); } - public void SetupDialog() { + public void SetupDialog() + { throw new NotImplementedException(); } - public void StartExposure(CaptureSequence sequence) { - if (_camera != null) { + public void StartExposure(CaptureSequence sequence) + { + if (_camera != null) + { SonyDriver driver = SonyDriver.GetInstance(); - lock (_captureLock) { + lock (_captureLock) + { _softCancelRequested = false; - if (!TryGetCaptureStatusLocked(driver, out var captureStatus, "start exposure preflight")) { + if (!TryGetCaptureStatusLocked(driver, out var captureStatus, "start exposure preflight")) + { Logger.Warning("Cannot start exposure: capture status unavailable."); throw new TaskCanceledException("Cannot start exposure: capture status unavailable."); } - if (BUSY_STATES.Contains(captureStatus)) { - if (_enableNativeCancel) { + if (BUSY_STATES.Contains(captureStatus)) + { + if (_enableNativeCancel) + { TryCancelCapture("start exposure reset"); - if (!TryGetCaptureStatusLocked(driver, out captureStatus, "start exposure post-cancel")) { + if (!TryGetCaptureStatusLocked(driver, out captureStatus, "start exposure post-cancel")) + { Logger.Warning("Cannot start exposure: capture status unavailable after cancel."); throw new TaskCanceledException("Cannot start exposure: capture status unavailable after cancel."); } } - if (BUSY_STATES.Contains(captureStatus)) { + if (BUSY_STATES.Contains(captureStatus)) + { Notification.ShowWarning("Camera is still busy with a previous exposure. Skipping new start."); throw new TaskCanceledException("Cannot start exposure: Camera is still busy with a previous exposure."); } } - if (!IDLE_STATES.Contains(captureStatus)) { + if (!IDLE_STATES.Contains(captureStatus)) + { Logger.Warning($"Cannot start exposure: Camera in unexpected capture status ({captureStatus})."); throw new TaskCanceledException($"Cannot start exposure: Camera in unexpected capture status ({captureStatus})."); } // Reset capture state for bodies that require a pre-start cancel, but only when native cancel is enabled. - if (_enableNativeCancel) { - try { + if (_enableNativeCancel) + { + try + { driver.CancelCapture(_camera.Handle); - } catch (Exception ex) { + } + catch (Exception ex) + { Logger.Warning($"Pre-start CancelCapture failed; continuing start. {ex.Message}"); } } @@ -608,66 +765,86 @@ public void StartExposure(CaptureSequence sequence) { } } - public void StopExposure() { + public void StopExposure() + { AbortExposure(); } - public void AbortExposure() { - if (_enableNativeCancel) { + public void AbortExposure() + { + if (_enableNativeCancel) + { TryCancelCapture("abort request"); - } else { + } + else + { _softCancelRequested = true; Logger.Info("AbortExposure requested; native cancel disabled; letting capture finish."); } } - public async Task WaitUntilExposureIsReady(CancellationToken token) { - using (token.Register(AbortExposure)) { - uint[] completionStates = { CAPTURE_CANCELLED, CAPTURE_COMPLETE, CAPTURE_FAILED }; + public async Task WaitUntilExposureIsReady(CancellationToken token) + { + using (token.Register(AbortExposure)) + { SonyDriver driver = SonyDriver.GetInstance(); - try { + try + { uint captureStatus; - lock (_captureLock) { - if (!TryGetCaptureStatusLocked(driver, out captureStatus, "wait begin")) { + lock (_captureLock) + { + if (!TryGetCaptureStatusLocked(driver, out captureStatus, "wait begin")) + { throw new SonyException("Problem while waiting for image to be ready (status unavailable)"); } } Logger.Info( - $"Waiting for image to be ready, current state is {captureStatus}, completion states are {String.Join(", ", completionStates)}"); + $"Waiting for image to be ready, current state is {captureStatus}, completion states are {String.Join(", ", COMPLETION_STATES)}"); - while (!completionStates.Contains(captureStatus)) { + while (!COMPLETION_STATES.Contains(captureStatus)) + { var waitToken = _enableNativeCancel ? token : CancellationToken.None; await CoreUtil.Wait(TimeSpan.FromMilliseconds(100), waitToken); - if (!_enableNativeCancel && token.IsCancellationRequested) { + if (!_enableNativeCancel && token.IsCancellationRequested) + { _softCancelRequested = true; } - lock (_captureLock) { - if (!TryGetCaptureStatusLocked(driver, out captureStatus, "wait poll")) { + lock (_captureLock) + { + if (!TryGetCaptureStatusLocked(driver, out captureStatus, "wait poll")) + { throw new SonyException("Problem while waiting for image to be ready (status unavailable)"); } } } Logger.Info($"Wait for image ready complete, completion state is {captureStatus}"); - if (_softCancelRequested || token.IsCancellationRequested) { + if (_softCancelRequested || token.IsCancellationRequested) + { _softCancelRequested = false; throw new TaskCanceledException("Exposure cancelled by user (soft cancel)."); } - } catch (TaskCanceledException) { + } + catch (TaskCanceledException) + { Logger.Info("WaitUntilExposureIsReady cancelled by token; exiting without native cancel."); throw; - } catch (Exception ex) { + } + catch (Exception ex) + { Logger.Error("WaitUntilExposureIsReady got exception", ex); throw new SonyException("Problem while waiting for image to be ready (see log)"); } } } - public Task DownloadExposure(CancellationToken token) { - return Task.Run(() => { + public Task DownloadExposure(CancellationToken token) + { + return Task.Run(() => + { byte[] rawImageData = SonyDriver.GetInstance().GetLastImage(); var metaData = new ImageMetaData(); @@ -684,29 +861,36 @@ public Task DownloadExposure(CancellationToken token) { #region Unsupported Methods - public string Action(string actionName, string actionParameters) { + public string Action(string actionName, string actionParameters) + { throw new NotImplementedException(); } - public void SendCommandBlind(string command, bool raw = true) { + public void SendCommandBlind(string command, bool raw = true) + { throw new NotImplementedException(); } - public bool SendCommandBool(string command, bool raw = true) { + public bool SendCommandBool(string command, bool raw = true) + { throw new NotImplementedException(); } - public string SendCommandString(string command, bool raw = true) { + public string SendCommandString(string command, bool raw = true) + { throw new NotImplementedException(); } - public void SetBinning(short x, short y) { + public void SetBinning(short x, short y) + { // Ignore } - - public void UpdateSubSampleArea() { - if (_camera == null) { + + public void UpdateSubSampleArea() + { + if (_camera == null) + { EnableSubSample = false; SubSampleX = 0; SubSampleY = 0; @@ -715,7 +899,8 @@ public void UpdateSubSampleArea() { return; } - if (EnableSubSample && !CanSubSample) { + if (EnableSubSample && !CanSubSample) + { Logger.Warning("Sub-sampling requested but not supported for Sony cameras. Falling back to full frame."); EnableSubSample = false; } From fca3e40b88882bbaf86a2e390860c1f223b0b8a8 Mon Sep 17 00:00:00 2001 From: Lucas Lepski Date: Mon, 8 Dec 2025 12:28:16 +0100 Subject: [PATCH 17/45] move native cancel capture hint --- Options.xaml | 2 +- Properties/AssemblyInfo.cs | 2 -- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/Options.xaml b/Options.xaml index 369b42b..ed1e99e 100644 --- a/Options.xaml +++ b/Options.xaml @@ -12,7 +12,7 @@ + ToolTip="When enabled, the driver will call the camera's native CancelCapture. Native CancelCapture stability varies by camera model and Windows driver stack. It can cause VCRUNTIME crashes. Disable if aborts cause Nina crashes." /> diff --git a/Properties/AssemblyInfo.cs b/Properties/AssemblyInfo.cs index c4d01b7..b4e7953 100644 --- a/Properties/AssemblyInfo.cs +++ b/Properties/AssemblyInfo.cs @@ -33,8 +33,6 @@ [assembly: AssemblyMetadata("LicenseURL", "https://www.mozilla.org/en-US/MPL/2.0/")] // The repository where your pluggin is hosted [assembly: AssemblyMetadata("Repository", "https://github.com/dougforpres/NINASonyCameraPlugin")] -// Note on native cancel: stability depends on camera model and Windows driver stack; leave disabled if aborts crash. -[assembly: AssemblyMetadata("NativeCancelHint", "Native CancelCapture stability varies by camera model and Windows driver stack; disable if aborts crash.")] // The following attributes are optional for the official manifest meta data From d6db836438779202ea43908f099089b49fcb1065 Mon Sep 17 00:00:00 2001 From: Lucas Lepski Date: Mon, 8 Dec 2025 12:30:07 +0100 Subject: [PATCH 18/45] Fix EnableNativeCancel nameof reference --- Drivers/CameraProvider.cs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Drivers/CameraProvider.cs b/Drivers/CameraProvider.cs index 6c872fc..48a714e 100644 --- a/Drivers/CameraProvider.cs +++ b/Drivers/CameraProvider.cs @@ -12,6 +12,7 @@ using System.ComponentModel.Composition; using System.Reflection; using System.Runtime.InteropServices; +using NINA.RetroKiwi.Plugin.SonyCamera; using NINA.Image.Interfaces; using NINA.WPF.Base.Mediator; using Sony; @@ -52,7 +53,7 @@ public IList GetEquipment() { var devices = new List(); bool enableNativeCancel = false; try { - var raw = pluginSettings.GetValueString(nameof(EnableNativeCancel), bool.FalseString); + var raw = pluginSettings.GetValueString(nameof(SonyCamera.EnableNativeCancel), bool.FalseString); enableNativeCancel = bool.TryParse(raw, out var parsed) && parsed; } catch (Exception ex) { Logger.Warning($"Unable to read EnableNativeCancel setting; defaulting to false. {ex.Message}"); From d17804606890e756dbc90e79e496b0470df65568 Mon Sep 17 00:00:00 2001 From: Lucas Lepski Date: Mon, 8 Dec 2025 12:33:20 +0100 Subject: [PATCH 19/45] Inject plugin GUID instead of reflecting --- Drivers/CameraProvider.cs | 6 +----- SonyCameraPlugin.cs | 3 ++- 2 files changed, 3 insertions(+), 6 deletions(-) diff --git a/Drivers/CameraProvider.cs b/Drivers/CameraProvider.cs index 48a714e..df536df 100644 --- a/Drivers/CameraProvider.cs +++ b/Drivers/CameraProvider.cs @@ -10,8 +10,6 @@ using System.Threading; using System.Threading.Tasks; using System.ComponentModel.Composition; -using System.Reflection; -using System.Runtime.InteropServices; using NINA.RetroKiwi.Plugin.SonyCamera; using NINA.Image.Interfaces; using NINA.WPF.Base.Mediator; @@ -29,14 +27,12 @@ public class CameraProvider : IEquipmentProvider { private IExposureDataFactory exposureDataFactory; SonyDriver driver; private readonly PluginOptionsAccessor pluginSettings; - private static readonly Guid PluginGuid = - Guid.Parse(((GuidAttribute)Attribute.GetCustomAttribute(typeof(CameraProvider).Assembly, typeof(GuidAttribute))).Value); [ImportingConstructor] public CameraProvider(IProfileService profileService, IExposureDataFactory exposureDataFactory) { this.profileService = profileService; this.exposureDataFactory = exposureDataFactory; - this.pluginSettings = new PluginOptionsAccessor(profileService, PluginGuid); + this.pluginSettings = new PluginOptionsAccessor(profileService, SonyCamera.PluginGuid); if (!DllLoader.IsX86()) { try { diff --git a/SonyCameraPlugin.cs b/SonyCameraPlugin.cs index b64a645..74b6fbf 100644 --- a/SonyCameraPlugin.cs +++ b/SonyCameraPlugin.cs @@ -28,6 +28,7 @@ namespace NINA.RetroKiwi.Plugin.SonyCamera { /// [Export(typeof(IPluginManifest))] public class SonyCamera : PluginBase, INotifyPropertyChanged { + public static readonly Guid PluginGuid = Guid.Parse("f3fd7bb5-2b69-40cc-846f-4f4a2ff62518"); private readonly IPluginOptionsAccessor pluginSettings; private readonly IProfileService profileService; private readonly IImageSaveMediator imageSaveMediator; @@ -44,7 +45,7 @@ public SonyCamera(IProfileService profileService, IOptionsVM options, IImageSave } // This helper class can be used to store plugin settings that are dependent on the current profile - this.pluginSettings = new PluginOptionsAccessor(profileService, Guid.Parse(this.Identifier)); + this.pluginSettings = new PluginOptionsAccessor(profileService, PluginGuid); this.profileService = profileService; // React on a changed profile profileService.ProfileChanged += ProfileService_ProfileChanged; From 77cd0c5271f536e7463c2037cc1359bd2520c4f0 Mon Sep 17 00:00:00 2001 From: Lucas Lepski Date: Mon, 8 Dec 2025 12:40:33 +0100 Subject: [PATCH 20/45] Warn when native cancel fails and clarify option text --- Drivers/CameraDriver.cs | 5 ++++- Options.xaml | 5 ++--- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/Drivers/CameraDriver.cs b/Drivers/CameraDriver.cs index 44fde91..ccd020e 100644 --- a/Drivers/CameraDriver.cs +++ b/Drivers/CameraDriver.cs @@ -774,7 +774,10 @@ public void AbortExposure() { if (_enableNativeCancel) { - TryCancelCapture("abort request"); + if (!TryCancelCapture("abort request")) + { + Notification.ShowWarning("Abort requested, but the camera did not accept native cancel; exposure will continue until it finishes."); + } } else { diff --git a/Options.xaml b/Options.xaml index ed1e99e..daafc7c 100644 --- a/Options.xaml +++ b/Options.xaml @@ -8,11 +8,10 @@ - + + IsChecked="{Binding EnableNativeCancel, Mode=TwoWay}" /> From 178819155d33d05623640733d4156285151ecfbe Mon Sep 17 00:00:00 2001 From: Lucas Lepski Date: Mon, 8 Dec 2025 12:42:39 +0100 Subject: [PATCH 21/45] Warn on soft cancel when native cancel disabled --- Drivers/CameraDriver.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/Drivers/CameraDriver.cs b/Drivers/CameraDriver.cs index ccd020e..5bae3ff 100644 --- a/Drivers/CameraDriver.cs +++ b/Drivers/CameraDriver.cs @@ -782,6 +782,7 @@ public void AbortExposure() else { _softCancelRequested = true; + Notification.ShowWarning("Abort requested; native cancel is disabled. Exposure will continue until it finishes."); Logger.Info("AbortExposure requested; native cancel disabled; letting capture finish."); } } From 7cb640af4869bb5940a582d12f9c43383710791b Mon Sep 17 00:00:00 2001 From: Lucas Lepski Date: Mon, 8 Dec 2025 12:46:30 +0100 Subject: [PATCH 22/45] Rename TryCancelCapture and remove redundant guards --- Drivers/CameraDriver.cs | 30 +++++++++++------------------- 1 file changed, 11 insertions(+), 19 deletions(-) diff --git a/Drivers/CameraDriver.cs b/Drivers/CameraDriver.cs index 5bae3ff..b735e29 100644 --- a/Drivers/CameraDriver.cs +++ b/Drivers/CameraDriver.cs @@ -111,7 +111,7 @@ private void NotifyGainPropertiesChanged() RaisePropertyChanged(nameof(Gains)); } - private bool TryCancelCapture(string reason) + private bool TryCancelCaptureIfEnabled(string reason) { if (_camera == null) { @@ -724,14 +724,11 @@ public void StartExposure(CaptureSequence sequence) if (BUSY_STATES.Contains(captureStatus)) { - if (_enableNativeCancel) + bool attemptedCancel = TryCancelCaptureIfEnabled("start exposure reset"); + if (attemptedCancel && !TryGetCaptureStatusLocked(driver, out captureStatus, "start exposure post-cancel")) { - TryCancelCapture("start exposure reset"); - if (!TryGetCaptureStatusLocked(driver, out captureStatus, "start exposure post-cancel")) - { - Logger.Warning("Cannot start exposure: capture status unavailable after cancel."); - throw new TaskCanceledException("Cannot start exposure: capture status unavailable after cancel."); - } + Logger.Warning("Cannot start exposure: capture status unavailable after cancel."); + throw new TaskCanceledException("Cannot start exposure: capture status unavailable after cancel."); } if (BUSY_STATES.Contains(captureStatus)) { @@ -772,19 +769,14 @@ public void StopExposure() public void AbortExposure() { - if (_enableNativeCancel) - { - if (!TryCancelCapture("abort request")) - { - Notification.ShowWarning("Abort requested, but the camera did not accept native cancel; exposure will continue until it finishes."); - } - } - else + if (TryCancelCaptureIfEnabled("abort request")) { - _softCancelRequested = true; - Notification.ShowWarning("Abort requested; native cancel is disabled. Exposure will continue until it finishes."); - Logger.Info("AbortExposure requested; native cancel disabled; letting capture finish."); + return; } + + _softCancelRequested = true; + Notification.ShowWarning("Abort requested; native cancel is disabled or the camera did not accept it. Exposure will continue until it finishes."); + Logger.Info("AbortExposure requested; native cancel unavailable; letting capture finish."); } public async Task WaitUntilExposureIsReady(CancellationToken token) From 96fbb687784b681443ec109210a3027668d58526 Mon Sep 17 00:00:00 2001 From: Lucas Lepski Date: Mon, 8 Dec 2025 12:48:29 +0100 Subject: [PATCH 23/45] Recheck status after cancel attempt regardless of success --- Drivers/CameraDriver.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Drivers/CameraDriver.cs b/Drivers/CameraDriver.cs index b735e29..562b112 100644 --- a/Drivers/CameraDriver.cs +++ b/Drivers/CameraDriver.cs @@ -724,8 +724,8 @@ public void StartExposure(CaptureSequence sequence) if (BUSY_STATES.Contains(captureStatus)) { - bool attemptedCancel = TryCancelCaptureIfEnabled("start exposure reset"); - if (attemptedCancel && !TryGetCaptureStatusLocked(driver, out captureStatus, "start exposure post-cancel")) + TryCancelCaptureIfEnabled("start exposure reset"); + if (_enableNativeCancel && !TryGetCaptureStatusLocked(driver, out captureStatus, "start exposure post-cancel")) { Logger.Warning("Cannot start exposure: capture status unavailable after cancel."); throw new TaskCanceledException("Cannot start exposure: capture status unavailable after cancel."); From 340b7d4e52e6390f930d14152dad0db90c62d617 Mon Sep 17 00:00:00 2001 From: Lucas Lepski Date: Mon, 8 Dec 2025 12:50:49 +0100 Subject: [PATCH 24/45] Reuse cancel helper for pre-start reset --- Drivers/CameraDriver.cs | 12 +----------- 1 file changed, 1 insertion(+), 11 deletions(-) diff --git a/Drivers/CameraDriver.cs b/Drivers/CameraDriver.cs index 562b112..a7ae88c 100644 --- a/Drivers/CameraDriver.cs +++ b/Drivers/CameraDriver.cs @@ -744,17 +744,7 @@ public void StartExposure(CaptureSequence sequence) } // Reset capture state for bodies that require a pre-start cancel, but only when native cancel is enabled. - if (_enableNativeCancel) - { - try - { - driver.CancelCapture(_camera.Handle); - } - catch (Exception ex) - { - Logger.Warning($"Pre-start CancelCapture failed; continuing start. {ex.Message}"); - } - } + TryCancelCaptureIfEnabled("start exposure pre-start reset"); double exposureTime = sequence.ExposureTime; driver.StartCapture(_camera.Handle, (float)exposureTime); From 5aefd72d93ac2a0b338261a2c9f515f1958d893a Mon Sep 17 00:00:00 2001 From: Lucas Lepski Date: Mon, 8 Dec 2025 12:58:13 +0100 Subject: [PATCH 25/45] undo formatter changes --- Drivers/CameraDriver.cs | 511 +++++++++++++--------------------------- 1 file changed, 167 insertions(+), 344 deletions(-) diff --git a/Drivers/CameraDriver.cs b/Drivers/CameraDriver.cs index a7ae88c..e731979 100644 --- a/Drivers/CameraDriver.cs +++ b/Drivers/CameraDriver.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.IO; using System.Linq; @@ -22,27 +22,26 @@ using NINA.Profile.Interfaces; using Sony; -namespace NINA.RetroKiwi.Plugin.SonyCamera.Drivers -{ - public class CameraDriver : BaseINPC, ICamera - { +namespace NINA.RetroKiwi.Plugin.SonyCamera.Drivers { + public class CameraDriver : BaseINPC, ICamera { // Some camera settings we are interested in private const uint PROPID_BATTERY = 53784; private const uint PROPID_ISO = 0xD21E; // Actual ISO currently set private const uint PROPID_ISOS = 0xFFFE; // Registry-backed list of learnt ISOs (may be empty until learnt) // Capture Status - private const uint CAPTURE_CREATED = 0x0000; - private const uint CAPTURE_CAPTURING = 0x0001; - private const uint CAPTURE_FAILED = 0x0002; - private const uint CAPTURE_CANCELLED = 0x0003; - private const uint CAPTURE_COMPLETE = 0x0004; - private const uint CAPTURE_STARTING = 0x8001; - private const uint CAPTURE_READING = 0x8002; + private const uint CAPTURE_CREATED = 0x0000; + private const uint CAPTURE_CAPTURING = 0x0001; + private const uint CAPTURE_FAILED = 0x0002; + private const uint CAPTURE_CANCELLED = 0x0003; + private const uint CAPTURE_COMPLETE = 0x0004; + private const uint CAPTURE_STARTING = 0x8001; + private const uint CAPTURE_READING = 0x8002; private const uint CAPTURE_PROCESSING = 0x8003; private static readonly uint[] IDLE_STATES = { CAPTURE_CREATED, CAPTURE_CANCELLED, CAPTURE_COMPLETE, CAPTURE_FAILED }; private static readonly uint[] BUSY_STATES = { CAPTURE_CAPTURING, CAPTURE_PROCESSING, CAPTURE_STARTING, CAPTURE_READING }; private static readonly uint[] COMPLETION_STATES = { CAPTURE_CANCELLED, CAPTURE_COMPLETE, CAPTURE_FAILED }; + private readonly bool _enableNativeCancel; private bool _softCancelRequested; @@ -56,8 +55,7 @@ public class CameraDriver : BaseINPC, ICamera private AsyncObservableCollection _binningModes; private readonly object _captureLock = new object(); - public CameraDriver(IProfileService profileService, IExposureDataFactory exposureDataFactory, SonyDevice device, bool enableNativeCancel) - { + public CameraDriver(IProfileService profileService, IExposureDataFactory exposureDataFactory, SonyDevice device, bool enableNativeCancel) { _profileService = profileService; _exposureDataFactory = exposureDataFactory; _device = device; @@ -67,32 +65,24 @@ public CameraDriver(IProfileService profileService, IExposureDataFactory exposur #region Internal Helpers - private PropertyValue GetPropertyValue(uint id) - { + private PropertyValue GetPropertyValue(uint id) { return SonyDriver.GetInstance().GetProperty(_camera.Handle, id); } - private IReadOnlyList GetAvailableIsoOptions() - { - if (_camera == null) - { + private IReadOnlyList GetAvailableIsoOptions() { + if (_camera == null) { return Array.Empty(); } uint[] propertyCandidates = { PROPID_ISOS, PROPID_ISO }; - foreach (var propertyId in propertyCandidates) - { - try - { + foreach (var propertyId in propertyCandidates) { + try { var options = _camera.GetPropertyInfo(propertyId)?.Options()?.Where(o => o.Value <= 0x00FFFFFF).ToList(); - if (options != null && options.Count > 0) - { + if (options != null && options.Count > 0) { return options; } - } - catch (Exception ex) - { + } catch (Exception ex) { Logger.Warning($"Unable to enumerate ISO options for property 0x{propertyId:X}: {ex.Message}"); } } @@ -101,8 +91,7 @@ private IReadOnlyList GetAvailableIsoOptions() return Array.Empty(); } - private void NotifyGainPropertiesChanged() - { + private void NotifyGainPropertiesChanged() { RaisePropertyChanged(nameof(CanGetGain)); RaisePropertyChanged(nameof(CanSetGain)); RaisePropertyChanged(nameof(GainMin)); @@ -111,31 +100,24 @@ private void NotifyGainPropertiesChanged() RaisePropertyChanged(nameof(Gains)); } - private bool TryCancelCaptureIfEnabled(string reason) - { - if (_camera == null) - { + private bool TryCancelCaptureIfEnabled(string reason) { + if (_camera == null) { return false; } - if (!_enableNativeCancel) - { + if (!_enableNativeCancel) { Logger.Info($"Native cancel disabled; skipping cancel ({reason})"); return false; } - lock (_captureLock) - { - try - { + lock (_captureLock) { + try { SonyDriver driver = SonyDriver.GetInstance(); - if (!TryGetCaptureStatusLocked(driver, out var status, reason)) - { + if (!TryGetCaptureStatusLocked(driver, out var status, reason)) { return false; } - if (!BUSY_STATES.Contains(status)) - { + if (!BUSY_STATES.Contains(status)) { Logger.Debug($"Skip cancel ({reason}); capture status is {status}"); return false; } @@ -143,24 +125,18 @@ private bool TryCancelCaptureIfEnabled(string reason) Logger.Info($"Issuing cancel ({reason}); capture status is {status}"); driver.CancelCapture(_camera.Handle); return true; - } - catch (Exception ex) - { + } catch (Exception ex) { Logger.Error($"CancelCapture failed ({reason})", ex); return false; } } } - private bool TryGetCaptureStatusLocked(SonyDriver driver, out uint status, string reason) - { - try - { + private bool TryGetCaptureStatusLocked(SonyDriver driver, out uint status, string reason) { + try { status = driver.GetCaptureStatus(_camera.Handle); return true; - } - catch (Exception ex) - { + } catch (Exception ex) { Logger.Warning($"Unable to get capture status ({reason}): {ex.Message}"); status = CAPTURE_FAILED; return false; @@ -176,8 +152,7 @@ private bool TryGetCaptureStatusLocked(SonyDriver driver, out uint status, strin // Although the driver supports camera temperature, it gets it from the ARW's // metadata after a photo is taken, because this code doesn't request processed // ARW, the temp cannot be determined. - public double Temperature - { + public double Temperature { get => double.NaN; /*{ @@ -194,16 +169,11 @@ public double Temperature public short BinX { get => 1; set => throw new NotImplementedException(); } public short BinY { get => 1; set => throw new NotImplementedException(); } - public string SensorName - { - get - { - if (_camera != null) - { + public string SensorName { + get { + if (_camera != null) { return _camera.SensorName; - } - else - { + } else { return string.Empty; } } @@ -215,61 +185,42 @@ public string SensorName public short BayerOffsetY { get => 1; set => throw new NotImplementedException(); } - public int CameraXSize - { - get - { - if (_camera != null) - { + public int CameraXSize { + get { + if (_camera != null) { return _camera.ImageSize.Width; } - else - { + else { return 0; } } } - public int CameraYSize - { - get - { - if (_camera != null) - { + public int CameraYSize { + get { + if (_camera != null) { return _camera.ImageSize.Height; - } - else - { + } else { return 0; } } } - public double ExposureMin - { - get - { - if (_camera != null) - { + public double ExposureMin { + get { + if (_camera != null) { return _camera.ExposureMin; - } - else - { + } else { return double.NaN; } } } - public double ExposureMax - { - get - { - if (_camera != null) - { + public double ExposureMax { + get { + if (_camera != null) { return _camera.ExposureMax; - } - else - { + } else { return double.NaN; } } @@ -279,31 +230,21 @@ public double ExposureMax public short MaxBinY { get => 1; set => throw new NotImplementedException(); } - public double PixelSizeX - { - get - { - if (_camera != null) - { + public double PixelSizeX { + get { + if (_camera != null) { return _camera.PixelWidth; - } - else - { + } else { return double.NaN; } } } - public double PixelSizeY - { - get - { - if (_camera != null) - { + public double PixelSizeY { + get { + if (_camera != null) { return _camera.PixelHeight; - } - else - { + } else { return double.NaN; } } @@ -313,26 +254,19 @@ public double PixelSizeY public CameraStates CameraState => CameraStates.NoState; // TODO - public bool CanShowLiveView - { - get - { - if (_camera != null) - { + public bool CanShowLiveView { + get { + if (_camera != null) { return _camera.SupportsPreview(); - } - else - { + } else { return false; } } } - public bool LiveViewEnabled - { + public bool LiveViewEnabled { get => _liveViewEnabled; - set - { + set { _liveViewEnabled = value; RaisePropertyChanged(); } @@ -340,31 +274,21 @@ public bool LiveViewEnabled public bool HasBattery => true; - public int BatteryLevel - { - get - { - if (_camera != null) - { + public int BatteryLevel { + get { + if (_camera != null) { return (int)GetPropertyValue(PROPID_BATTERY).Value; - } - else - { + } else { return 0; } } } - public int BitDepth - { - get - { - if (_camera != null) - { + public int BitDepth { + get { + if (_camera != null) { return _camera.BitsPerPixel; - } - else - { + } else { return 0; } } @@ -374,15 +298,11 @@ public int BitDepth public bool CanSetGain => CanGetGain; - public int GainMax - { - get - { + public int GainMax { + get { var isoOptions = GetAvailableIsoOptions(); - if (!isoOptions.Any()) - { - if (_camera != null) - { + if (!isoOptions.Any()) { + if (_camera != null) { Logger.Error("Problem getting gain max: camera did not report ISO options."); } return -1; @@ -392,15 +312,11 @@ public int GainMax } } - public int GainMin - { - get - { + public int GainMin { + get { var isoOptions = GetAvailableIsoOptions(); - if (!isoOptions.Any()) - { - if (_camera != null) - { + if (!isoOptions.Any()) { + if (_camera != null) { Logger.Error("Problem getting gain min: camera did not report ISO options."); } return -1; @@ -410,61 +326,42 @@ public int GainMin } } - public int Gain - { - get - { - if (_camera != null) - { - try - { + public int Gain { + get { + if (_camera != null) { + try { PropertyValue value = GetPropertyValue(PROPID_ISO); return (int)(value.Value == 0xffffff ? 0 : value.Value); - } - catch (Exception ex) - { + } catch (Exception ex) { Logger.Error("Problem getting gain", ex); return -1; } - } - else - { + } else { return -1; } } - set - { - if (_camera != null) - { - try - { + set { + if (_camera != null) { + try { SonyDriver.GetInstance().SetProperty(_camera.Handle, PROPID_ISO, (uint)value); RaisePropertyChanged(nameof(Gain)); - } - catch (Exception ex) - { + } catch (Exception ex) { Logger.Error($"Problem setting gain to {value}", ex); } } } } - public IList Gains - { - get - { + public IList Gains { + get { List gains = new List(); - foreach (var iso in GetAvailableIsoOptions()) - { - if (iso.Value == 0xffffff) - { + foreach (var iso in GetAvailableIsoOptions()) { + if (iso.Value == 0xffffff) { gains.Add(0); // AUTO - } - else - { + } else { gains.Add((int)iso.Value); } } @@ -475,38 +372,29 @@ public IList Gains public string Id => "Sony"; - public string Name - { + public string Name { get => _device.Model; set => throw new NotImplementedException(); } - public string DisplayName - { + public string DisplayName { get => _device.Model; set => throw new NotImplementedException(); } public string Category { get => "Sony"; } - public bool Connected - { - get - { + public bool Connected { + get { return _camera != null; } } - public string Description - { - get - { - if (_camera != null) - { + public string Description { + get { + if (_camera != null) { return _camera.GetDescription(); - } - else - { + } else { return _device.GetDescription(); } } @@ -516,12 +404,10 @@ public string Description public string DriverVersion => string.Empty; - public double TemperatureSetPoint - { + public double TemperatureSetPoint { get => double.NaN; - set - { + set { } } @@ -537,11 +423,9 @@ public double TemperatureSetPoint public int SubSampleHeight { get; set; } - public bool CoolerOn - { + public bool CoolerOn { get => false; - set - { + set { } } @@ -549,11 +433,9 @@ public bool CoolerOn public bool HasDewHeater => false; - public bool DewHeaterOn - { + public bool DewHeaterOn { get => false; - set - { + set { } } @@ -579,38 +461,30 @@ public bool DewHeaterOn public IList ReadoutModes => new List { "Default" }; - public short ReadoutMode - { + public short ReadoutMode { get => 0; set { } } - public short ReadoutModeForSnapImages - { + public short ReadoutModeForSnapImages { get => _readoutModeForSnapImages; - set - { + set { _readoutModeForSnapImages = value; RaisePropertyChanged(); } } - public short ReadoutModeForNormalImages - { + public short ReadoutModeForNormalImages { get => _readoutModeForNormalImages; - set - { + set { _readoutModeForNormalImages = value; RaisePropertyChanged(); } } - public AsyncObservableCollection BinningModes - { - get - { - if (_binningModes == null) - { + public AsyncObservableCollection BinningModes { + get { + if (_binningModes == null) { _binningModes = new AsyncObservableCollection(); _binningModes.Add(new BinningMode(1, 1)); } @@ -623,26 +497,19 @@ public AsyncObservableCollection BinningModes #region Supported Methods - public void StartLiveView(CaptureSequence sequence) - { + public void StartLiveView(CaptureSequence sequence) { LiveViewEnabled = true; } - public void StopLiveView() - { + public void StopLiveView() { LiveViewEnabled = false; } - public Task Connect(CancellationToken token) - { - return Task.Run(() => - { - try - { + public Task Connect(CancellationToken token) { + return Task.Run(() => { + try { _camera = SonyDriver.GetInstance().OpenCamera(_device.Id); - } - catch (Exception ex) - { + } catch (Exception ex) { Logger.Error(ex); _camera = null; } @@ -652,16 +519,11 @@ public Task Connect(CancellationToken token) }); } - public void Disconnect() - { - if (_camera != null) - { - try - { + public void Disconnect() { + if (_camera != null) { + try { SonyDriver.GetInstance().CloseCamera(_camera.Handle); - } - catch (Exception ex) - { + } catch (Exception ex) { Logger.Error(ex); } @@ -670,12 +532,9 @@ public void Disconnect() } } - public Task DownloadLiveView(CancellationToken token) - { - return Task.Run(() => - { - using (var memStream = new MemoryStream(SonyDriver.GetInstance().GetLiveView(_camera.Handle))) - { + public Task DownloadLiveView(CancellationToken token) { + return Task.Run(() => { + using (var memStream = new MemoryStream(SonyDriver.GetInstance().GetLiveView(_camera.Handle))) { memStream.Position = 0; JpegBitmapDecoder decoder = @@ -703,42 +562,33 @@ public Task DownloadLiveView(CancellationToken token) }); } - public void SetupDialog() - { + public void SetupDialog() { throw new NotImplementedException(); } - public void StartExposure(CaptureSequence sequence) - { - if (_camera != null) - { + public void StartExposure(CaptureSequence sequence) { + if (_camera != null) { SonyDriver driver = SonyDriver.GetInstance(); - lock (_captureLock) - { + lock (_captureLock) { _softCancelRequested = false; - if (!TryGetCaptureStatusLocked(driver, out var captureStatus, "start exposure preflight")) - { + if (!TryGetCaptureStatusLocked(driver, out var captureStatus, "start exposure preflight")) { Logger.Warning("Cannot start exposure: capture status unavailable."); throw new TaskCanceledException("Cannot start exposure: capture status unavailable."); } - if (BUSY_STATES.Contains(captureStatus)) - { + if (BUSY_STATES.Contains(captureStatus)) { TryCancelCaptureIfEnabled("start exposure reset"); - if (_enableNativeCancel && !TryGetCaptureStatusLocked(driver, out captureStatus, "start exposure post-cancel")) - { + if (_enableNativeCancel && !TryGetCaptureStatusLocked(driver, out captureStatus, "start exposure post-cancel")) { Logger.Warning("Cannot start exposure: capture status unavailable after cancel."); throw new TaskCanceledException("Cannot start exposure: capture status unavailable after cancel."); } - if (BUSY_STATES.Contains(captureStatus)) - { + if (BUSY_STATES.Contains(captureStatus)) { Notification.ShowWarning("Camera is still busy with a previous exposure. Skipping new start."); throw new TaskCanceledException("Cannot start exposure: Camera is still busy with a previous exposure."); } } - if (!IDLE_STATES.Contains(captureStatus)) - { + if (!IDLE_STATES.Contains(captureStatus)) { Logger.Warning($"Cannot start exposure: Camera in unexpected capture status ({captureStatus})."); throw new TaskCanceledException($"Cannot start exposure: Camera in unexpected capture status ({captureStatus})."); } @@ -752,15 +602,12 @@ public void StartExposure(CaptureSequence sequence) } } - public void StopExposure() - { + public void StopExposure() { AbortExposure(); } - public void AbortExposure() - { - if (TryCancelCaptureIfEnabled("abort request")) - { + public void AbortExposure() { + if (TryCancelCaptureIfEnabled("abort request")) { return; } @@ -769,68 +616,52 @@ public void AbortExposure() Logger.Info("AbortExposure requested; native cancel unavailable; letting capture finish."); } - public async Task WaitUntilExposureIsReady(CancellationToken token) - { - using (token.Register(AbortExposure)) - { + public async Task WaitUntilExposureIsReady(CancellationToken token) { + using (token.Register(AbortExposure)) { SonyDriver driver = SonyDriver.GetInstance(); - try - { + try { uint captureStatus; - lock (_captureLock) - { - if (!TryGetCaptureStatusLocked(driver, out captureStatus, "wait begin")) - { + lock (_captureLock) { + if (!TryGetCaptureStatusLocked(driver, out captureStatus, "wait begin")) { throw new SonyException("Problem while waiting for image to be ready (status unavailable)"); } } Logger.Info( $"Waiting for image to be ready, current state is {captureStatus}, completion states are {String.Join(", ", COMPLETION_STATES)}"); - while (!COMPLETION_STATES.Contains(captureStatus)) - { + while (!COMPLETION_STATES.Contains(captureStatus)) { var waitToken = _enableNativeCancel ? token : CancellationToken.None; await CoreUtil.Wait(TimeSpan.FromMilliseconds(100), waitToken); - if (!_enableNativeCancel && token.IsCancellationRequested) - { + if (!_enableNativeCancel && token.IsCancellationRequested) { _softCancelRequested = true; } - lock (_captureLock) - { - if (!TryGetCaptureStatusLocked(driver, out captureStatus, "wait poll")) - { + lock (_captureLock) { + if (!TryGetCaptureStatusLocked(driver, out captureStatus, "wait poll")) { throw new SonyException("Problem while waiting for image to be ready (status unavailable)"); } } } Logger.Info($"Wait for image ready complete, completion state is {captureStatus}"); - if (_softCancelRequested || token.IsCancellationRequested) - { + if (_softCancelRequested || token.IsCancellationRequested) { _softCancelRequested = false; throw new TaskCanceledException("Exposure cancelled by user (soft cancel)."); } - } - catch (TaskCanceledException) - { + } catch (TaskCanceledException) { Logger.Info("WaitUntilExposureIsReady cancelled by token; exiting without native cancel."); throw; - } - catch (Exception ex) - { + } catch (Exception ex) { Logger.Error("WaitUntilExposureIsReady got exception", ex); throw new SonyException("Problem while waiting for image to be ready (see log)"); } } } - public Task DownloadExposure(CancellationToken token) - { - return Task.Run(() => - { + public Task DownloadExposure(CancellationToken token) { + return Task.Run(() => { byte[] rawImageData = SonyDriver.GetInstance().GetLastImage(); var metaData = new ImageMetaData(); @@ -847,36 +678,29 @@ public Task DownloadExposure(CancellationToken token) #region Unsupported Methods - public string Action(string actionName, string actionParameters) - { + public string Action(string actionName, string actionParameters) { throw new NotImplementedException(); } - public void SendCommandBlind(string command, bool raw = true) - { + public void SendCommandBlind(string command, bool raw = true) { throw new NotImplementedException(); } - public bool SendCommandBool(string command, bool raw = true) - { + public bool SendCommandBool(string command, bool raw = true) { throw new NotImplementedException(); } - public string SendCommandString(string command, bool raw = true) - { + public string SendCommandString(string command, bool raw = true) { throw new NotImplementedException(); } - public void SetBinning(short x, short y) - { + public void SetBinning(short x, short y) { // Ignore } - - public void UpdateSubSampleArea() - { - if (_camera == null) - { + + public void UpdateSubSampleArea() { + if (_camera == null) { EnableSubSample = false; SubSampleX = 0; SubSampleY = 0; @@ -885,8 +709,7 @@ public void UpdateSubSampleArea() return; } - if (EnableSubSample && !CanSubSample) - { + if (EnableSubSample && !CanSubSample) { Logger.Warning("Sub-sampling requested but not supported for Sony cameras. Falling back to full frame."); EnableSubSample = false; } From 6df3c4134d63857f6aeeb45024acdfd66eb6282b Mon Sep 17 00:00:00 2001 From: Lucas Lepski Date: Mon, 8 Dec 2025 13:09:33 +0100 Subject: [PATCH 26/45] Handle unknown capture status responses --- Drivers/CameraDriver.cs | 23 +++++++++++++++-------- 1 file changed, 15 insertions(+), 8 deletions(-) diff --git a/Drivers/CameraDriver.cs b/Drivers/CameraDriver.cs index e731979..aa30900 100644 --- a/Drivers/CameraDriver.cs +++ b/Drivers/CameraDriver.cs @@ -38,6 +38,7 @@ public class CameraDriver : BaseINPC, ICamera { private const uint CAPTURE_STARTING = 0x8001; private const uint CAPTURE_READING = 0x8002; private const uint CAPTURE_PROCESSING = 0x8003; + private const uint CAPTURE_STATUS_UNKNOWN = 0xFFFFFFFF; private static readonly uint[] IDLE_STATES = { CAPTURE_CREATED, CAPTURE_CANCELLED, CAPTURE_COMPLETE, CAPTURE_FAILED }; private static readonly uint[] BUSY_STATES = { CAPTURE_CAPTURING, CAPTURE_PROCESSING, CAPTURE_STARTING, CAPTURE_READING }; private static readonly uint[] COMPLETION_STATES = { CAPTURE_CANCELLED, CAPTURE_COMPLETE, CAPTURE_FAILED }; @@ -113,7 +114,12 @@ private bool TryCancelCaptureIfEnabled(string reason) { lock (_captureLock) { try { SonyDriver driver = SonyDriver.GetInstance(); - if (!TryGetCaptureStatusLocked(driver, out var status, reason)) { + if (!TryGetCaptureStatus(driver, out var status, reason)) { + return false; + } + + if (status == CAPTURE_STATUS_UNKNOWN) { + Logger.Warning($"Skip cancel ({reason}); capture status is unknown (camera did not respond)."); return false; } @@ -132,13 +138,13 @@ private bool TryCancelCaptureIfEnabled(string reason) { } } - private bool TryGetCaptureStatusLocked(SonyDriver driver, out uint status, string reason) { + private bool TryGetCaptureStatus(SonyDriver driver, out uint status, string reason) { try { status = driver.GetCaptureStatus(_camera.Handle); return true; } catch (Exception ex) { - Logger.Warning($"Unable to get capture status ({reason}): {ex.Message}"); - status = CAPTURE_FAILED; + Logger.Warning($"Unable to get capture status ({reason}); camera may not have responded: {ex.Message}"); + status = CAPTURE_STATUS_UNKNOWN; return false; } } @@ -571,14 +577,15 @@ public void StartExposure(CaptureSequence sequence) { SonyDriver driver = SonyDriver.GetInstance(); lock (_captureLock) { _softCancelRequested = false; - if (!TryGetCaptureStatusLocked(driver, out var captureStatus, "start exposure preflight")) { + if (!TryGetCaptureStatus(driver, out var captureStatus, "start exposure preflight") || + captureStatus == CAPTURE_STATUS_UNKNOWN) { Logger.Warning("Cannot start exposure: capture status unavailable."); throw new TaskCanceledException("Cannot start exposure: capture status unavailable."); } if (BUSY_STATES.Contains(captureStatus)) { TryCancelCaptureIfEnabled("start exposure reset"); - if (_enableNativeCancel && !TryGetCaptureStatusLocked(driver, out captureStatus, "start exposure post-cancel")) { + if (_enableNativeCancel && (!TryGetCaptureStatus(driver, out captureStatus, "start exposure post-cancel") || captureStatus == CAPTURE_STATUS_UNKNOWN)) { Logger.Warning("Cannot start exposure: capture status unavailable after cancel."); throw new TaskCanceledException("Cannot start exposure: capture status unavailable after cancel."); } @@ -624,7 +631,7 @@ public async Task WaitUntilExposureIsReady(CancellationToken token) { try { uint captureStatus; lock (_captureLock) { - if (!TryGetCaptureStatusLocked(driver, out captureStatus, "wait begin")) { + if (!TryGetCaptureStatus(driver, out captureStatus, "wait begin") || captureStatus == CAPTURE_STATUS_UNKNOWN) { throw new SonyException("Problem while waiting for image to be ready (status unavailable)"); } } @@ -639,7 +646,7 @@ public async Task WaitUntilExposureIsReady(CancellationToken token) { } lock (_captureLock) { - if (!TryGetCaptureStatusLocked(driver, out captureStatus, "wait poll")) { + if (!TryGetCaptureStatus(driver, out captureStatus, "wait poll") || captureStatus == CAPTURE_STATUS_UNKNOWN) { throw new SonyException("Problem while waiting for image to be ready (status unavailable)"); } } From f5e93dffc56c2c03c9811770f325d0465a98808d Mon Sep 17 00:00:00 2001 From: Lucas Lepski Date: Mon, 8 Dec 2025 13:31:16 +0100 Subject: [PATCH 27/45] refactor cancelation logic --- Drivers/CameraDriver.cs | 26 +++++++++----------------- 1 file changed, 9 insertions(+), 17 deletions(-) diff --git a/Drivers/CameraDriver.cs b/Drivers/CameraDriver.cs index aa30900..cad0e98 100644 --- a/Drivers/CameraDriver.cs +++ b/Drivers/CameraDriver.cs @@ -583,16 +583,15 @@ public void StartExposure(CaptureSequence sequence) { throw new TaskCanceledException("Cannot start exposure: capture status unavailable."); } + TryCancelCaptureIfEnabled("start exposure reset"); + if (_enableNativeCancel && (!TryGetCaptureStatus(driver, out captureStatus, "start exposure post-cancel") || captureStatus == CAPTURE_STATUS_UNKNOWN)) { + Logger.Warning("Cannot start exposure: capture status unavailable after cancel."); + throw new TaskCanceledException("Cannot start exposure: capture status unavailable after cancel."); + } + if (BUSY_STATES.Contains(captureStatus)) { - TryCancelCaptureIfEnabled("start exposure reset"); - if (_enableNativeCancel && (!TryGetCaptureStatus(driver, out captureStatus, "start exposure post-cancel") || captureStatus == CAPTURE_STATUS_UNKNOWN)) { - Logger.Warning("Cannot start exposure: capture status unavailable after cancel."); - throw new TaskCanceledException("Cannot start exposure: capture status unavailable after cancel."); - } - if (BUSY_STATES.Contains(captureStatus)) { - Notification.ShowWarning("Camera is still busy with a previous exposure. Skipping new start."); - throw new TaskCanceledException("Cannot start exposure: Camera is still busy with a previous exposure."); - } + Notification.ShowWarning("Camera is still busy with a previous exposure. Skipping new start."); + throw new TaskCanceledException("Cannot start exposure: Camera is still busy with a previous exposure."); } if (!IDLE_STATES.Contains(captureStatus)) { @@ -600,9 +599,6 @@ public void StartExposure(CaptureSequence sequence) { throw new TaskCanceledException($"Cannot start exposure: Camera in unexpected capture status ({captureStatus})."); } - // Reset capture state for bodies that require a pre-start cancel, but only when native cancel is enabled. - TryCancelCaptureIfEnabled("start exposure pre-start reset"); - double exposureTime = sequence.ExposureTime; driver.StartCapture(_camera.Handle, (float)exposureTime); } @@ -639,11 +635,7 @@ public async Task WaitUntilExposureIsReady(CancellationToken token) { $"Waiting for image to be ready, current state is {captureStatus}, completion states are {String.Join(", ", COMPLETION_STATES)}"); while (!COMPLETION_STATES.Contains(captureStatus)) { - var waitToken = _enableNativeCancel ? token : CancellationToken.None; - await CoreUtil.Wait(TimeSpan.FromMilliseconds(100), waitToken); - if (!_enableNativeCancel && token.IsCancellationRequested) { - _softCancelRequested = true; - } + await CoreUtil.Wait(TimeSpan.FromMilliseconds(100), token); lock (_captureLock) { if (!TryGetCaptureStatus(driver, out captureStatus, "wait poll") || captureStatus == CAPTURE_STATUS_UNKNOWN) { From d0b12accfb771c6db8b57162214910e4fa3cd41b Mon Sep 17 00:00:00 2001 From: Lucas Lepski Date: Mon, 8 Dec 2025 13:33:16 +0100 Subject: [PATCH 28/45] use live value of cancelcapture option --- Drivers/CameraDriver.cs | 30 +++++++++++++++++++++++++----- Drivers/CameraProvider.cs | 10 +--------- 2 files changed, 26 insertions(+), 14 deletions(-) diff --git a/Drivers/CameraDriver.cs b/Drivers/CameraDriver.cs index cad0e98..de7dff5 100644 --- a/Drivers/CameraDriver.cs +++ b/Drivers/CameraDriver.cs @@ -20,6 +20,7 @@ using NINA.Image.Interfaces; using NINA.Profile; using NINA.Profile.Interfaces; +using NINA.RetroKiwi.Plugin.SonyCamera; using Sony; namespace NINA.RetroKiwi.Plugin.SonyCamera.Drivers { @@ -43,7 +44,8 @@ public class CameraDriver : BaseINPC, ICamera { private static readonly uint[] BUSY_STATES = { CAPTURE_CAPTURING, CAPTURE_PROCESSING, CAPTURE_STARTING, CAPTURE_READING }; private static readonly uint[] COMPLETION_STATES = { CAPTURE_CANCELLED, CAPTURE_COMPLETE, CAPTURE_FAILED }; - private readonly bool _enableNativeCancel; + private readonly PluginOptionsAccessor _pluginSettings; + private readonly bool _enableNativeCancelDefault; private bool _softCancelRequested; private SonyCameraInfo _camera = null; @@ -56,11 +58,12 @@ public class CameraDriver : BaseINPC, ICamera { private AsyncObservableCollection _binningModes; private readonly object _captureLock = new object(); - public CameraDriver(IProfileService profileService, IExposureDataFactory exposureDataFactory, SonyDevice device, bool enableNativeCancel) { + public CameraDriver(IProfileService profileService, IExposureDataFactory exposureDataFactory, SonyDevice device, PluginOptionsAccessor pluginSettings, bool enableNativeCancel) { _profileService = profileService; _exposureDataFactory = exposureDataFactory; _device = device; - _enableNativeCancel = enableNativeCancel; + _pluginSettings = pluginSettings; + _enableNativeCancelDefault = enableNativeCancel; _softCancelRequested = false; } @@ -101,12 +104,29 @@ private void NotifyGainPropertiesChanged() { RaisePropertyChanged(nameof(Gains)); } + private bool NativeCancelEnabled { + get { + if (_pluginSettings != null) { + try { + var raw = _pluginSettings.GetValueString(nameof(SonyCamera.EnableNativeCancel), _enableNativeCancelDefault.ToString()); + if (bool.TryParse(raw, out var enabled)) { + return enabled; + } + } catch (Exception ex) { + Logger.Warning($"Unable to read EnableNativeCancel setting; defaulting to {_enableNativeCancelDefault}. {ex.Message}"); + } + } + + return _enableNativeCancelDefault; + } + } + private bool TryCancelCaptureIfEnabled(string reason) { if (_camera == null) { return false; } - if (!_enableNativeCancel) { + if (!NativeCancelEnabled) { Logger.Info($"Native cancel disabled; skipping cancel ({reason})"); return false; } @@ -584,7 +604,7 @@ public void StartExposure(CaptureSequence sequence) { } TryCancelCaptureIfEnabled("start exposure reset"); - if (_enableNativeCancel && (!TryGetCaptureStatus(driver, out captureStatus, "start exposure post-cancel") || captureStatus == CAPTURE_STATUS_UNKNOWN)) { + if (NativeCancelEnabled && (!TryGetCaptureStatus(driver, out captureStatus, "start exposure post-cancel") || captureStatus == CAPTURE_STATUS_UNKNOWN)) { Logger.Warning("Cannot start exposure: capture status unavailable after cancel."); throw new TaskCanceledException("Cannot start exposure: capture status unavailable after cancel."); } diff --git a/Drivers/CameraProvider.cs b/Drivers/CameraProvider.cs index df536df..94adaa9 100644 --- a/Drivers/CameraProvider.cs +++ b/Drivers/CameraProvider.cs @@ -47,21 +47,13 @@ public CameraProvider(IProfileService profileService, IExposureDataFactory expos public IList GetEquipment() { var devices = new List(); - bool enableNativeCancel = false; - try { - var raw = pluginSettings.GetValueString(nameof(SonyCamera.EnableNativeCancel), bool.FalseString); - enableNativeCancel = bool.TryParse(raw, out var parsed) && parsed; - } catch (Exception ex) { - Logger.Warning($"Unable to read EnableNativeCancel setting; defaulting to false. {ex.Message}"); - } - if (this.driver != null) { try { int count = 0; foreach (var sonyDevice in driver.Cameras()) { count++; - devices.Add(new CameraDriver(profileService, exposureDataFactory, sonyDevice, enableNativeCancel)); + devices.Add(new CameraDriver(profileService, exposureDataFactory, sonyDevice, pluginSettings, false)); } Logger.Info($"Found {count} Sony Cameras"); From 19a0b2a6615ae7b81c93b54ad2e84d1ab2b0c991 Mon Sep 17 00:00:00 2001 From: Lucas Lepski Date: Mon, 8 Dec 2025 13:50:03 +0100 Subject: [PATCH 29/45] Prevent deadlock during rapid start and cancel --- Drivers/CameraDriver.cs | 84 +++++++++++++++++++++++------------------ 1 file changed, 48 insertions(+), 36 deletions(-) diff --git a/Drivers/CameraDriver.cs b/Drivers/CameraDriver.cs index de7dff5..df93490 100644 --- a/Drivers/CameraDriver.cs +++ b/Drivers/CameraDriver.cs @@ -87,7 +87,7 @@ private IReadOnlyList GetAvailableIsoOptions() { return options; } } catch (Exception ex) { - Logger.Warning($"Unable to enumerate ISO options for property 0x{propertyId:X}: {ex.Message}"); + Logger.Warning($"Unable to enumerate ISO options for property 0x{propertyId:X}: {ex.Message}."); } } @@ -113,7 +113,7 @@ private bool NativeCancelEnabled { return enabled; } } catch (Exception ex) { - Logger.Warning($"Unable to read EnableNativeCancel setting; defaulting to {_enableNativeCancelDefault}. {ex.Message}"); + Logger.Warning($"EnableNativeCancel read failed; defaulting to {_enableNativeCancelDefault}. {ex.Message}."); } } @@ -121,40 +121,49 @@ private bool NativeCancelEnabled { } } - private bool TryCancelCaptureIfEnabled(string reason) { + private bool TryCancelCaptureIfEnabled(string reason, int lockTimeoutMs = 1000) { if (_camera == null) { return false; } if (!NativeCancelEnabled) { - Logger.Info($"Native cancel disabled; skipping cancel ({reason})"); + Logger.Info($"Native cancel disabled; skipping cancel ({reason})."); return false; } - lock (_captureLock) { - try { - SonyDriver driver = SonyDriver.GetInstance(); - if (!TryGetCaptureStatus(driver, out var status, reason)) { - return false; - } + bool lockTaken = false; + try { + if (!Monitor.TryEnter(_captureLock, lockTimeoutMs)) { + Logger.Warning($"Skipping cancel ({reason}); capture lock unavailable after {lockTimeoutMs} ms."); + return false; + } + lockTaken = true; - if (status == CAPTURE_STATUS_UNKNOWN) { - Logger.Warning($"Skip cancel ({reason}); capture status is unknown (camera did not respond)."); - return false; - } + SonyDriver driver = SonyDriver.GetInstance(); + if (!TryGetCaptureStatus(driver, out var status, reason)) { + return false; + } - if (!BUSY_STATES.Contains(status)) { - Logger.Debug($"Skip cancel ({reason}); capture status is {status}"); - return false; - } + if (status == CAPTURE_STATUS_UNKNOWN) { + Logger.Warning($"Skipping cancel ({reason}); capture status is unknown (camera did not respond)."); + return false; + } - Logger.Info($"Issuing cancel ({reason}); capture status is {status}"); - driver.CancelCapture(_camera.Handle); - return true; - } catch (Exception ex) { - Logger.Error($"CancelCapture failed ({reason})", ex); + if (!BUSY_STATES.Contains(status)) { + Logger.Debug($"Skipping cancel ({reason}); capture status is {status}."); return false; } + + Logger.Info($"Issuing cancel ({reason}); capture status is {status}."); + driver.CancelCapture(_camera.Handle); + return true; + } catch (Exception ex) { + Logger.Error($"CancelCapture failed ({reason}).", ex); + return false; + } finally { + if (lockTaken) { + Monitor.Exit(_captureLock); + } } } @@ -163,7 +172,7 @@ private bool TryGetCaptureStatus(SonyDriver driver, out uint status, string reas status = driver.GetCaptureStatus(_camera.Handle); return true; } catch (Exception ex) { - Logger.Warning($"Unable to get capture status ({reason}); camera may not have responded: {ex.Message}"); + Logger.Warning($"Unable to get capture status ({reason}); camera may not have responded: {ex.Message}."); status = CAPTURE_STATUS_UNKNOWN; return false; } @@ -360,7 +369,7 @@ public int Gain { return (int)(value.Value == 0xffffff ? 0 : value.Value); } catch (Exception ex) { - Logger.Error("Problem getting gain", ex); + Logger.Error("Problem getting gain.", ex); return -1; } } else { @@ -374,7 +383,7 @@ public int Gain { SonyDriver.GetInstance().SetProperty(_camera.Handle, PROPID_ISO, (uint)value); RaisePropertyChanged(nameof(Gain)); } catch (Exception ex) { - Logger.Error($"Problem setting gain to {value}", ex); + Logger.Error($"Problem setting gain to {value}.", ex); } } } @@ -595,6 +604,8 @@ public void SetupDialog() { public void StartExposure(CaptureSequence sequence) { if (_camera != null) { SonyDriver driver = SonyDriver.GetInstance(); + + double exposureTime; lock (_captureLock) { _softCancelRequested = false; if (!TryGetCaptureStatus(driver, out var captureStatus, "start exposure preflight") || @@ -610,18 +621,20 @@ public void StartExposure(CaptureSequence sequence) { } if (BUSY_STATES.Contains(captureStatus)) { - Notification.ShowWarning("Camera is still busy with a previous exposure. Skipping new start."); - throw new TaskCanceledException("Cannot start exposure: Camera is still busy with a previous exposure."); + Notification.ShowWarning("Camera is still busy with a previous exposure; skipping new start."); + throw new TaskCanceledException("Cannot start exposure: camera is still busy with a previous exposure."); } if (!IDLE_STATES.Contains(captureStatus)) { - Logger.Warning($"Cannot start exposure: Camera in unexpected capture status ({captureStatus})."); - throw new TaskCanceledException($"Cannot start exposure: Camera in unexpected capture status ({captureStatus})."); + Logger.Warning($"Cannot start exposure: camera in unexpected capture status ({captureStatus})."); + throw new TaskCanceledException($"Cannot start exposure: camera in unexpected capture status ({captureStatus})."); } - double exposureTime = sequence.ExposureTime; - driver.StartCapture(_camera.Handle, (float)exposureTime); + exposureTime = sequence.ExposureTime; } + + // Start capture outside the capture lock to avoid blocking abort/cancel paths if the call hangs. + driver.StartCapture(_camera.Handle, (float)exposureTime); } } @@ -651,8 +664,7 @@ public async Task WaitUntilExposureIsReady(CancellationToken token) { throw new SonyException("Problem while waiting for image to be ready (status unavailable)"); } } - Logger.Info( - $"Waiting for image to be ready, current state is {captureStatus}, completion states are {String.Join(", ", COMPLETION_STATES)}"); + Logger.Info($"Waiting for image to be ready; current state is {captureStatus}; completion states are {String.Join(", ", COMPLETION_STATES)}."); while (!COMPLETION_STATES.Contains(captureStatus)) { await CoreUtil.Wait(TimeSpan.FromMilliseconds(100), token); @@ -664,7 +676,7 @@ public async Task WaitUntilExposureIsReady(CancellationToken token) { } } - Logger.Info($"Wait for image ready complete, completion state is {captureStatus}"); + Logger.Info($"Wait for image ready complete; completion state is {captureStatus}."); if (_softCancelRequested || token.IsCancellationRequested) { _softCancelRequested = false; throw new TaskCanceledException("Exposure cancelled by user (soft cancel)."); @@ -673,7 +685,7 @@ public async Task WaitUntilExposureIsReady(CancellationToken token) { Logger.Info("WaitUntilExposureIsReady cancelled by token; exiting without native cancel."); throw; } catch (Exception ex) { - Logger.Error("WaitUntilExposureIsReady got exception", ex); + Logger.Error("WaitUntilExposureIsReady got exception.", ex); throw new SonyException("Problem while waiting for image to be ready (see log)"); } } From 8d4a65f197f5f77daba6c0fa1e6c5c142d8e0aba Mon Sep 17 00:00:00 2001 From: Lucas Lepski Date: Mon, 8 Dec 2025 13:51:03 +0100 Subject: [PATCH 30/45] Show abort warning only once per exposure --- Drivers/CameraDriver.cs | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/Drivers/CameraDriver.cs b/Drivers/CameraDriver.cs index df93490..e8aab0e 100644 --- a/Drivers/CameraDriver.cs +++ b/Drivers/CameraDriver.cs @@ -47,6 +47,7 @@ public class CameraDriver : BaseINPC, ICamera { private readonly PluginOptionsAccessor _pluginSettings; private readonly bool _enableNativeCancelDefault; private bool _softCancelRequested; + private bool _abortWarningShown; private SonyCameraInfo _camera = null; private SonyDevice _device = null; @@ -65,6 +66,7 @@ public CameraDriver(IProfileService profileService, IExposureDataFactory exposur _pluginSettings = pluginSettings; _enableNativeCancelDefault = enableNativeCancel; _softCancelRequested = false; + _abortWarningShown = false; } #region Internal Helpers @@ -608,6 +610,7 @@ public void StartExposure(CaptureSequence sequence) { double exposureTime; lock (_captureLock) { _softCancelRequested = false; + _abortWarningShown = false; if (!TryGetCaptureStatus(driver, out var captureStatus, "start exposure preflight") || captureStatus == CAPTURE_STATUS_UNKNOWN) { Logger.Warning("Cannot start exposure: capture status unavailable."); @@ -648,7 +651,10 @@ public void AbortExposure() { } _softCancelRequested = true; - Notification.ShowWarning("Abort requested; native cancel is disabled or the camera did not accept it. Exposure will continue until it finishes."); + if (!_abortWarningShown) { + Notification.ShowWarning("Abort requested; native cancel is disabled or the camera did not accept it. Exposure will continue until it finishes."); + _abortWarningShown = true; + } Logger.Info("AbortExposure requested; native cancel unavailable; letting capture finish."); } From b5f128832e9706aebe3d407ddd4c6bb2f9e08536 Mon Sep 17 00:00:00 2001 From: Lucas Lepski Date: Mon, 8 Dec 2025 13:52:04 +0100 Subject: [PATCH 31/45] Revert "Show abort warning only once per exposure" This reverts commit 8d4a65f197f5f77daba6c0fa1e6c5c142d8e0aba. --- Drivers/CameraDriver.cs | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/Drivers/CameraDriver.cs b/Drivers/CameraDriver.cs index e8aab0e..df93490 100644 --- a/Drivers/CameraDriver.cs +++ b/Drivers/CameraDriver.cs @@ -47,7 +47,6 @@ public class CameraDriver : BaseINPC, ICamera { private readonly PluginOptionsAccessor _pluginSettings; private readonly bool _enableNativeCancelDefault; private bool _softCancelRequested; - private bool _abortWarningShown; private SonyCameraInfo _camera = null; private SonyDevice _device = null; @@ -66,7 +65,6 @@ public CameraDriver(IProfileService profileService, IExposureDataFactory exposur _pluginSettings = pluginSettings; _enableNativeCancelDefault = enableNativeCancel; _softCancelRequested = false; - _abortWarningShown = false; } #region Internal Helpers @@ -610,7 +608,6 @@ public void StartExposure(CaptureSequence sequence) { double exposureTime; lock (_captureLock) { _softCancelRequested = false; - _abortWarningShown = false; if (!TryGetCaptureStatus(driver, out var captureStatus, "start exposure preflight") || captureStatus == CAPTURE_STATUS_UNKNOWN) { Logger.Warning("Cannot start exposure: capture status unavailable."); @@ -651,10 +648,7 @@ public void AbortExposure() { } _softCancelRequested = true; - if (!_abortWarningShown) { - Notification.ShowWarning("Abort requested; native cancel is disabled or the camera did not accept it. Exposure will continue until it finishes."); - _abortWarningShown = true; - } + Notification.ShowWarning("Abort requested; native cancel is disabled or the camera did not accept it. Exposure will continue until it finishes."); Logger.Info("AbortExposure requested; native cancel unavailable; letting capture finish."); } From 90bd2beb719786ca7307a00fabacb376a04827b4 Mon Sep 17 00:00:00 2001 From: christian-photo <77056626+christian-photo@users.noreply.github.com> Date: Fri, 6 Mar 2026 16:56:00 +0100 Subject: [PATCH 32/45] return false instead of throwing for HasSetupDialog --- Drivers/FocuserDriver.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Drivers/FocuserDriver.cs b/Drivers/FocuserDriver.cs index 9b3dfe3..8f08915 100644 --- a/Drivers/FocuserDriver.cs +++ b/Drivers/FocuserDriver.cs @@ -98,11 +98,11 @@ public int Position { public bool TempCompAvailable { get => false; } - public bool TempComp { get => false; set => throw new NotImplementedException(); } + public bool TempComp { get => false; set { } } public double Temperature { get => double.NaN; } - public bool HasSetupDialog => throw new NotImplementedException(); + public bool HasSetupDialog => false; public string Id { get => _info.Id; } From f3a878059f49dd78808466580eaa5ee3c4f8e6d3 Mon Sep 17 00:00:00 2001 From: totoantibes Date: Fri, 18 Sep 2026 11:02:49 +0200 Subject: [PATCH 33/45] Free the driver's image buffers instead of leaking one per frame GetCaptureStatus and GetPreviewImage copy the frame out of the buffer the driver returns in ImageInfo, then drop the pointer on the floor. The driver allocates ImageData and MetaData with CoTaskMemAlloc and hands ownership to us - the usual COM convention, and the one this class already follows for the device-id list in GetDeviceIds - so nothing ever released them and every captured frame leaked its full size. Measured on an a7S (~12 MB ARW) driving NINA 3.3.0.1057 with driver 1.0.1.21: a session left running from 2026-09-15 to 2026-09-18 took 1770 frames over 71.6 hours and reached a 28 GB working set, ~16.2 MB per frame (image plus metadata). It ended when a 12 MB CoTaskMemAlloc inside the driver failed with E_OUTOFMEMORY (hr = 0x8007000e) during a flat sequence and took NINA down mid-exposure. The driver reported the failure because it was the next caller to ask for memory, but the allocation it could not satisfy was ours. Add FreeImageInfo and call it from a finally at all four sites that hand an ImageInfo to the driver. finally rather than a straight-line call because both image paths can throw after the driver has already allocated, and liveview runs GetPreviewImage many times a second. Freeing a null pointer is a no-op, so it is safe whatever the driver populated. Verified on the same rig: 154 full-size frames in 9.4 minutes, where the old build would have grown ~2.44 GB, and memory stayed bounded at ~1.4 GB with no driver errors and no crash dump. --- SonyCameraPluginNative/SonyMTPCameraDll.cs | 109 ++++++++++++++++----- 1 file changed, 82 insertions(+), 27 deletions(-) diff --git a/SonyCameraPluginNative/SonyMTPCameraDll.cs b/SonyCameraPluginNative/SonyMTPCameraDll.cs index c23916e..07c152f 100644 --- a/SonyCameraPluginNative/SonyMTPCameraDll.cs +++ b/SonyCameraPluginNative/SonyMTPCameraDll.cs @@ -231,25 +231,59 @@ public void CloseDevice(UInt32 hDevice) { fCloseDevice(hDevice); } + /// + /// Releases the unmanaged buffers the driver returns inside an + /// . + /// + /// The driver fills ImageData and MetaData with CoTaskMemAlloc and hands + /// ownership to the caller - the usual COM convention, and the one this + /// class already follows for the device-id list in GetDeviceIds. Nothing + /// was releasing the image buffers, so every frame leaked its full size. + /// + /// + /// Freeing a null pointer is a documented no-op, so this is safe on any + /// ImageInfo whatever the driver populated, and safe to call twice. + /// + /// + private static void FreeImageInfo(ref ImageInfo info) { + if (info.ImageData != IntPtr.Zero) { + Marshal.FreeCoTaskMem(info.ImageData); + info.ImageData = IntPtr.Zero; + } + + if (info.MetaData != IntPtr.Zero) { + Marshal.FreeCoTaskMem(info.MetaData); + info.MetaData = IntPtr.Zero; + } + } + public byte[] GetPreviewImage(UInt32 hDevice) { ImageInfo info = new ImageInfo { ImageMode = IMAGEMODE_JPEG }; - uint hr = fGetPreviewImage(hDevice, ref info); - if (hr != ERROR_SUCCESS) { - throw new SonyException($"Unable to get preview image, error {hr}"); - } + try { + uint hr = fGetPreviewImage(hDevice, ref info); - byte[] returndata = new byte[info.ImageSize]; + if (hr != ERROR_SUCCESS) { + throw new SonyException($"Unable to get preview image, error {hr}"); + } - if (returndata == null) { - throw new SonyException("Unable to allocate memory for image"); - } + byte[] returndata = new byte[info.ImageSize]; + + if (returndata == null) { + throw new SonyException("Unable to allocate memory for image"); + } - Marshal.Copy(info.ImageData, returndata, 0, (Int32)info.ImageSize); + Marshal.Copy(info.ImageData, returndata, 0, (Int32)info.ImageSize); - return returndata; + return returndata; + } finally { + // finally, not a straight-line call: the throws above are + // reachable with a buffer already allocated, and liveview runs + // this many times a second. + FreeImageInfo(ref info); + } } public UInt32 StartCapture(UInt32 hDevice, float exposureTime) { @@ -262,38 +296,59 @@ public UInt32 StartCapture(UInt32 hDevice, float exposureTime) { ExposureTime = (double)exposureTime, }; - if (fStartCapture(hDevice, ref info) != ERROR_SUCCESS) { - throw new SonyException("Problem starting capture"); - } + try { + if (fStartCapture(hDevice, ref info) != ERROR_SUCCESS) { + throw new SonyException("Problem starting capture"); + } - return info.Status; + return info.Status; + } finally { + // Not expected to return a buffer, but it is handed the same + // struct and checking two null pointers costs nothing next to + // silently leaking one. + FreeImageInfo(ref info); + } } public void CancelCapture(UInt32 hDevice) { - ImageInfo Info = new ImageInfo(); + ImageInfo info = new ImageInfo(); - fCancelCapture(hDevice, ref Info); + try { + fCancelCapture(hDevice, ref info); + } finally { + FreeImageInfo(ref info); + } } public UInt32 GetCaptureStatus(UInt32 hDevice) { ImageInfo info = new ImageInfo(); - if (fGetCaptureStatus(hDevice, ref info) != ERROR_SUCCESS) { - throw new SonyException("Unable to get image status"); - } + try { + if (fGetCaptureStatus(hDevice, ref info) != ERROR_SUCCESS) { + throw new SonyException("Unable to get image status"); + } - // If the capture is complete, copy the image - if (info.Status == STATUS_COMPLETE) { - _lastImage = new byte[info.ImageSize]; + // If the capture is complete, copy the image + if (info.Status == STATUS_COMPLETE) { + _lastImage = new byte[info.ImageSize]; - if (_lastImage == null) { - throw new SonyException("Unable to allocate memory for image"); + if (_lastImage == null) { + throw new SonyException("Unable to allocate memory for image"); + } + + Marshal.Copy(info.ImageData, _lastImage, 0, (Int32)info.ImageSize); } - Marshal.Copy(info.ImageData, _lastImage, 0, (Int32)info.ImageSize); + return info.Status; + } finally { + // This is the leak that mattered: the driver allocates the frame + // here on every completed capture, and the copy above puts it in + // managed memory, so the unmanaged original is dead the moment + // Marshal.Copy returns. Freeing in finally also covers the paths + // that skip the copy - a cancelled or failed capture can still + // arrive with a buffer attached. + FreeImageInfo(ref info); } - - return info.Status; } public CameraInfo GetCameraInfo(UInt32 hDevice, UInt32 Flags) { From b441b73a3a7857f0c10dc6f337dc0e6129f3b358 Mon Sep 17 00:00:00 2001 From: Lucas Lepski Date: Sat, 3 Oct 2026 10:18:13 +0200 Subject: [PATCH 34/45] fix: serialize capture starts and aborts Keep native abort opt-in, reject busy starts, and prevent delayed aborts from cancelling newer captures. Report cancelled and failed captures without downloading stale images. Add 27 capture regression tests, CI checks, and hardware validation notes. --- .github/workflows/dotnet.yml | 5 +- CHANGELOG.md | 25 ++- Drivers/CameraDriver.cs | 176 ++++------------- Drivers/CameraProvider.cs | 3 +- Drivers/CaptureController.cs | 131 +++++++++++++ NINASonyCameraPlugin.csproj | 3 +- Options.xaml | 2 +- README.md | 3 +- Tests/CaptureController.Tests.csproj | 15 ++ Tests/CaptureControllerTests.cs | 250 ++++++++++++++++++++++++ docs/capture-cancellation-validation.md | 29 +++ 11 files changed, 483 insertions(+), 159 deletions(-) create mode 100644 Drivers/CaptureController.cs create mode 100644 Tests/CaptureController.Tests.csproj create mode 100644 Tests/CaptureControllerTests.cs create mode 100644 docs/capture-cancellation-validation.md diff --git a/.github/workflows/dotnet.yml b/.github/workflows/dotnet.yml index 6410f60..198e781 100644 --- a/.github/workflows/dotnet.yml +++ b/.github/workflows/dotnet.yml @@ -34,6 +34,9 @@ jobs: - name: Restore Packages run: nuget restore NINASonyCameraPlugin.sln + + - name: Test capture coordination + run: dotnet test Tests/CaptureController.Tests.csproj --configuration Release - name: Build run: msbuild.exe NINASonyCameraPlugin.sln /nologo /nr:false /p:DeployOnBuild=true /p:DeployDefaultTarget=WebPublish /p:WebPublishMethod=FileSystem /p:DeleteExistingFiles=True /p:platform="Any CPU" /p:configuration="Release" /p:PublishUrl="../_build" @@ -43,4 +46,4 @@ jobs: with: name: SonyCamera-DLL path: | - **/bin/Release/**/NINA.RetroKiwi.Plugin.SonyCamera.dll \ No newline at end of file + **/bin/Release/**/NINA.RetroKiwi.Plugin.SonyCamera.dll diff --git a/CHANGELOG.md b/CHANGELOG.md index 8eb3bb6..db7a70f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,30 +2,27 @@ ## 1.0.0.5 -- Added native cancel option in plugin settings and defaulted to off to avoid crashes. -- Serialized capture cancel/start/status checks and treated busy starts as soft cancels. -- Prevented new exposures from starting when the camera is busy; improved abort behavior without native cancel. +* Added a profile-specific native cancel option, defaulted to off to avoid abort-related crashes. +* Serialized native capture starts and status/cancel calls; busy or unknown states reject new exposures without cancelling the previous capture. +* Made aborts idempotent, kept their lock wait bounded, and prevented delayed aborts from cancelling a newer exposure. +* Reported cancelled and failed captures correctly rather than downloading a stale image. +* Added automated capture coordination regression tests and a camera validation checklist. ## 1.0.0.4 - -- Added the `UpdateSubSampleArea` implementation and bumped the minimum NINA version so the plugin loads in 3.2. -- Restored the ISO/Gain UI by notifying NINA whenever the camera connection updates ISO data and by handling cameras without ISO option lists. -- Probed the registry ISO property (`0xFFFE`) so older Sony bodies still populate the gain dropdown. -- Logged clearer errors when gain min/max cannot be determined and improved the GitHub workflow to publish the release DLL artifact. +* Added the `UpdateSubSampleArea` implementation and bumped the minimum NINA version so the plugin loads in 3.2. +* Restored the ISO/Gain UI by notifying NINA whenever the camera connection updates ISO data and by handling cameras without ISO option lists. +* Probed the registry ISO property (`0xFFFE`) so older Sony bodies still populate the gain dropdown. +* Logged clearer errors when gain min/max cannot be determined and improved the GitHub workflow to publish the release DLL artifact. ## 1.0.0.3 - Updated to support new device property "DisplayName" required in NINA 3 ## 1.0.0.2 - Rebuilt using .NET Core 8 for NINA 3 ## 1.0.0.1 - Initial release - Supports all functionality provided by the ASCOM version, plus: - -- LiveView support -- Actual ISO values displayed in Gain drop-down vs a numeric index (1, 2, 3, etc) +* LiveView support +* Actual ISO values displayed in Gain drop-down vs a numeric index (1, 2, 3, etc) Note that NINA saves the raw ARW files and does not generate FITS files for DSLRs (per the note in NINA: Options.Imaging). diff --git a/Drivers/CameraDriver.cs b/Drivers/CameraDriver.cs index df93490..d1a1958 100644 --- a/Drivers/CameraDriver.cs +++ b/Drivers/CameraDriver.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.IO; using System.Linq; @@ -20,7 +20,6 @@ using NINA.Image.Interfaces; using NINA.Profile; using NINA.Profile.Interfaces; -using NINA.RetroKiwi.Plugin.SonyCamera; using Sony; namespace NINA.RetroKiwi.Plugin.SonyCamera.Drivers { @@ -30,23 +29,8 @@ public class CameraDriver : BaseINPC, ICamera { private const uint PROPID_ISO = 0xD21E; // Actual ISO currently set private const uint PROPID_ISOS = 0xFFFE; // Registry-backed list of learnt ISOs (may be empty until learnt) - // Capture Status - private const uint CAPTURE_CREATED = 0x0000; - private const uint CAPTURE_CAPTURING = 0x0001; - private const uint CAPTURE_FAILED = 0x0002; - private const uint CAPTURE_CANCELLED = 0x0003; - private const uint CAPTURE_COMPLETE = 0x0004; - private const uint CAPTURE_STARTING = 0x8001; - private const uint CAPTURE_READING = 0x8002; - private const uint CAPTURE_PROCESSING = 0x8003; - private const uint CAPTURE_STATUS_UNKNOWN = 0xFFFFFFFF; - private static readonly uint[] IDLE_STATES = { CAPTURE_CREATED, CAPTURE_CANCELLED, CAPTURE_COMPLETE, CAPTURE_FAILED }; - private static readonly uint[] BUSY_STATES = { CAPTURE_CAPTURING, CAPTURE_PROCESSING, CAPTURE_STARTING, CAPTURE_READING }; - private static readonly uint[] COMPLETION_STATES = { CAPTURE_CANCELLED, CAPTURE_COMPLETE, CAPTURE_FAILED }; - private readonly PluginOptionsAccessor _pluginSettings; - private readonly bool _enableNativeCancelDefault; - private bool _softCancelRequested; + private readonly CaptureController _capture; private SonyCameraInfo _camera = null; private SonyDevice _device = null; @@ -56,15 +40,20 @@ public class CameraDriver : BaseINPC, ICamera { private short _readoutModeForSnapImages; private short _readoutModeForNormalImages; private AsyncObservableCollection _binningModes; - private readonly object _captureLock = new object(); - public CameraDriver(IProfileService profileService, IExposureDataFactory exposureDataFactory, SonyDevice device, PluginOptionsAccessor pluginSettings, bool enableNativeCancel) { + public CameraDriver(IProfileService profileService, IExposureDataFactory exposureDataFactory, SonyDevice device, PluginOptionsAccessor pluginSettings) { _profileService = profileService; _exposureDataFactory = exposureDataFactory; _device = device; _pluginSettings = pluginSettings; - _enableNativeCancelDefault = enableNativeCancel; - _softCancelRequested = false; + _capture = new CaptureController( + () => { + TryGetCaptureStatus(SonyDriver.GetInstance(), out var status, "capture status"); + return status; + }, + exposureTime => SonyDriver.GetInstance().StartCapture(_camera.Handle, exposureTime), + TryNativeCancel, + () => NativeCancelEnabled); } #region Internal Helpers @@ -87,7 +76,7 @@ private IReadOnlyList GetAvailableIsoOptions() { return options; } } catch (Exception ex) { - Logger.Warning($"Unable to enumerate ISO options for property 0x{propertyId:X}: {ex.Message}."); + Logger.Warning($"Unable to enumerate ISO options for property 0x{propertyId:X}: {ex.Message}"); } } @@ -106,64 +95,23 @@ private void NotifyGainPropertiesChanged() { private bool NativeCancelEnabled { get { - if (_pluginSettings != null) { - try { - var raw = _pluginSettings.GetValueString(nameof(SonyCamera.EnableNativeCancel), _enableNativeCancelDefault.ToString()); - if (bool.TryParse(raw, out var enabled)) { - return enabled; - } - } catch (Exception ex) { - Logger.Warning($"EnableNativeCancel read failed; defaulting to {_enableNativeCancelDefault}. {ex.Message}."); - } + try { + var raw = _pluginSettings.GetValueString(nameof(SonyCamera.EnableNativeCancel), bool.FalseString); + return bool.TryParse(raw, out var enabled) && enabled; + } catch (Exception ex) { + Logger.Warning($"EnableNativeCancel read failed; defaulting to false. {ex.Message}"); + return false; } - - return _enableNativeCancelDefault; } } - private bool TryCancelCaptureIfEnabled(string reason, int lockTimeoutMs = 1000) { - if (_camera == null) { - return false; - } - - if (!NativeCancelEnabled) { - Logger.Info($"Native cancel disabled; skipping cancel ({reason})."); - return false; - } - - bool lockTaken = false; + private bool TryNativeCancel() { try { - if (!Monitor.TryEnter(_captureLock, lockTimeoutMs)) { - Logger.Warning($"Skipping cancel ({reason}); capture lock unavailable after {lockTimeoutMs} ms."); - return false; - } - lockTaken = true; - - SonyDriver driver = SonyDriver.GetInstance(); - if (!TryGetCaptureStatus(driver, out var status, reason)) { - return false; - } - - if (status == CAPTURE_STATUS_UNKNOWN) { - Logger.Warning($"Skipping cancel ({reason}); capture status is unknown (camera did not respond)."); - return false; - } - - if (!BUSY_STATES.Contains(status)) { - Logger.Debug($"Skipping cancel ({reason}); capture status is {status}."); - return false; - } - - Logger.Info($"Issuing cancel ({reason}); capture status is {status}."); - driver.CancelCapture(_camera.Handle); + SonyDriver.GetInstance().CancelCapture(_camera.Handle); return true; } catch (Exception ex) { - Logger.Error($"CancelCapture failed ({reason}).", ex); + Logger.Error("CancelCapture failed; letting capture finish.", ex); return false; - } finally { - if (lockTaken) { - Monitor.Exit(_captureLock); - } } } @@ -173,7 +121,7 @@ private bool TryGetCaptureStatus(SonyDriver driver, out uint status, string reas return true; } catch (Exception ex) { Logger.Warning($"Unable to get capture status ({reason}); camera may not have responded: {ex.Message}."); - status = CAPTURE_STATUS_UNKNOWN; + status = CaptureController.StatusUnknown; return false; } } @@ -369,7 +317,7 @@ public int Gain { return (int)(value.Value == 0xffffff ? 0 : value.Value); } catch (Exception ex) { - Logger.Error("Problem getting gain.", ex); + Logger.Error("Problem getting gain", ex); return -1; } } else { @@ -383,7 +331,7 @@ public int Gain { SonyDriver.GetInstance().SetProperty(_camera.Handle, PROPID_ISO, (uint)value); RaisePropertyChanged(nameof(Gain)); } catch (Exception ex) { - Logger.Error($"Problem setting gain to {value}.", ex); + Logger.Error($"Problem setting gain to {value}", ex); } } } @@ -603,38 +551,12 @@ public void SetupDialog() { public void StartExposure(CaptureSequence sequence) { if (_camera != null) { - SonyDriver driver = SonyDriver.GetInstance(); - - double exposureTime; - lock (_captureLock) { - _softCancelRequested = false; - if (!TryGetCaptureStatus(driver, out var captureStatus, "start exposure preflight") || - captureStatus == CAPTURE_STATUS_UNKNOWN) { - Logger.Warning("Cannot start exposure: capture status unavailable."); - throw new TaskCanceledException("Cannot start exposure: capture status unavailable."); - } - - TryCancelCaptureIfEnabled("start exposure reset"); - if (NativeCancelEnabled && (!TryGetCaptureStatus(driver, out captureStatus, "start exposure post-cancel") || captureStatus == CAPTURE_STATUS_UNKNOWN)) { - Logger.Warning("Cannot start exposure: capture status unavailable after cancel."); - throw new TaskCanceledException("Cannot start exposure: capture status unavailable after cancel."); - } - - if (BUSY_STATES.Contains(captureStatus)) { - Notification.ShowWarning("Camera is still busy with a previous exposure; skipping new start."); - throw new TaskCanceledException("Cannot start exposure: camera is still busy with a previous exposure."); - } - - if (!IDLE_STATES.Contains(captureStatus)) { - Logger.Warning($"Cannot start exposure: camera in unexpected capture status ({captureStatus})."); - throw new TaskCanceledException($"Cannot start exposure: camera in unexpected capture status ({captureStatus})."); - } - - exposureTime = sequence.ExposureTime; + try { + _capture.Start((float)sequence.ExposureTime); + } catch (InvalidOperationException ex) { + Logger.Warning(ex.Message); + throw new SonyException(ex.Message); } - - // Start capture outside the capture lock to avoid blocking abort/cancel paths if the call hangs. - driver.StartCapture(_camera.Handle, (float)exposureTime); } } @@ -643,46 +565,22 @@ public void StopExposure() { } public void AbortExposure() { - if (TryCancelCaptureIfEnabled("abort request")) { + if (_camera == null) { return; } - _softCancelRequested = true; - Notification.ShowWarning("Abort requested; native cancel is disabled or the camera did not accept it. Exposure will continue until it finishes."); - Logger.Info("AbortExposure requested; native cancel unavailable; letting capture finish."); + if (_capture.Abort() == CaptureController.AbortResult.SoftCancelRequested) { + Notification.ShowWarning("Abort requested. The camera will finish the current exposure before another can start. Native cancellation is disabled or unavailable."); + Logger.Info("AbortExposure requested; native cancel unavailable; letting capture finish."); + } } public async Task WaitUntilExposureIsReady(CancellationToken token) { using (token.Register(AbortExposure)) { - - SonyDriver driver = SonyDriver.GetInstance(); - try { - uint captureStatus; - lock (_captureLock) { - if (!TryGetCaptureStatus(driver, out captureStatus, "wait begin") || captureStatus == CAPTURE_STATUS_UNKNOWN) { - throw new SonyException("Problem while waiting for image to be ready (status unavailable)"); - } - } - Logger.Info($"Waiting for image to be ready; current state is {captureStatus}; completion states are {String.Join(", ", COMPLETION_STATES)}."); - - while (!COMPLETION_STATES.Contains(captureStatus)) { - await CoreUtil.Wait(TimeSpan.FromMilliseconds(100), token); - - lock (_captureLock) { - if (!TryGetCaptureStatus(driver, out captureStatus, "wait poll") || captureStatus == CAPTURE_STATUS_UNKNOWN) { - throw new SonyException("Problem while waiting for image to be ready (status unavailable)"); - } - } - } - - Logger.Info($"Wait for image ready complete; completion state is {captureStatus}."); - if (_softCancelRequested || token.IsCancellationRequested) { - _softCancelRequested = false; - throw new TaskCanceledException("Exposure cancelled by user (soft cancel)."); - } - } catch (TaskCanceledException) { - Logger.Info("WaitUntilExposureIsReady cancelled by token; exiting without native cancel."); + await _capture.WaitUntilReady(token); + } catch (OperationCanceledException) { + Logger.Info("WaitUntilExposureIsReady cancelled."); throw; } catch (Exception ex) { Logger.Error("WaitUntilExposureIsReady got exception.", ex); diff --git a/Drivers/CameraProvider.cs b/Drivers/CameraProvider.cs index 94adaa9..d6d6447 100644 --- a/Drivers/CameraProvider.cs +++ b/Drivers/CameraProvider.cs @@ -10,7 +10,6 @@ using System.Threading; using System.Threading.Tasks; using System.ComponentModel.Composition; -using NINA.RetroKiwi.Plugin.SonyCamera; using NINA.Image.Interfaces; using NINA.WPF.Base.Mediator; using Sony; @@ -53,7 +52,7 @@ public IList GetEquipment() { foreach (var sonyDevice in driver.Cameras()) { count++; - devices.Add(new CameraDriver(profileService, exposureDataFactory, sonyDevice, pluginSettings, false)); + devices.Add(new CameraDriver(profileService, exposureDataFactory, sonyDevice, pluginSettings)); } Logger.Info($"Found {count} Sony Cameras"); diff --git a/Drivers/CaptureController.cs b/Drivers/CaptureController.cs new file mode 100644 index 0000000..fda0fdb --- /dev/null +++ b/Drivers/CaptureController.cs @@ -0,0 +1,131 @@ +using System; +using System.Threading; +using System.Threading.Tasks; + +namespace NINA.RetroKiwi.Plugin.SonyCamera.Drivers { + // Keep capture coordination independent of NINA and the native DLL so races can be tested without a camera. + internal sealed class CaptureController { + internal const uint StatusUnknown = 0xFFFFFFFF; + private const uint Created = 0x0000; + private const uint Capturing = 0x0001; + private const uint Failed = 0x0002; + private const uint Cancelled = 0x0003; + private const uint Complete = 0x0004; + private const uint Starting = 0x8001; + private const uint Reading = 0x8002; + private const uint Processing = 0x8003; + + internal enum AbortResult { AlreadyRequested, NativeCancelRequested, SoftCancelRequested } + + private readonly object captureLock = new object(); + private readonly Func getStatus; + private readonly Action startCapture; + private readonly Func cancelCapture; + private readonly Func nativeCancelEnabled; + private readonly int cancelLockTimeoutMs; + private sealed class CaptureRequest { + internal int CancelRequested; + } + private CaptureRequest currentRequest = new CaptureRequest(); + + internal CaptureController(Func getStatus, Action startCapture, + Func cancelCapture, Func nativeCancelEnabled, int cancelLockTimeoutMs = 1000) { + this.getStatus = getStatus; + this.startCapture = startCapture; + this.cancelCapture = cancelCapture; + this.nativeCancelEnabled = nativeCancelEnabled; + this.cancelLockTimeoutMs = cancelLockTimeoutMs; + } + + internal void Start(float exposureTime) { + lock (captureLock) { + uint status = getStatus(); + if (IsBusy(status)) { + throw new InvalidOperationException("Cannot start exposure: camera is still busy with a previous exposure."); + } + if (status != Created && status != Cancelled && status != Complete && status != Failed) { + throw new InvalidOperationException($"Cannot start exposure: capture status unavailable or unexpected ({status})."); + } + + // A rejected start must not clear cancellation of the preceding exposure. + Volatile.Write(ref currentRequest, new CaptureRequest()); + // Serialize the native start itself: it can replace the native capture object. + startCapture(exposureTime); + } + } + + internal AbortResult Abort() { + // Record cancellation before waiting for the lock, including while StartCapture is blocked. + var request = Volatile.Read(ref currentRequest); + if (Interlocked.Exchange(ref request.CancelRequested, 1) != 0) { + return AbortResult.AlreadyRequested; + } + if (!nativeCancelEnabled() || !Monitor.TryEnter(captureLock, cancelLockTimeoutMs)) { + return AbortResult.SoftCancelRequested; + } + + try { + // An abort queued for the previous exposure must never cancel a new one. + if (!ReferenceEquals(request, currentRequest)) { + return AbortResult.AlreadyRequested; + } + uint status = getStatus(); + if (status == StatusUnknown || (!IsBusy(status) && status != Created && + status != Cancelled && status != Complete && status != Failed)) { + return AbortResult.SoftCancelRequested; + } + if (!IsBusy(status)) { + return AbortResult.AlreadyRequested; + } + return cancelCapture() ? AbortResult.NativeCancelRequested : AbortResult.SoftCancelRequested; + } finally { + Monitor.Exit(captureLock); + } + } + + internal async Task WaitUntilReady(CancellationToken token) { + var request = Volatile.Read(ref currentRequest); + while (true) { + token.ThrowIfCancellationRequested(); + ThrowIfCancelled(request); + uint status; + lock (captureLock) { + if (!ReferenceEquals(request, currentRequest)) { + throw new TaskCanceledException("Exposure superseded by a new capture."); + } + status = getStatus(); + } + token.ThrowIfCancellationRequested(); + ThrowIfCancelled(request); + + switch (status) { + case Complete: + return; + case Cancelled: + throw new TaskCanceledException("Exposure cancelled by camera."); + case Failed: + throw new InvalidOperationException("Camera capture failed."); + case Created: + case Capturing: + case Starting: + case Reading: + case Processing: + await Task.Delay(100, token); + break; + default: + throw new InvalidOperationException($"Capture status unavailable or unexpected ({status})."); + } + } + } + + private static void ThrowIfCancelled(CaptureRequest request) { + if (Volatile.Read(ref request.CancelRequested) != 0) { + throw new TaskCanceledException("Exposure cancelled by user; the camera may still be finishing it."); + } + } + + private static bool IsBusy(uint status) { + return status == Capturing || status == Starting || status == Reading || status == Processing; + } + } +} diff --git a/NINASonyCameraPlugin.csproj b/NINASonyCameraPlugin.csproj index 949eaca..608d7c4 100644 --- a/NINASonyCameraPlugin.csproj +++ b/NINASonyCameraPlugin.csproj @@ -40,6 +40,7 @@ + @@ -68,7 +69,7 @@ - + diff --git a/Options.xaml b/Options.xaml index daafc7c..b7cb9c5 100644 --- a/Options.xaml +++ b/Options.xaml @@ -8,7 +8,7 @@ - + diff --git a/README.md b/README.md index 254ec52..0b9dd2d 100644 --- a/README.md +++ b/README.md @@ -31,7 +31,8 @@ https://github.com/dougforpres/NINASonyCameraPlugin/releases - Binning and sub-sampling are not supported; captures use the full sensor frame. - For exposures <= 30s the driver chooses the nearest built-in shutter speed; longer exposures fall back to Bulb. - Powering off/unplugging the camera while connected can crash NINA via Windows' MTP stack (PortableDeviceApi.dll access violation); disconnect in NINA first to avoid it. -- The “Enable native cancel” option calls the camera’s CancelCapture; stability varies by body and Windows driver stack. Leave it off if you see crashes when aborting exposures. +- “Enable native cancel” is off by default and saved per NINA profile. With it off, abort cancels the wait and discards the exposure, while the camera finishes capturing; another exposure can start once the camera finishes. Enabling it allows native cancellation during aborts, which may crash NINA on some camera models and Windows drivers. New exposure requests never cancel an existing capture. +- See the [capture cancellation validation checklist](docs/capture-cancellation-validation.md) for automated checks and camera testing before release. ## Support diff --git a/Tests/CaptureController.Tests.csproj b/Tests/CaptureController.Tests.csproj new file mode 100644 index 0000000..3ca46b0 --- /dev/null +++ b/Tests/CaptureController.Tests.csproj @@ -0,0 +1,15 @@ + + + net8.0 + true + false + + + + + + all + + + + diff --git a/Tests/CaptureControllerTests.cs b/Tests/CaptureControllerTests.cs new file mode 100644 index 0000000..f52c0f1 --- /dev/null +++ b/Tests/CaptureControllerTests.cs @@ -0,0 +1,250 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using NINA.RetroKiwi.Plugin.SonyCamera.Drivers; +using Xunit; + +public class CaptureControllerTests { + private sealed class Camera { + internal uint Status = 2; // The native driver reports Failed when no capture exists. + internal bool NativeCancelEnabled; + internal bool CancelSucceeds = true; + internal int Starts; + internal int Cancels; + internal Action BeforeStart; + internal Action BeforeReadNativeCancel; + internal Func ReadStatus; + internal readonly CaptureController Capture; + + internal Camera() { + Capture = new CaptureController( + () => ReadStatus == null ? Status : ReadStatus(), + exposure => { + BeforeStart?.Invoke(); + Starts++; + Status = 1; + }, + () => { + Cancels++; + return CancelSucceeds; + }, + () => { + BeforeReadNativeCancel?.Invoke(); + return NativeCancelEnabled; + }, + cancelLockTimeoutMs: 20); + } + } + + [Theory] + [InlineData(1u)] + [InlineData(0x8001u)] + [InlineData(0x8002u)] + [InlineData(0x8003u)] + [InlineData(0xFFFFFFFFu)] + [InlineData(99u)] + public void BusyOrUnknownStartNeverCancelsPreviousExposure(uint status) { + var camera = new Camera { Status = status, NativeCancelEnabled = true }; + Assert.Throws(() => camera.Capture.Start(1)); + Assert.Equal(0, camera.Starts); + Assert.Equal(0, camera.Cancels); + } + + [Theory] + [InlineData(0u)] + [InlineData(2u)] + [InlineData(3u)] + [InlineData(4u)] + public void IdleStartDoesNotCallNativeCancel(uint status) { + var camera = new Camera { Status = status, NativeCancelEnabled = true }; + camera.Capture.Start(1); + Assert.Equal(1, camera.Starts); + Assert.Equal(0, camera.Cancels); + } + + [Fact] + public async Task SoftAbortStopsWaitingAndRejectsRestartUntilCameraFinishes() { + var camera = new Camera(); + camera.Capture.Start(1); + Assert.Equal(CaptureController.AbortResult.SoftCancelRequested, camera.Capture.Abort()); + Assert.Equal(CaptureController.AbortResult.AlreadyRequested, camera.Capture.Abort()); + Assert.Throws(() => camera.Capture.Start(1)); + await Assert.ThrowsAsync(() => camera.Capture.WaitUntilReady(CancellationToken.None)); + Assert.Equal(0, camera.Cancels); + + camera.Status = 4; + camera.Capture.Start(1); + camera.Status = 4; + await camera.Capture.WaitUntilReady(CancellationToken.None); + Assert.Equal(2, camera.Starts); + } + + [Fact] + public async Task NativeAbortIsIssuedOnceAndNeverReturnsAnImage() { + var camera = new Camera { NativeCancelEnabled = true }; + camera.Capture.Start(1); + Assert.Equal(CaptureController.AbortResult.NativeCancelRequested, camera.Capture.Abort()); + Assert.Equal(CaptureController.AbortResult.AlreadyRequested, camera.Capture.Abort()); + camera.Status = 4; + await Assert.ThrowsAsync(() => camera.Capture.WaitUntilReady(CancellationToken.None)); + Assert.Equal(1, camera.Cancels); + } + + [Theory] + [InlineData(3u, typeof(TaskCanceledException))] + [InlineData(2u, typeof(InvalidOperationException))] + [InlineData(0xFFFFFFFFu, typeof(InvalidOperationException))] + [InlineData(99u, typeof(InvalidOperationException))] + public async Task CancelledFailedAndUnknownCapturesNeverReportReady(uint status, Type errorType) { + var camera = new Camera(); + camera.Capture.Start(1); + camera.Status = status; + var error = await Record.ExceptionAsync(() => camera.Capture.WaitUntilReady(CancellationToken.None)); + Assert.IsType(errorType, error); + } + + [Fact] + public async Task CompleteStatusIsReadOnlyOnceBecauseNativeReadConsumesTheImage() { + var camera = new Camera(); + camera.Capture.Start(1); + int reads = 0; + camera.ReadStatus = () => ++reads == 1 ? 4u : 2u; + await camera.Capture.WaitUntilReady(CancellationToken.None); + Assert.Equal(1, reads); + } + + [Fact] + public async Task CancellationDuringStatusReadWinsOverImageCompletion() { + var camera = new Camera(); + camera.Capture.Start(1); + using var cancellation = new CancellationTokenSource(); + camera.ReadStatus = () => { + cancellation.Cancel(); + return 4; + }; + await Assert.ThrowsAnyAsync(() => camera.Capture.WaitUntilReady(cancellation.Token)); + } + + [Fact] + public async Task TokenCancellationInterruptsPolling() { + var camera = new Camera(); + camera.Capture.Start(1); + using var cancellation = new CancellationTokenSource(); + var wait = camera.Capture.WaitUntilReady(cancellation.Token); + cancellation.Cancel(); + await Assert.ThrowsAnyAsync(() => wait.WaitAsync(TimeSpan.FromSeconds(2))); + } + + [Fact] + public void NativeCancelSettingIsReadAtAbortTime() { + var camera = new Camera(); + camera.Capture.Start(1); + camera.NativeCancelEnabled = true; + Assert.Equal(CaptureController.AbortResult.NativeCancelRequested, camera.Capture.Abort()); + Assert.Equal(1, camera.Cancels); + } + + [Fact] + public async Task FailedNativeCancelFallsBackToSoftCancellation() { + var camera = new Camera { NativeCancelEnabled = true, CancelSucceeds = false }; + camera.Capture.Start(1); + Assert.Equal(CaptureController.AbortResult.SoftCancelRequested, camera.Capture.Abort()); + await Assert.ThrowsAsync(() => camera.Capture.WaitUntilReady(CancellationToken.None)); + } + + [Theory] + [InlineData(0xFFFFFFFFu)] + [InlineData(99u)] + public async Task UnknownStatusDuringAbortSkipsNativeCancel(uint status) { + var camera = new Camera { NativeCancelEnabled = true }; + camera.Capture.Start(1); + camera.Status = status; + Assert.Equal(CaptureController.AbortResult.SoftCancelRequested, camera.Capture.Abort()); + Assert.Equal(0, camera.Cancels); + await Assert.ThrowsAsync(() => camera.Capture.WaitUntilReady(CancellationToken.None)); + } + + [Fact] + public async Task CancellationAfterPollingBeginsInterruptsWaitWithoutAToken() { + var camera = new Camera(); + camera.Capture.Start(1); + var wait = camera.Capture.WaitUntilReady(CancellationToken.None); + camera.Capture.Abort(); + await Assert.ThrowsAsync(() => wait.WaitAsync(TimeSpan.FromSeconds(2))); + } + + [Fact] + public async Task AbortDuringBlockedNativeStartReturnsWithoutCallingNativeCancel() { + var camera = new Camera { NativeCancelEnabled = true }; + using var entered = new ManualResetEventSlim(); + using var release = new ManualResetEventSlim(); + camera.BeforeStart = () => { + entered.Set(); + Assert.True(release.Wait(TimeSpan.FromSeconds(5))); + }; + var start = Task.Factory.StartNew(() => camera.Capture.Start(1), TaskCreationOptions.LongRunning); + try { + Assert.True(entered.Wait(TimeSpan.FromSeconds(5))); + var abort = Task.Run(() => camera.Capture.Abort()); + Assert.Equal(CaptureController.AbortResult.SoftCancelRequested, + await abort.WaitAsync(TimeSpan.FromSeconds(2))); + Assert.Equal(0, camera.Cancels); + } finally { + release.Set(); + } + await start; + await Assert.ThrowsAsync(() => camera.Capture.WaitUntilReady(CancellationToken.None)); + } + + [Fact] + public async Task ConcurrentStartsCannotBothEnterNativeStart() { + var camera = new Camera(); + using var entered = new ManualResetEventSlim(); + using var release = new ManualResetEventSlim(); + using var secondAttempted = new ManualResetEventSlim(); + camera.BeforeStart = () => { + entered.Set(); + Assert.True(release.Wait(TimeSpan.FromSeconds(5))); + }; + var first = Task.Factory.StartNew(() => camera.Capture.Start(1), TaskCreationOptions.LongRunning); + Task second = null; + try { + Assert.True(entered.Wait(TimeSpan.FromSeconds(5))); + second = Task.Factory.StartNew(() => { + secondAttempted.Set(); + return Record.Exception(() => camera.Capture.Start(1)); + }, TaskCreationOptions.LongRunning); + Assert.True(secondAttempted.Wait(TimeSpan.FromSeconds(5))); + Assert.NotSame(second, await Task.WhenAny(second, Task.Delay(100))); + } finally { + release.Set(); + } + await first; + Assert.IsType(await second); + Assert.Equal(1, camera.Starts); + } + + [Fact] + public async Task DelayedAbortForPreviousExposureCannotCancelNextExposure() { + var camera = new Camera { NativeCancelEnabled = true }; + camera.Capture.Start(1); + camera.Status = 4; + using var entered = new ManualResetEventSlim(); + using var release = new ManualResetEventSlim(); + camera.BeforeReadNativeCancel = () => { + entered.Set(); + Assert.True(release.Wait(TimeSpan.FromSeconds(5))); + }; + var abort = Task.Factory.StartNew(() => camera.Capture.Abort(), TaskCreationOptions.LongRunning); + try { + Assert.True(entered.Wait(TimeSpan.FromSeconds(5))); + camera.Capture.Start(1); + } finally { + release.Set(); + } + Assert.Equal(CaptureController.AbortResult.AlreadyRequested, await abort); + Assert.Equal(0, camera.Cancels); + camera.Status = 4; + await camera.Capture.WaitUntilReady(CancellationToken.None); + } +} diff --git a/docs/capture-cancellation-validation.md b/docs/capture-cancellation-validation.md new file mode 100644 index 0000000..70d1772 --- /dev/null +++ b/docs/capture-cancellation-validation.md @@ -0,0 +1,29 @@ +# Capture cancellation validation + +Run the regression tests with .NET 8: + +```sh +dotnet test Tests/CaptureController.Tests.csproj --configuration Release +``` + +The Windows plugin can also be cross-compiled on Linux with `dotnet build NINASonyCameraPlugin.csproj --configuration Release -p:EnableWindowsTargeting=true`. The local NINA install step runs only on Windows. Cross-compilation does not allow running the plugin or native driver on Linux. + +These tests compile the production capture controller directly and replace native camera calls with controlled callbacks. They cover busy and unknown states, native cancellation being opt-in, cancelled and failed captures never reporting an image ready, cancellation during status reads, repeated aborts, abort during a blocked start, concurrent starts, and an old abort arriving after a new exposure starts. CI runs these checks before building the Windows plugin DLL. + +The tests do not establish the stability of SonyMTPCamera.dll or the Windows MTP stack. Complete the following checks in NINA with a real camera before releasing 1.0.0.5. Record camera model, native driver version, Windows version, NINA version, plugin build commit, and relevant log timestamps with the results. + +| Check | Expected result | Result | +| --- | --- | --- | +| Fresh profile | Enable native cancel is off. | Pending | +| Change setting, reconnect, restart NINA, switch profiles | Setting persists per profile and takes effect on the next abort without recreating the camera driver. | Pending | +| Native cancel off: take several short exposures | Every exposure returns its own ARW image; the next capture starts normally. | Pending | +| Native cancel off: abort a long Bulb exposure | NINA stops waiting. The camera finishes the exposure naturally. Starting again while busy reports an error; after completion, the next exposure succeeds. | Pending | +| Native cancel off: repeatedly abort and rapidly request another exposure | One soft-cancel warning per exposure, no overlapping native start/cancel calls, and no NINA crash. | Pending | +| Native cancel off: stop a looping sequence and restart after the camera finishes | No cancelled frame is delivered as the new exposure. | Pending | +| Native cancel on: abort short and Bulb exposures, including repeated aborts | At most one native cancel request per exposure. NINA reports cancellation and does not return a cancelled image. Verify stability on the specific camera/driver combination. | Pending | +| Switch native cancel off again while connected | Abort returns to letting the camera finish naturally. | Pending | +| Normal LiveView, ISO selection, and reconnect | Existing behavior remains functional. | Pending | + +A successful native cancel call only means the request was issued; it does not prove the camera stopped. The next start always checks the reported state. Managed exception handling cannot recover from a native access violation. + +The maintainer's previous release prerequisite is satisfied in the published NINA manifest: [version 1.0.0.4 was added on December 12, 2025](https://github.com/isbeorn/nina.plugin.manifests/commit/d0ba770de8a26a5146b9ee8af7800c3293925d00). Merge and release remain maintainer decisions. From e1e88547eeadbf943df669b43a1adae9b8e8847f Mon Sep 17 00:00:00 2001 From: Lucas Lepski Date: Sat, 3 Oct 2026 10:26:12 +0200 Subject: [PATCH 35/45] docs: remove camera testing checklist --- CHANGELOG.md | 2 +- README.md | 1 - docs/capture-cancellation-validation.md | 29 ------------------------- 3 files changed, 1 insertion(+), 31 deletions(-) delete mode 100644 docs/capture-cancellation-validation.md diff --git a/CHANGELOG.md b/CHANGELOG.md index db7a70f..da98988 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,7 @@ * Serialized native capture starts and status/cancel calls; busy or unknown states reject new exposures without cancelling the previous capture. * Made aborts idempotent, kept their lock wait bounded, and prevented delayed aborts from cancelling a newer exposure. * Reported cancelled and failed captures correctly rather than downloading a stale image. -* Added automated capture coordination regression tests and a camera validation checklist. +* Added automated capture coordination regression tests. ## 1.0.0.4 * Added the `UpdateSubSampleArea` implementation and bumped the minimum NINA version so the plugin loads in 3.2. diff --git a/README.md b/README.md index 0b9dd2d..e429f4d 100644 --- a/README.md +++ b/README.md @@ -32,7 +32,6 @@ https://github.com/dougforpres/NINASonyCameraPlugin/releases - For exposures <= 30s the driver chooses the nearest built-in shutter speed; longer exposures fall back to Bulb. - Powering off/unplugging the camera while connected can crash NINA via Windows' MTP stack (PortableDeviceApi.dll access violation); disconnect in NINA first to avoid it. - “Enable native cancel” is off by default and saved per NINA profile. With it off, abort cancels the wait and discards the exposure, while the camera finishes capturing; another exposure can start once the camera finishes. Enabling it allows native cancellation during aborts, which may crash NINA on some camera models and Windows drivers. New exposure requests never cancel an existing capture. -- See the [capture cancellation validation checklist](docs/capture-cancellation-validation.md) for automated checks and camera testing before release. ## Support diff --git a/docs/capture-cancellation-validation.md b/docs/capture-cancellation-validation.md deleted file mode 100644 index 70d1772..0000000 --- a/docs/capture-cancellation-validation.md +++ /dev/null @@ -1,29 +0,0 @@ -# Capture cancellation validation - -Run the regression tests with .NET 8: - -```sh -dotnet test Tests/CaptureController.Tests.csproj --configuration Release -``` - -The Windows plugin can also be cross-compiled on Linux with `dotnet build NINASonyCameraPlugin.csproj --configuration Release -p:EnableWindowsTargeting=true`. The local NINA install step runs only on Windows. Cross-compilation does not allow running the plugin or native driver on Linux. - -These tests compile the production capture controller directly and replace native camera calls with controlled callbacks. They cover busy and unknown states, native cancellation being opt-in, cancelled and failed captures never reporting an image ready, cancellation during status reads, repeated aborts, abort during a blocked start, concurrent starts, and an old abort arriving after a new exposure starts. CI runs these checks before building the Windows plugin DLL. - -The tests do not establish the stability of SonyMTPCamera.dll or the Windows MTP stack. Complete the following checks in NINA with a real camera before releasing 1.0.0.5. Record camera model, native driver version, Windows version, NINA version, plugin build commit, and relevant log timestamps with the results. - -| Check | Expected result | Result | -| --- | --- | --- | -| Fresh profile | Enable native cancel is off. | Pending | -| Change setting, reconnect, restart NINA, switch profiles | Setting persists per profile and takes effect on the next abort without recreating the camera driver. | Pending | -| Native cancel off: take several short exposures | Every exposure returns its own ARW image; the next capture starts normally. | Pending | -| Native cancel off: abort a long Bulb exposure | NINA stops waiting. The camera finishes the exposure naturally. Starting again while busy reports an error; after completion, the next exposure succeeds. | Pending | -| Native cancel off: repeatedly abort and rapidly request another exposure | One soft-cancel warning per exposure, no overlapping native start/cancel calls, and no NINA crash. | Pending | -| Native cancel off: stop a looping sequence and restart after the camera finishes | No cancelled frame is delivered as the new exposure. | Pending | -| Native cancel on: abort short and Bulb exposures, including repeated aborts | At most one native cancel request per exposure. NINA reports cancellation and does not return a cancelled image. Verify stability on the specific camera/driver combination. | Pending | -| Switch native cancel off again while connected | Abort returns to letting the camera finish naturally. | Pending | -| Normal LiveView, ISO selection, and reconnect | Existing behavior remains functional. | Pending | - -A successful native cancel call only means the request was issued; it does not prove the camera stopped. The next start always checks the reported state. Managed exception handling cannot recover from a native access violation. - -The maintainer's previous release prerequisite is satisfied in the published NINA manifest: [version 1.0.0.4 was added on December 12, 2025](https://github.com/isbeorn/nina.plugin.manifests/commit/d0ba770de8a26a5146b9ee8af7800c3293925d00). Merge and release remain maintainer decisions. From f6c9688cee4bb1f2b630bcbd7ddb99bb78db5c6b Mon Sep 17 00:00:00 2001 From: Lucas Lepski Date: Sat, 3 Oct 2026 10:47:04 +0200 Subject: [PATCH 36/45] feat: add Sony camera simulator Inject camera communication so the production plugin can run without SonyMTPCamera.dll or hardware. Keep simulation opt-in and share the backend between NINA discovery and automated tests. Exercise timing, failures, profile settings, cancellation, and image routing with a manual clock and actual plugin integration tests. --- .github/workflows/dotnet.yml | 3 + CHANGELOG.md | 5 + Drivers/CameraDriver.cs | 45 ++-- Drivers/CameraProvider.cs | 10 +- Drivers/FocuserProvider.cs | 2 +- Drivers/ISonyCameraBackend.cs | 25 ++ Drivers/NativeSonyCameraBackend.cs | 19 ++ Drivers/SimulatedSonyCameraBackend.cs | 211 ++++++++++++++++ README.md | 44 ++++ SonyCameraPluginNative/SonyCameraInfo.cs | 1 - Tests/CaptureController.Tests.csproj | 6 + Tests/ManualTimeProvider.cs | 10 + .../CameraDriverSimulatorTests.cs | 230 ++++++++++++++++++ .../PluginIntegration.Tests.csproj | 18 ++ .../SimulatorDiscoveryTests.cs | 46 ++++ Tests/SimulatedSonyCameraTests.cs | 172 +++++++++++++ 16 files changed, 825 insertions(+), 22 deletions(-) create mode 100644 Drivers/ISonyCameraBackend.cs create mode 100644 Drivers/NativeSonyCameraBackend.cs create mode 100644 Drivers/SimulatedSonyCameraBackend.cs create mode 100644 Tests/ManualTimeProvider.cs create mode 100644 Tests/PluginIntegration/CameraDriverSimulatorTests.cs create mode 100644 Tests/PluginIntegration/PluginIntegration.Tests.csproj create mode 100644 Tests/PluginIntegration/SimulatorDiscoveryTests.cs create mode 100644 Tests/SimulatedSonyCameraTests.cs diff --git a/.github/workflows/dotnet.yml b/.github/workflows/dotnet.yml index 198e781..2fd842c 100644 --- a/.github/workflows/dotnet.yml +++ b/.github/workflows/dotnet.yml @@ -37,6 +37,9 @@ jobs: - name: Test capture coordination run: dotnet test Tests/CaptureController.Tests.csproj --configuration Release + + - name: Test plugin with simulated camera + run: dotnet test Tests/PluginIntegration/PluginIntegration.Tests.csproj --configuration Release - name: Build run: msbuild.exe NINASonyCameraPlugin.sln /nologo /nr:false /p:DeployOnBuild=true /p:DeployDefaultTarget=WebPublish /p:WebPublishMethod=FileSystem /p:DeleteExistingFiles=True /p:platform="Any CPU" /p:configuration="Release" /p:PublishUrl="../_build" diff --git a/CHANGELOG.md b/CHANGELOG.md index da98988..4cba68d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,10 @@ # Sony Camera Plugin +## Unreleased + +* Added an opt-in Sony Camera Simulator for development without the native driver or hardware. +* Added automated tests of camera discovery, profile settings, image delivery, and cancellation through the actual plugin driver. + ## 1.0.0.5 * Added a profile-specific native cancel option, defaulted to off to avoid abort-related crashes. diff --git a/Drivers/CameraDriver.cs b/Drivers/CameraDriver.cs index d1a1958..f646ccb 100644 --- a/Drivers/CameraDriver.cs +++ b/Drivers/CameraDriver.cs @@ -29,7 +29,9 @@ public class CameraDriver : BaseINPC, ICamera { private const uint PROPID_ISO = 0xD21E; // Actual ISO currently set private const uint PROPID_ISOS = 0xFFFE; // Registry-backed list of learnt ISOs (may be empty until learnt) - private readonly PluginOptionsAccessor _pluginSettings; + private readonly IPluginOptionsAccessor _pluginSettings; + private readonly ISonyCameraBackend _driver; + private readonly Action _showWarning; private readonly CaptureController _capture; private SonyCameraInfo _camera = null; @@ -41,17 +43,19 @@ public class CameraDriver : BaseINPC, ICamera { private short _readoutModeForNormalImages; private AsyncObservableCollection _binningModes; - public CameraDriver(IProfileService profileService, IExposureDataFactory exposureDataFactory, SonyDevice device, PluginOptionsAccessor pluginSettings) { + public CameraDriver(IProfileService profileService, IExposureDataFactory exposureDataFactory, SonyDevice device, IPluginOptionsAccessor pluginSettings, ISonyCameraBackend driver = null, Action showWarning = null) { _profileService = profileService; _exposureDataFactory = exposureDataFactory; _device = device; _pluginSettings = pluginSettings; + _driver = driver ?? new NativeSonyCameraBackend(); + _showWarning = showWarning ?? Notification.ShowWarning; _capture = new CaptureController( () => { - TryGetCaptureStatus(SonyDriver.GetInstance(), out var status, "capture status"); + TryGetCaptureStatus(_driver, out var status, "capture status"); return status; }, - exposureTime => SonyDriver.GetInstance().StartCapture(_camera.Handle, exposureTime), + exposureTime => _driver.StartCapture(_camera.Handle, exposureTime), TryNativeCancel, () => NativeCancelEnabled); } @@ -59,7 +63,7 @@ public CameraDriver(IProfileService profileService, IExposureDataFactory exposur #region Internal Helpers private PropertyValue GetPropertyValue(uint id) { - return SonyDriver.GetInstance().GetProperty(_camera.Handle, id); + return _driver.GetProperty(_camera.Handle, id); } private IReadOnlyList GetAvailableIsoOptions() { @@ -107,7 +111,7 @@ private bool NativeCancelEnabled { private bool TryNativeCancel() { try { - SonyDriver.GetInstance().CancelCapture(_camera.Handle); + _driver.CancelCapture(_camera.Handle); return true; } catch (Exception ex) { Logger.Error("CancelCapture failed; letting capture finish.", ex); @@ -115,7 +119,7 @@ private bool TryNativeCancel() { } } - private bool TryGetCaptureStatus(SonyDriver driver, out uint status, string reason) { + private bool TryGetCaptureStatus(ISonyCameraBackend driver, out uint status, string reason) { try { status = driver.GetCaptureStatus(_camera.Handle); return true; @@ -328,7 +332,7 @@ public int Gain { set { if (_camera != null) { try { - SonyDriver.GetInstance().SetProperty(_camera.Handle, PROPID_ISO, (uint)value); + _driver.SetProperty(_camera.Handle, PROPID_ISO, (uint)value); RaisePropertyChanged(nameof(Gain)); } catch (Exception ex) { Logger.Error($"Problem setting gain to {value}", ex); @@ -353,7 +357,7 @@ public IList Gains { } } - public string Id => "Sony"; + public string Id => _device.Id == SimulatedSonyCameraBackend.DeviceId ? SimulatedSonyCameraBackend.DeviceId : "Sony"; public string Name { get => _device.Model; @@ -491,7 +495,7 @@ public void StopLiveView() { public Task Connect(CancellationToken token) { return Task.Run(() => { try { - _camera = SonyDriver.GetInstance().OpenCamera(_device.Id); + _camera = _driver.OpenCamera(_device.Id); } catch (Exception ex) { Logger.Error(ex); _camera = null; @@ -505,7 +509,7 @@ public Task Connect(CancellationToken token) { public void Disconnect() { if (_camera != null) { try { - SonyDriver.GetInstance().CloseCamera(_camera.Handle); + _driver.CloseCamera(_camera.Handle); } catch (Exception ex) { Logger.Error(ex); } @@ -517,7 +521,7 @@ public void Disconnect() { public Task DownloadLiveView(CancellationToken token) { return Task.Run(() => { - using (var memStream = new MemoryStream(SonyDriver.GetInstance().GetLiveView(_camera.Handle))) { + using (var memStream = new MemoryStream(_driver.GetLiveView(_camera.Handle))) { memStream.Position = 0; JpegBitmapDecoder decoder = @@ -570,7 +574,7 @@ public void AbortExposure() { } if (_capture.Abort() == CaptureController.AbortResult.SoftCancelRequested) { - Notification.ShowWarning("Abort requested. The camera will finish the current exposure before another can start. Native cancellation is disabled or unavailable."); + _showWarning("Abort requested. The camera will finish the current exposure before another can start. Native cancellation is disabled or unavailable."); Logger.Info("AbortExposure requested; native cancel unavailable; letting capture finish."); } } @@ -591,13 +595,22 @@ public async Task WaitUntilExposureIsReady(CancellationToken token) { public Task DownloadExposure(CancellationToken token) { return Task.Run(() => { - byte[] rawImageData = SonyDriver.GetInstance().GetLastImage(); - + token.ThrowIfCancellationRequested(); + SonyExposureFrame image = _driver.GetLastImage(); var metaData = new ImageMetaData(); + if (image.Pixels != null) { + return _exposureDataFactory.CreateImageArrayExposureData( + input: image.Pixels, + width: image.Width, + height: image.Height, + bitDepth: image.BitDepth, + isBayered: true, + metaData: metaData); + } return _exposureDataFactory.CreateRAWExposureData( converter: _profileService.ActiveProfile.CameraSettings.RawConverter, - rawBytes: rawImageData, + rawBytes: image.RawBytes, rawType: "arw", bitDepth: this.BitDepth, metaData: metaData); diff --git a/Drivers/CameraProvider.cs b/Drivers/CameraProvider.cs index d6d6447..dab8049 100644 --- a/Drivers/CameraProvider.cs +++ b/Drivers/CameraProvider.cs @@ -24,7 +24,7 @@ namespace NINA.RetroKiwi.Plugin.SonyCamera.Drivers { public class CameraProvider : IEquipmentProvider { private IProfileService profileService; private IExposureDataFactory exposureDataFactory; - SonyDriver driver; + ISonyCameraBackend driver; private readonly PluginOptionsAccessor pluginSettings; [ImportingConstructor] @@ -33,9 +33,11 @@ public CameraProvider(IProfileService profileService, IExposureDataFactory expos this.exposureDataFactory = exposureDataFactory; this.pluginSettings = new PluginOptionsAccessor(profileService, SonyCamera.PluginGuid); - if (!DllLoader.IsX86()) { + if (SimulatedSonyCameraBackend.Enabled) { + this.driver = SimulatedSonyCameraBackend.FromEnvironment(); + } else if (!DllLoader.IsX86()) { try { - this.driver = SonyDriver.GetInstance(); + this.driver = new NativeSonyCameraBackend(); } catch (Exception ex) { Logger.Error(ex); } @@ -52,7 +54,7 @@ public IList GetEquipment() { foreach (var sonyDevice in driver.Cameras()) { count++; - devices.Add(new CameraDriver(profileService, exposureDataFactory, sonyDevice, pluginSettings)); + devices.Add(new CameraDriver(profileService, exposureDataFactory, sonyDevice, pluginSettings, driver)); } Logger.Info($"Found {count} Sony Cameras"); diff --git a/Drivers/FocuserProvider.cs b/Drivers/FocuserProvider.cs index 7fdefc4..1982787 100644 --- a/Drivers/FocuserProvider.cs +++ b/Drivers/FocuserProvider.cs @@ -28,7 +28,7 @@ public class FocuserProvider : IEquipmentProvider { public FocuserProvider(IProfileService profileService) { this.profileService = profileService; - if (!DllLoader.IsX86()) { + if (!SimulatedSonyCameraBackend.Enabled && !DllLoader.IsX86()) { try { this.driver = SonyDriver.GetInstance(); } catch (Exception ex) { diff --git a/Drivers/ISonyCameraBackend.cs b/Drivers/ISonyCameraBackend.cs new file mode 100644 index 0000000..48c2a75 --- /dev/null +++ b/Drivers/ISonyCameraBackend.cs @@ -0,0 +1,25 @@ +using System.Collections.Generic; +using Sony; + +namespace NINA.RetroKiwi.Plugin.SonyCamera.Drivers { + public interface ISonyCameraBackend { + IEnumerable Cameras(); + SonyCameraInfo OpenCamera(string id); + void CloseCamera(uint handle); + PropertyValue GetProperty(uint handle, uint propertyId); + void SetProperty(uint handle, uint propertyId, uint value); + byte[] GetLiveView(uint handle); + uint GetCaptureStatus(uint handle); + void StartCapture(uint handle, float exposureTime); + void CancelCapture(uint handle); + SonyExposureFrame GetLastImage(); + } + + public sealed class SonyExposureFrame { + public byte[] RawBytes { get; init; } + public ushort[] Pixels { get; init; } + public int Width { get; init; } + public int Height { get; init; } + public int BitDepth { get; init; } + } +} diff --git a/Drivers/NativeSonyCameraBackend.cs b/Drivers/NativeSonyCameraBackend.cs new file mode 100644 index 0000000..bc674e9 --- /dev/null +++ b/Drivers/NativeSonyCameraBackend.cs @@ -0,0 +1,19 @@ +using System.Collections.Generic; +using Sony; + +namespace NINA.RetroKiwi.Plugin.SonyCamera.Drivers { + internal sealed class NativeSonyCameraBackend : ISonyCameraBackend { + private readonly SonyDriver driver = SonyDriver.GetInstance(); + + public IEnumerable Cameras() => driver.Cameras(); + public SonyCameraInfo OpenCamera(string id) => driver.OpenCamera(id); + public void CloseCamera(uint handle) => driver.CloseCamera(handle); + public PropertyValue GetProperty(uint handle, uint propertyId) => driver.GetProperty(handle, propertyId); + public void SetProperty(uint handle, uint propertyId, uint value) => driver.SetProperty(handle, propertyId, value); + public byte[] GetLiveView(uint handle) => driver.GetLiveView(handle); + public uint GetCaptureStatus(uint handle) => driver.GetCaptureStatus(handle); + public void StartCapture(uint handle, float exposureTime) => driver.StartCapture(handle, exposureTime); + public void CancelCapture(uint handle) => driver.CancelCapture(handle); + public SonyExposureFrame GetLastImage() => new SonyExposureFrame { RawBytes = driver.GetLastImage() }; + } +} diff --git a/Drivers/SimulatedSonyCameraBackend.cs b/Drivers/SimulatedSonyCameraBackend.cs new file mode 100644 index 0000000..94b9f10 --- /dev/null +++ b/Drivers/SimulatedSonyCameraBackend.cs @@ -0,0 +1,211 @@ +using System; +using System.Collections.Generic; +using Sony; + +namespace NINA.RetroKiwi.Plugin.SonyCamera.Drivers { + // A managed replacement for the native camera calls, shared by NINA and automated tests. + public sealed class SimulatedSonyCameraBackend : ISonyCameraBackend { + public const string DeviceId = "sony-camera-simulator"; + public static bool Enabled => Environment.GetEnvironmentVariable("NINA_SONY_SIMULATOR") == "1"; + private const uint IsoProperty = 0xD21E; + private const uint IsoOptionsProperty = 0xFFFE; + private const uint BatteryProperty = 53784; + private const int Width = 320; + private const int Height = 240; + private const int BitDepth = 14; + private readonly object cameraLock = new object(); + private readonly TimeProvider clock; + private uint handle; + private bool connected; + private bool captureActive; + private uint captureStatus = 2; + private uint iso = 400; + private long startedAt; + private TimeSpan exposureDuration; + private int frameNumber; + private SonyExposureFrame lastImage; + + public TimeSpan ReadoutDelay { get; set; } = TimeSpan.FromMilliseconds(150); + public bool FailCapture { get; set; } + public bool IgnoreNativeCancel { get; set; } + public int StatusReadFailures { get; set; } + public int NativeCancelCalls { get; private set; } + public int CaptureStarts { get; private set; } + + public SimulatedSonyCameraBackend(TimeProvider clock = null) { + this.clock = clock ?? TimeProvider.System; + } + + internal static SimulatedSonyCameraBackend FromEnvironment() { + var camera = new SimulatedSonyCameraBackend(); + switch (Environment.GetEnvironmentVariable("NINA_SONY_SIMULATOR_SCENARIO")) { + case "slow-readout": camera.ReadoutDelay = TimeSpan.FromSeconds(10); break; + case "ignored-cancel": camera.IgnoreNativeCancel = true; break; + case "status-failure": camera.StatusReadFailures = 1; break; + case "failed-capture": camera.FailCapture = true; break; + } + return camera; + } + + public IEnumerable Cameras() { + yield return new SonyDevice(new PortableDeviceInfo { + id = DeviceId, manufacturer = "Sony", model = "Sony Camera Simulator", devicePath = DeviceId + }); + } + + public SonyCameraInfo OpenCamera(string id) { + lock (cameraLock) { + if (id != DeviceId) { + throw new InvalidOperationException("Unknown simulated camera."); + } + if (connected) { + throw new InvalidOperationException("Simulated camera is already connected."); + } + connected = true; + handle++; + captureActive = false; + captureStatus = 2; + lastImage = null; + var isos = new List(); + foreach (uint value in new uint[] { 100, 200, 400, 800, 1600, 3200 }) { + isos.Add(new PropertyValueOption { Value = value, Name = value.ToString() }); + } + var properties = new List { + new Sony.PropertyInfo(new PropertyDescriptor { Id = IsoProperty, Name = "ISO", ValueCount = (uint)isos.Count }, isos), + new Sony.PropertyInfo(new PropertyDescriptor { Id = IsoOptionsProperty, Name = "ISO options", ValueCount = (uint)isos.Count }, isos) + }; + return new SonyCameraInfo(handle, new DeviceInfo { + Manufacturer = "Sony", Model = "Sony Camera Simulator", DeviceName = "Sony Camera Simulator", + SensorName = "Simulated RGGB sensor", ImageWidthPixels = Width, ImageHeightPixels = Height, + ExposureTimeMin = 0.01, ExposureTimeMax = 3600, PixelWidth = 4.5, PixelHeight = 4.5, + BitsPerPixel = BitDepth + }, new CameraInfo(), properties); + } + } + + public void CloseCamera(uint cameraHandle) { + lock (cameraLock) { + RequireConnection(cameraHandle); + connected = false; + captureActive = false; + lastImage = null; + } + } + + public PropertyValue GetProperty(uint cameraHandle, uint propertyId) { + lock (cameraLock) { + RequireConnection(cameraHandle); + return propertyId switch { + IsoProperty => new PropertyValue { Id = propertyId, Value = iso }, + BatteryProperty => new PropertyValue { Id = propertyId, Value = 100 }, + _ => throw new InvalidOperationException("Unsupported simulated camera property.") + }; + } + } + + public void SetProperty(uint cameraHandle, uint propertyId, uint value) { + lock (cameraLock) { + RequireConnection(cameraHandle); + if (propertyId != IsoProperty || (value != 100 && value != 200 && value != 400 && + value != 800 && value != 1600 && value != 3200)) { + throw new InvalidOperationException("Unsupported simulated ISO value."); + } + iso = value; + } + } + + public byte[] GetLiveView(uint cameraHandle) => throw new NotSupportedException("The Sony simulator does not provide LiveView."); + + public void StartCapture(uint cameraHandle, float exposureTime) { + lock (cameraLock) { + RequireConnection(cameraHandle); + UpdateCapture(); + if (captureActive && captureStatus != 2 && captureStatus != 3 && captureStatus != 4) { + throw new InvalidOperationException("Simulated camera is busy."); + } + if (float.IsNaN(exposureTime) || float.IsInfinity(exposureTime) || exposureTime < 0.01f || exposureTime > 3600) { + throw new ArgumentOutOfRangeException(nameof(exposureTime)); + } + lastImage = null; + captureActive = true; + captureStatus = 0x8001; + startedAt = clock.GetTimestamp(); + exposureDuration = TimeSpan.FromSeconds(exposureTime); + frameNumber++; + CaptureStarts++; + } + } + + public uint GetCaptureStatus(uint cameraHandle) { + lock (cameraLock) { + RequireConnection(cameraHandle); + if (StatusReadFailures > 0) { + StatusReadFailures--; + throw new InvalidOperationException("Simulated camera status read failure."); + } + UpdateCapture(); + uint result = captureStatus; + if (result == 4) { + // Match the native API: reading Complete consumes the capture and saves its image. + lastImage = CreateFrame(); + captureActive = false; + captureStatus = 2; + } + return result; + } + } + + public void CancelCapture(uint cameraHandle) { + lock (cameraLock) { + RequireConnection(cameraHandle); + NativeCancelCalls++; + UpdateCapture(); + if (captureActive && captureStatus != 4 && !IgnoreNativeCancel) { + captureStatus = 3; + captureActive = false; + lastImage = null; + } + } + } + + public SonyExposureFrame GetLastImage() { + lock (cameraLock) { + if (!connected || lastImage == null) { + throw new InvalidOperationException("No completed simulated exposure is available."); + } + return lastImage; + } + } + + private void UpdateCapture() { + if (!captureActive) return; + var elapsed = clock.GetElapsedTime(startedAt); + if (elapsed < TimeSpan.FromMilliseconds(20)) captureStatus = 0x8001; + else if (elapsed < exposureDuration) captureStatus = 1; + else if (elapsed < exposureDuration + ReadoutDelay) captureStatus = 0x8002; + else if (elapsed < exposureDuration + ReadoutDelay + TimeSpan.FromMilliseconds(20)) captureStatus = 0x8003; + else { + captureStatus = FailCapture ? 2u : 4u; + if (FailCapture) captureActive = false; + } + } + + private SonyExposureFrame CreateFrame() { + var pixels = new ushort[Width * Height]; + for (int y = 0; y < Height; y++) { + for (int x = 0; x < Width; x++) { + double distance = Math.Pow(x - Width / 2.0, 2) + Math.Pow(y - Height / 2.0, 2); + pixels[y * Width + x] = (ushort)Math.Min(16383, 200 + frameNumber + (x + y) % 31 + 12000 * Math.Exp(-distance / 50)); + } + } + pixels[0] = (ushort)(frameNumber % 16384); + return new SonyExposureFrame { Pixels = pixels, Width = Width, Height = Height, BitDepth = BitDepth }; + } + + private void RequireConnection(uint cameraHandle) { + if (!connected || cameraHandle != handle) { + throw new InvalidOperationException("Simulated camera is disconnected or the handle is invalid."); + } + } + } +} diff --git a/README.md b/README.md index e429f4d..b2f879e 100644 --- a/README.md +++ b/README.md @@ -33,6 +33,50 @@ https://github.com/dougforpres/NINASonyCameraPlugin/releases - Powering off/unplugging the camera while connected can crash NINA via Windows' MTP stack (PortableDeviceApi.dll access violation); disconnect in NINA first to avoid it. - “Enable native cancel” is off by default and saved per NINA profile. With it off, abort cancels the wait and discards the exposure, while the camera finishes capturing; another exposure can start once the camera finishes. Enabling it allows native cancellation during aborts, which may crash NINA on some camera models and Windows drivers. New exposure requests never cancel an existing capture. +## Simulator development + +The plugin includes an opt-in **Sony Camera Simulator** that runs the actual Sony camera driver without SonyMTPCamera.dll or a physical camera. It generates distinct 320 × 240, 14-bit Bayer images and exposes ISO controls, exposure timing, readout, cancellation, and reconnect behavior. + +Launch NINA from PowerShell with simulator mode enabled: + +```powershell +$env:NINA_SONY_SIMULATOR = "1" +& "C:\Program Files\N.I.N.A. - Nighttime Imaging 'N' Astronomy\NINA.exe" +``` + +Adjust the executable path if your installation differs. Select **Sony Camera Simulator** in NINA's camera list. This mode replaces Sony camera discovery and skips Sony lens discovery, so the native DLL is not loaded. Close NINA and remove the environment variable to return to physical Sony equipment: + +```powershell +Remove-Item Env:NINA_SONY_SIMULATOR -ErrorAction SilentlyContinue +Remove-Item Env:NINA_SONY_SIMULATOR_SCENARIO -ErrorAction SilentlyContinue +``` + +Set `$env:NINA_SONY_SIMULATOR_SCENARIO` before launching NINA to exercise a failure scenario: + +| Value | Behavior | +| --- | --- | +| Unset | Normal exposure and readout. | +| `slow-readout` | Ten seconds of readout after each exposure. | +| `ignored-cancel` | Native cancel requests leave the capture running. | +| `status-failure` | The first capture status read throws an error; later reads recover. | +| `failed-capture` | Captures finish in the failed state without an image. | + +Synthetic frames use NINA's image-array path. This simulator does not exercise ARW decoding, LiveView, Sony USB/MTP communication, or native access violations. + +Run the portable capture and simulator tests with .NET 8: + +```sh +dotnet test Tests/CaptureController.Tests.csproj --configuration Release +``` + +Run the actual plugin integration tests on Windows: + +```powershell +dotnet test Tests/PluginIntegration/PluginIntegration.Tests.csproj --configuration Release +``` + +The integration tests use the production camera driver, NINA profile settings, and NINA exposure objects with the simulated backend. A manual clock advances long exposures immediately. Both suites run in Windows CI; the portable suite also runs on Linux. On Linux, cross-compile with `dotnet build NINASonyCameraPlugin.csproj --configuration Release -p:EnableWindowsTargeting=true`. + ## Support - Best contact is email via the Homepage link or the ASCOM driver troubleshooting page footer. diff --git a/SonyCameraPluginNative/SonyCameraInfo.cs b/SonyCameraPluginNative/SonyCameraInfo.cs index 55c8682..caf9fb7 100644 --- a/SonyCameraPluginNative/SonyCameraInfo.cs +++ b/SonyCameraPluginNative/SonyCameraInfo.cs @@ -5,7 +5,6 @@ using System.Text; using System.Threading.Tasks; using System.Drawing; -using NINA.Core.Utility; namespace Sony { public class Size { diff --git a/Tests/CaptureController.Tests.csproj b/Tests/CaptureController.Tests.csproj index 3ca46b0..51f06b4 100644 --- a/Tests/CaptureController.Tests.csproj +++ b/Tests/CaptureController.Tests.csproj @@ -5,11 +5,17 @@ false + all + + + + + diff --git a/Tests/ManualTimeProvider.cs b/Tests/ManualTimeProvider.cs new file mode 100644 index 0000000..66fc64d --- /dev/null +++ b/Tests/ManualTimeProvider.cs @@ -0,0 +1,10 @@ +using System; +using System.Threading; + +internal sealed class ManualTimeProvider : TimeProvider { + private long ticks; + public override long TimestampFrequency => TimeSpan.TicksPerSecond; + public override long GetTimestamp() => Interlocked.Read(ref ticks); + public override DateTimeOffset GetUtcNow() => DateTimeOffset.UnixEpoch.AddTicks(GetTimestamp()); + internal void Advance(TimeSpan duration) => Interlocked.Add(ref ticks, duration.Ticks); +} diff --git a/Tests/PluginIntegration/CameraDriverSimulatorTests.cs b/Tests/PluginIntegration/CameraDriverSimulatorTests.cs new file mode 100644 index 0000000..e56e353 --- /dev/null +++ b/Tests/PluginIntegration/CameraDriverSimulatorTests.cs @@ -0,0 +1,230 @@ +using System; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Moq; +using NINA.Equipment.Model; +using NINA.Image.ImageData; +using NINA.Image.Interfaces; +using NINA.Profile; +using NINA.Profile.Interfaces; +using NINA.RetroKiwi.Plugin.SonyCamera; +using NINA.RetroKiwi.Plugin.SonyCamera.Drivers; +using Xunit; + +public class CameraDriverSimulatorTests { + private sealed class Fixture : IDisposable { + internal readonly ManualTimeProvider Clock = new ManualTimeProvider(); + internal readonly SimulatedSonyCameraBackend Backend; + internal readonly Mock Profile = new Mock { DefaultValue = DefaultValue.Mock }; + internal readonly Mock ExposureFactory = new Mock(); + internal readonly PluginOptionsAccessor Options; + internal readonly CameraDriver Driver; + internal PluginSettings ActiveSettings = new PluginSettings(); + internal ushort[] DownloadedPixels; + internal int Warnings; + + internal Fixture() { + Backend = new SimulatedSonyCameraBackend(Clock); + Profile.SetupGet(x => x.ActiveProfile.PluginSettings).Returns(() => ActiveSettings); + Options = new PluginOptionsAccessor(Profile.Object, SonyCamera.PluginGuid); + ExposureFactory.Setup(x => x.CreateImageArrayExposureData(It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .Returns((ushort[] pixels, int width, int height, int bitDepth, bool isBayered, ImageMetaData metadata) => { + DownloadedPixels = pixels; + return new ImageArrayExposureData(pixels, width, height, bitDepth, isBayered, metadata, + new Mock().Object); + }); + Driver = new CameraDriver(Profile.Object, ExposureFactory.Object, Backend.Cameras().Single(), Options, + Backend, message => Warnings++); + } + + internal void Start(double duration = 1) => Driver.StartExposure(new CaptureSequence { ExposureTime = duration }); + internal void Finish(double seconds = 2) => Clock.Advance(TimeSpan.FromSeconds(seconds)); + public void Dispose() => Driver.Disconnect(); + } + + [Fact] + public async Task ActualDriverConnectsAndExposesCameraPropertiesAndIso() { + using var test = new Fixture(); + Assert.False(test.Driver.Connected); + Assert.True(await test.Driver.Connect(CancellationToken.None)); + Assert.Equal("Sony Camera Simulator", test.Driver.DisplayName); + Assert.Equal(SimulatedSonyCameraBackend.DeviceId, test.Driver.Id); + Assert.Equal(320, test.Driver.CameraXSize); + Assert.Equal(240, test.Driver.CameraYSize); + Assert.Equal(14, test.Driver.BitDepth); + Assert.Equal(100, test.Driver.BatteryLevel); + Assert.Equal(new int[] { 100, 200, 400, 800, 1600, 3200 }, test.Driver.Gains); + test.Driver.Gain = 1600; + Assert.Equal(1600, test.Driver.Gain); + test.Driver.Disconnect(); + Assert.False(test.Driver.Connected); + } + + [Fact] + public async Task ActualDriverWaitsDownloadsAndCreatesNinaExposureData() { + using var test = new Fixture(); + Assert.True(await test.Driver.Connect(CancellationToken.None)); + test.Start(); + test.Finish(); + await test.Driver.WaitUntilExposureIsReady(CancellationToken.None); + var image = Assert.IsType(await test.Driver.DownloadExposure(CancellationToken.None)); + Assert.Equal(320, image.Width); + Assert.Equal(240, image.Height); + Assert.Equal(14, image.BitDepth); + Assert.True(image.IsBayered); + Assert.Equal(320 * 240, test.DownloadedPixels.Length); + Assert.Equal(1, test.DownloadedPixels[0]); + Assert.Equal(0, test.Backend.NativeCancelCalls); + } + + [Fact] + public async Task SoftAbortWarnsOnceAndBusyRestartDoesNotLoseCancellation() { + using var test = new Fixture(); + Assert.True(await test.Driver.Connect(CancellationToken.None)); + test.Start(120); + test.Driver.AbortExposure(); + test.Driver.StopExposure(); + var error = Assert.ThrowsAny(() => test.Start()); + Assert.Contains("busy", error.Message); + Assert.False(error is OperationCanceledException); + await Assert.ThrowsAsync(() => test.Driver.WaitUntilExposureIsReady(CancellationToken.None)); + Assert.Equal(1, test.Warnings); + Assert.Equal(0, test.Backend.NativeCancelCalls); + test.Finish(121); + test.Start(); + test.Finish(); + await test.Driver.WaitUntilExposureIsReady(CancellationToken.None); + await test.Driver.DownloadExposure(CancellationToken.None); + Assert.Equal(2, test.DownloadedPixels[0]); + } + + [Fact] + public async Task TokenCancellationRunsActualAbortCallbackAndKeepsCancellationException() { + using var test = new Fixture(); + Assert.True(await test.Driver.Connect(CancellationToken.None)); + test.Start(120); + using var cancellation = new CancellationTokenSource(); + var wait = test.Driver.WaitUntilExposureIsReady(cancellation.Token); + cancellation.Cancel(); + await Assert.ThrowsAnyAsync(() => wait.WaitAsync(TimeSpan.FromSeconds(3))); + Assert.Equal(1, test.Warnings); + Assert.Equal(0, test.Backend.NativeCancelCalls); + } + + [Fact] + public async Task NativeCancelOptionIsPersistedAndReadFromCurrentProfileAtAbortTime() { + using var test = new Fixture(); + Assert.True(await test.Driver.Connect(CancellationToken.None)); + test.Start(); + test.Options.SetValueString(nameof(SonyCamera.EnableNativeCancel), bool.TrueString); + test.Driver.AbortExposure(); + test.Driver.AbortExposure(); + Assert.Equal(1, test.Backend.NativeCancelCalls); + await Assert.ThrowsAsync(() => test.Driver.WaitUntilExposureIsReady(CancellationToken.None)); + + var firstProfile = test.ActiveSettings; + test.ActiveSettings = new PluginSettings(); + test.Start(); + test.Driver.AbortExposure(); + Assert.Equal(1, test.Backend.NativeCancelCalls); + Assert.Equal(1, test.Warnings); + test.Finish(); + test.ActiveSettings = firstProfile; + Assert.Equal(bool.TrueString, test.Options.GetValueString(nameof(SonyCamera.EnableNativeCancel), bool.FalseString)); + test.Start(); + test.Driver.AbortExposure(); + Assert.Equal(2, test.Backend.NativeCancelCalls); + } + + [Fact] + public async Task IgnoredNativeCancelStillBlocksRestartUntilCameraFinishes() { + using var test = new Fixture(); + test.Backend.IgnoreNativeCancel = true; + test.Options.SetValueString(nameof(SonyCamera.EnableNativeCancel), bool.TrueString); + Assert.True(await test.Driver.Connect(CancellationToken.None)); + test.Start(120); + test.Driver.AbortExposure(); + Assert.Contains("busy", Assert.ThrowsAny(() => test.Start()).Message); + await Assert.ThrowsAsync(() => test.Driver.WaitUntilExposureIsReady(CancellationToken.None)); + Assert.Equal(1, test.Backend.NativeCancelCalls); + test.Finish(121); + test.Start(); + } + + [Fact] + public async Task CaptureAndStatusFailuresAreErrorsRatherThanSuccessfulImages() { + using var test = new Fixture(); + Assert.True(await test.Driver.Connect(CancellationToken.None)); + test.Backend.FailCapture = true; + test.Start(); + test.Finish(); + var failed = await Assert.ThrowsAnyAsync(() => test.Driver.WaitUntilExposureIsReady(CancellationToken.None)); + Assert.False(failed is OperationCanceledException); + Assert.Throws(() => test.Backend.GetLastImage()); + test.Backend.FailCapture = false; + test.Start(); + test.Backend.StatusReadFailures = 1; + var unreadable = await Assert.ThrowsAnyAsync(() => test.Driver.WaitUntilExposureIsReady(CancellationToken.None)); + Assert.False(unreadable is OperationCanceledException); + test.Finish(); + await test.Driver.WaitUntilExposureIsReady(CancellationToken.None); + await test.Driver.DownloadExposure(CancellationToken.None); + Assert.Equal(2, test.DownloadedPixels[0]); + } + + [Fact] + public async Task ReconnectDiscardsPreviousImageAndSubsamplingUsesReportedSensorSize() { + using var test = new Fixture(); + Assert.True(await test.Driver.Connect(CancellationToken.None)); + test.Start(); + test.Finish(); + await test.Driver.WaitUntilExposureIsReady(CancellationToken.None); + test.Driver.Disconnect(); + Assert.True(await test.Driver.Connect(CancellationToken.None)); + await Assert.ThrowsAsync(() => test.Driver.DownloadExposure(CancellationToken.None)); + test.Driver.UpdateSubSampleArea(); + Assert.Equal(320, test.Driver.SubSampleWidth); + Assert.Equal(240, test.Driver.SubSampleHeight); + } + + [Fact] + public async Task RepeatedActualPluginExposuresDeliverDistinctNinaImages() { + using var test = new Fixture(); + Assert.True(await test.Driver.Connect(CancellationToken.None)); + for (int frame = 1; frame <= 20; frame++) { + test.Start(); + test.Finish(); + await test.Driver.WaitUntilExposureIsReady(CancellationToken.None); + Assert.IsType(await test.Driver.DownloadExposure(CancellationToken.None)); + Assert.Equal(frame, test.DownloadedPixels[0]); + } + Assert.Equal(20, test.Backend.CaptureStarts); + } + + [Fact] + public async Task RawBackendImageStillUsesExistingArwFactoryPath() { + using var test = new Fixture(); + byte[] bytes = { 1, 2, 3, 4 }; + var backend = new Mock(); + backend.Setup(x => x.OpenCamera(It.IsAny())).Returns(test.Backend.OpenCamera(SimulatedSonyCameraBackend.DeviceId)); + backend.Setup(x => x.GetLastImage()).Returns(new SonyExposureFrame { RawBytes = bytes }); + var expected = new Mock().Object; + test.ExposureFactory.SetReturnsDefault(expected); + var driver = new CameraDriver(test.Profile.Object, test.ExposureFactory.Object, + test.Backend.Cameras().Single(), test.Options, backend.Object); + try { + Assert.True(await driver.Connect(CancellationToken.None)); + Assert.Same(expected, await driver.DownloadExposure(CancellationToken.None)); + var invocation = Assert.Single(test.ExposureFactory.Invocations); + Assert.Equal("CreateRAWExposureData", invocation.Method.Name); + var parameters = invocation.Method.GetParameters(); + Assert.Same(bytes, invocation.Arguments[Array.FindIndex(parameters, p => p.Name == "rawBytes")]); + Assert.Equal("arw", invocation.Arguments[Array.FindIndex(parameters, p => p.Name == "rawType")]); + Assert.Equal(14, invocation.Arguments[Array.FindIndex(parameters, p => p.Name == "bitDepth")]); + } finally { + driver.Disconnect(); + } + } +} diff --git a/Tests/PluginIntegration/PluginIntegration.Tests.csproj b/Tests/PluginIntegration/PluginIntegration.Tests.csproj new file mode 100644 index 0000000..b47b219 --- /dev/null +++ b/Tests/PluginIntegration/PluginIntegration.Tests.csproj @@ -0,0 +1,18 @@ + + + net8.0-windows10.0.17763.0 + true + true + false + + + + + + all + + + + + + diff --git a/Tests/PluginIntegration/SimulatorDiscoveryTests.cs b/Tests/PluginIntegration/SimulatorDiscoveryTests.cs new file mode 100644 index 0000000..21d6d87 --- /dev/null +++ b/Tests/PluginIntegration/SimulatorDiscoveryTests.cs @@ -0,0 +1,46 @@ +using System; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Moq; +using NINA.Image.ImageData; +using NINA.Image.Interfaces; +using NINA.Profile; +using NINA.Profile.Interfaces; +using NINA.RetroKiwi.Plugin.SonyCamera.Drivers; +using Xunit; + +[CollectionDefinition("Simulator environment", DisableParallelization = true)] +public class SimulatorEnvironmentCollection { } + +[Collection("Simulator environment")] +public class SimulatorDiscoveryTests { + [Fact] + public async Task SimulatorAppearsInActualNinaProviderWithoutNativeDll() { + string previous = Environment.GetEnvironmentVariable("NINA_SONY_SIMULATOR"); + string scenario = Environment.GetEnvironmentVariable("NINA_SONY_SIMULATOR_SCENARIO"); + Environment.SetEnvironmentVariable("NINA_SONY_SIMULATOR", "1"); + Environment.SetEnvironmentVariable("NINA_SONY_SIMULATOR_SCENARIO", null); + try { + var profile = new Mock(); + profile.SetupGet(x => x.ActiveProfile.PluginSettings).Returns(new PluginSettings()); + var factory = new Mock(); + var provider = new CameraProvider(profile.Object, factory.Object); + var camera = Assert.Single(provider.GetEquipment()); + try { + Assert.Equal("Sony Camera Simulator", camera.DisplayName); + Assert.True(await camera.Connect(CancellationToken.None)); + camera.StartExposure(new NINA.Equipment.Model.CaptureSequence { ExposureTime = 0.01 }); + using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + await camera.WaitUntilExposureIsReady(timeout.Token); + Assert.Empty(new FocuserProvider(profile.Object).GetEquipment()); + Assert.False(camera.CanShowLiveView); + } finally { + camera.Disconnect(); + } + } finally { + Environment.SetEnvironmentVariable("NINA_SONY_SIMULATOR", previous); + Environment.SetEnvironmentVariable("NINA_SONY_SIMULATOR_SCENARIO", scenario); + } + } +} diff --git a/Tests/SimulatedSonyCameraTests.cs b/Tests/SimulatedSonyCameraTests.cs new file mode 100644 index 0000000..e5b3f5a --- /dev/null +++ b/Tests/SimulatedSonyCameraTests.cs @@ -0,0 +1,172 @@ +using System; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using NINA.RetroKiwi.Plugin.SonyCamera.Drivers; +using Xunit; + +public class SimulatedSonyCameraTests { + [Fact] + public void EnumerateConnectAndChangeIsoWithoutLoadingNativeDriver() { + var camera = new SimulatedSonyCameraBackend(); + var device = Assert.Single(camera.Cameras()); + var info = camera.OpenCamera(device.Id); + Assert.Equal("Sony Camera Simulator", device.Model); + Assert.Equal(320, info.ImageSize.Width); + Assert.Equal(240, info.ImageSize.Height); + Assert.Equal(14, info.BitsPerPixel); + Assert.False(info.SupportsPreview()); + Assert.Equal(new uint[] { 100, 200, 400, 800, 1600, 3200 }, info.GetPropertyInfo(0xFFFE).Options().Select(x => x.Value)); + camera.SetProperty(info.Handle, 0xD21E, 800); + Assert.Equal(800u, camera.GetProperty(info.Handle, 0xD21E).Value); + Assert.Equal(100u, camera.GetProperty(info.Handle, 53784).Value); + Assert.Throws(() => camera.SetProperty(info.Handle, 0xD21E, 123)); + } + + [Fact] + public void ExposureProgressesThroughNativeStatesAndCompleteConsumesCapture() { + var clock = new ManualTimeProvider(); + var camera = new SimulatedSonyCameraBackend(clock); + uint handle = camera.OpenCamera(SimulatedSonyCameraBackend.DeviceId).Handle; + camera.StartCapture(handle, 1); + Assert.Equal(0x8001u, camera.GetCaptureStatus(handle)); + clock.Advance(TimeSpan.FromMilliseconds(30)); + Assert.Equal(1u, camera.GetCaptureStatus(handle)); + Assert.Throws(() => camera.StartCapture(handle, 1)); + clock.Advance(TimeSpan.FromSeconds(1)); + Assert.Equal(0x8002u, camera.GetCaptureStatus(handle)); + clock.Advance(TimeSpan.FromMilliseconds(130)); + Assert.Equal(0x8003u, camera.GetCaptureStatus(handle)); + clock.Advance(TimeSpan.FromMilliseconds(20)); + Assert.Equal(4u, camera.GetCaptureStatus(handle)); + Assert.Equal(2u, camera.GetCaptureStatus(handle)); + var frame = camera.GetLastImage(); + Assert.Equal(320 * 240, frame.Pixels.Length); + Assert.Equal(1, frame.Pixels[0]); + Assert.All(frame.Pixels, pixel => Assert.InRange(pixel, (ushort)0, (ushort)16383)); + } + + [Fact] + public async Task SoftAbortAllowsPhysicalCaptureToFinishAndNextExposureIsFresh() { + var clock = new ManualTimeProvider(); + var camera = new SimulatedSonyCameraBackend(clock); + uint handle = camera.OpenCamera(SimulatedSonyCameraBackend.DeviceId).Handle; + var controller = new CaptureController(() => camera.GetCaptureStatus(handle), + duration => camera.StartCapture(handle, duration), () => { camera.CancelCapture(handle); return true; }, () => false); + controller.Start(120); + controller.Abort(); + await Assert.ThrowsAsync(() => controller.WaitUntilReady(CancellationToken.None)); + Assert.Throws(() => controller.Start(1)); + clock.Advance(TimeSpan.FromSeconds(121)); + controller.Start(1); + clock.Advance(TimeSpan.FromSeconds(2)); + await controller.WaitUntilReady(CancellationToken.None); + Assert.Equal(2, camera.GetLastImage().Pixels[0]); + Assert.Equal(0, camera.NativeCancelCalls); + } + + [Fact] + public void AcceptedNativeAbortEndsCaptureWithoutProducingImage() { + var camera = new SimulatedSonyCameraBackend(); + uint handle = camera.OpenCamera(SimulatedSonyCameraBackend.DeviceId).Handle; + camera.StartCapture(handle, 120); + camera.CancelCapture(handle); + Assert.Equal(3u, camera.GetCaptureStatus(handle)); + Assert.Throws(() => camera.GetLastImage()); + camera.StartCapture(handle, 1); + Assert.Equal(2, camera.CaptureStarts); + } + + [Fact] + public void IgnoredNativeAbortRemainsBusyUntilExposureAndReadoutFinish() { + var clock = new ManualTimeProvider(); + var camera = new SimulatedSonyCameraBackend(clock) { IgnoreNativeCancel = true, ReadoutDelay = TimeSpan.FromSeconds(10) }; + uint handle = camera.OpenCamera(SimulatedSonyCameraBackend.DeviceId).Handle; + camera.StartCapture(handle, 1); + clock.Advance(TimeSpan.FromMilliseconds(50)); + camera.CancelCapture(handle); + Assert.Equal(1u, camera.GetCaptureStatus(handle)); + clock.Advance(TimeSpan.FromSeconds(2)); + Assert.Equal(0x8002u, camera.GetCaptureStatus(handle)); + Assert.Throws(() => camera.StartCapture(handle, 1)); + clock.Advance(TimeSpan.FromSeconds(10)); + Assert.Equal(4u, camera.GetCaptureStatus(handle)); + } + + [Fact] + public void StatusFailuresLeaveCaptureRunningAndCanRecover() { + var clock = new ManualTimeProvider(); + var camera = new SimulatedSonyCameraBackend(clock); + uint handle = camera.OpenCamera(SimulatedSonyCameraBackend.DeviceId).Handle; + camera.StartCapture(handle, 1); + camera.StatusReadFailures = 1; + Assert.Throws(() => camera.GetCaptureStatus(handle)); + Assert.Equal(0x8001u, camera.GetCaptureStatus(handle)); + clock.Advance(TimeSpan.FromSeconds(2)); + Assert.Equal(4u, camera.GetCaptureStatus(handle)); + } + + [Fact] + public void FailedCaptureHasNoImageAndDoesNotResurrectWhenFailureIsDisabled() { + var clock = new ManualTimeProvider(); + var camera = new SimulatedSonyCameraBackend(clock) { FailCapture = true }; + uint handle = camera.OpenCamera(SimulatedSonyCameraBackend.DeviceId).Handle; + camera.StartCapture(handle, 1); + clock.Advance(TimeSpan.FromSeconds(2)); + Assert.Equal(2u, camera.GetCaptureStatus(handle)); + camera.FailCapture = false; + Assert.Equal(2u, camera.GetCaptureStatus(handle)); + Assert.Throws(() => camera.GetLastImage()); + camera.StartCapture(handle, 1); + clock.Advance(TimeSpan.FromSeconds(2)); + Assert.Equal(4u, camera.GetCaptureStatus(handle)); + } + + [Fact] + public void ReconnectInvalidatesOldHandleAndDiscardsLastImage() { + var clock = new ManualTimeProvider(); + var camera = new SimulatedSonyCameraBackend(clock); + uint oldHandle = camera.OpenCamera(SimulatedSonyCameraBackend.DeviceId).Handle; + camera.StartCapture(oldHandle, 1); + clock.Advance(TimeSpan.FromSeconds(2)); + camera.GetCaptureStatus(oldHandle); + camera.CloseCamera(oldHandle); + Assert.Throws(() => camera.GetLastImage()); + uint newHandle = camera.OpenCamera(SimulatedSonyCameraBackend.DeviceId).Handle; + Assert.NotEqual(oldHandle, newHandle); + Assert.Throws(() => camera.GetCaptureStatus(oldHandle)); + Assert.Throws(() => camera.GetLastImage()); + } + + [Fact] + public void RepeatedExposuresReturnDistinctFramesWithoutRealTimeDelays() { + var clock = new ManualTimeProvider(); + var camera = new SimulatedSonyCameraBackend(clock); + uint handle = camera.OpenCamera(SimulatedSonyCameraBackend.DeviceId).Handle; + ushort[] previous = null; + for (int frame = 1; frame <= 100; frame++) { + camera.StartCapture(handle, 120); + Assert.Throws(() => camera.GetLastImage()); + clock.Advance(TimeSpan.FromSeconds(121)); + Assert.Equal(4u, camera.GetCaptureStatus(handle)); + ushort[] pixels = camera.GetLastImage().Pixels; + Assert.Equal(frame, pixels[0]); + Assert.NotSame(previous, pixels); + previous = pixels; + } + Assert.Equal(100, camera.CaptureStarts); + } + + [Theory] + [InlineData(float.NaN)] + [InlineData(float.PositiveInfinity)] + [InlineData(0f)] + [InlineData(-1f)] + [InlineData(3601f)] + public void InvalidDurationCannotReplaceExistingCapture(float duration) { + var camera = new SimulatedSonyCameraBackend(); + uint handle = camera.OpenCamera(SimulatedSonyCameraBackend.DeviceId).Handle; + Assert.Throws(() => camera.StartCapture(handle, duration)); + Assert.Equal(0, camera.CaptureStarts); + } +} From e03c38eaf7f0adbbbbe1950ef1e5d49f1adeff54 Mon Sep 17 00:00:00 2001 From: Lucas Lepski Date: Sat, 3 Oct 2026 10:50:21 +0200 Subject: [PATCH 37/45] test: return concrete RAW exposure from mock --- Tests/PluginIntegration/CameraDriverSimulatorTests.cs | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/Tests/PluginIntegration/CameraDriverSimulatorTests.cs b/Tests/PluginIntegration/CameraDriverSimulatorTests.cs index e56e353..3f3a24d 100644 --- a/Tests/PluginIntegration/CameraDriverSimulatorTests.cs +++ b/Tests/PluginIntegration/CameraDriverSimulatorTests.cs @@ -3,6 +3,7 @@ using System.Threading; using System.Threading.Tasks; using Moq; +using NINA.Core.Enum; using NINA.Equipment.Model; using NINA.Image.ImageData; using NINA.Image.Interfaces; @@ -210,8 +211,10 @@ public async Task RawBackendImageStillUsesExistingArwFactoryPath() { var backend = new Mock(); backend.Setup(x => x.OpenCamera(It.IsAny())).Returns(test.Backend.OpenCamera(SimulatedSonyCameraBackend.DeviceId)); backend.Setup(x => x.GetLastImage()).Returns(new SonyExposureFrame { RawBytes = bytes }); - var expected = new Mock().Object; - test.ExposureFactory.SetReturnsDefault(expected); + var expected = new RAWExposureData(new Mock().Object, bytes, "arw", 14, + new ImageMetaData(), new Mock().Object); + test.ExposureFactory.Setup(x => x.CreateRAWExposureData(It.IsAny(), + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())).Returns(expected); var driver = new CameraDriver(test.Profile.Object, test.ExposureFactory.Object, test.Backend.Cameras().Single(), test.Options, backend.Object); try { From 7f7ddeedf9e8f39167296f22b1f19e043960cefc Mon Sep 17 00:00:00 2001 From: Lucas Lepski Date: Sat, 3 Oct 2026 16:15:50 +0200 Subject: [PATCH 38/45] fix: preserve connections and completed captures --- CHANGELOG.md | 1 + Drivers/CameraDriver.cs | 26 ++++++++++------ Drivers/SimulatedSonyCameraBackend.cs | 3 +- .../CameraDriverSimulatorTests.cs | 30 +++++++++++++++++++ Tests/SimulatedSonyCameraTests.cs | 22 ++++++++++++++ 5 files changed, 72 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4cba68d..813f6b7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ * Added an opt-in Sony Camera Simulator for development without the native driver or hardware. * Added automated tests of camera discovery, profile settings, image delivery, and cancellation through the actual plugin driver. +* Preserved active camera connections when connect is called repeatedly or concurrently, and honored cancelled connection requests. ## 1.0.0.5 diff --git a/Drivers/CameraDriver.cs b/Drivers/CameraDriver.cs index f646ccb..c557570 100644 --- a/Drivers/CameraDriver.cs +++ b/Drivers/CameraDriver.cs @@ -33,6 +33,7 @@ public class CameraDriver : BaseINPC, ICamera { private readonly ISonyCameraBackend _driver; private readonly Action _showWarning; private readonly CaptureController _capture; + private readonly object connectionLock = new object(); private SonyCameraInfo _camera = null; private SonyDevice _device = null; @@ -494,20 +495,27 @@ public void StopLiveView() { public Task Connect(CancellationToken token) { return Task.Run(() => { - try { - _camera = _driver.OpenCamera(_device.Id); - } catch (Exception ex) { - Logger.Error(ex); - _camera = null; + bool connected; + lock (connectionLock) { + token.ThrowIfCancellationRequested(); + if (_camera != null) return true; + try { + _camera = _driver.OpenCamera(_device.Id); + } catch (Exception ex) { + Logger.Error(ex); + _camera = null; + } + connected = _camera != null; } NotifyGainPropertiesChanged(); - return _camera != null; - }); + return connected; + }, token); } public void Disconnect() { - if (_camera != null) { + lock (connectionLock) { + if (_camera == null) return; try { _driver.CloseCamera(_camera.Handle); } catch (Exception ex) { @@ -515,8 +523,8 @@ public void Disconnect() { } _camera = null; - NotifyGainPropertiesChanged(); } + NotifyGainPropertiesChanged(); } public Task DownloadLiveView(CancellationToken token) { diff --git a/Drivers/SimulatedSonyCameraBackend.cs b/Drivers/SimulatedSonyCameraBackend.cs index 94b9f10..1d176e9 100644 --- a/Drivers/SimulatedSonyCameraBackend.cs +++ b/Drivers/SimulatedSonyCameraBackend.cs @@ -178,7 +178,8 @@ public SonyExposureFrame GetLastImage() { } private void UpdateCapture() { - if (!captureActive) return; + // Complete remains latched until GetCaptureStatus collects the image. + if (!captureActive || captureStatus == 4) return; var elapsed = clock.GetElapsedTime(startedAt); if (elapsed < TimeSpan.FromMilliseconds(20)) captureStatus = 0x8001; else if (elapsed < exposureDuration) captureStatus = 1; diff --git a/Tests/PluginIntegration/CameraDriverSimulatorTests.cs b/Tests/PluginIntegration/CameraDriverSimulatorTests.cs index 3f3a24d..03fe733 100644 --- a/Tests/PluginIntegration/CameraDriverSimulatorTests.cs +++ b/Tests/PluginIntegration/CameraDriverSimulatorTests.cs @@ -63,6 +63,36 @@ public async Task ActualDriverConnectsAndExposesCameraPropertiesAndIso() { Assert.False(test.Driver.Connected); } + [Fact] + public async Task RepeatedAndConcurrentConnectsPreserveTheActiveCamera() { + using var test = new Fixture(); + var connections = await Task.WhenAll(test.Driver.Connect(CancellationToken.None), + test.Driver.Connect(CancellationToken.None)); + Assert.All(connections, connected => Assert.True(connected)); + Assert.True(await test.Driver.Connect(CancellationToken.None)); + Assert.True(test.Driver.Connected); + test.Start(); + test.Finish(); + await test.Driver.WaitUntilExposureIsReady(CancellationToken.None); + await test.Driver.DownloadExposure(CancellationToken.None); + Assert.Equal(1, test.DownloadedPixels[0]); + test.Driver.Disconnect(); + Assert.True(await test.Driver.Connect(CancellationToken.None)); + } + + [Fact] + public async Task CancelledConnectDoesNotOpenOrLoseAnExistingCamera() { + using var test = new Fixture(); + using var cancellation = new CancellationTokenSource(); + cancellation.Cancel(); + await Assert.ThrowsAnyAsync(() => test.Driver.Connect(cancellation.Token)); + Assert.False(test.Driver.Connected); + Assert.True(await test.Driver.Connect(CancellationToken.None)); + await Assert.ThrowsAnyAsync(() => test.Driver.Connect(cancellation.Token)); + Assert.True(test.Driver.Connected); + Assert.Equal(400, test.Driver.Gain); + } + [Fact] public async Task ActualDriverWaitsDownloadsAndCreatesNinaExposureData() { using var test = new Fixture(); diff --git a/Tests/SimulatedSonyCameraTests.cs b/Tests/SimulatedSonyCameraTests.cs index e5b3f5a..b39aaac 100644 --- a/Tests/SimulatedSonyCameraTests.cs +++ b/Tests/SimulatedSonyCameraTests.cs @@ -122,6 +122,28 @@ public void FailedCaptureHasNoImageAndDoesNotResurrectWhenFailureIsDisabled() { Assert.Equal(4u, camera.GetCaptureStatus(handle)); } + [Fact] + public void CompletedCaptureCannotFailOrReturnToReadoutBeforeImageIsCollected() { + var clock = new ManualTimeProvider(); + var camera = new SimulatedSonyCameraBackend(clock); + uint handle = camera.OpenCamera(SimulatedSonyCameraBackend.DeviceId).Handle; + camera.StartCapture(handle, 1); + clock.Advance(TimeSpan.FromSeconds(2)); + // Cancellation observes Complete without consuming its image. + camera.CancelCapture(handle); + camera.FailCapture = true; + camera.ReadoutDelay = TimeSpan.FromSeconds(10); + Assert.Equal(4u, camera.GetCaptureStatus(handle)); + Assert.Equal(1, camera.GetLastImage().Pixels[0]); + // Updated settings still apply to subsequent captures. + camera.StartCapture(handle, 1); + clock.Advance(TimeSpan.FromSeconds(2)); + Assert.Equal(0x8002u, camera.GetCaptureStatus(handle)); + clock.Advance(TimeSpan.FromSeconds(10)); + Assert.Equal(2u, camera.GetCaptureStatus(handle)); + Assert.Throws(() => camera.GetLastImage()); + } + [Fact] public void ReconnectInvalidatesOldHandleAndDiscardsLastImage() { var clock = new ManualTimeProvider(); From d2a2d37b4819b0f7a614d01de30f7738fd2bdedd Mon Sep 17 00:00:00 2001 From: Lucas Lepski Date: Sat, 3 Oct 2026 16:55:53 +0200 Subject: [PATCH 39/45] docs: explain how to launch lens learning --- README.md | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/README.md b/README.md index a98c5bd..6e117b1 100644 --- a/README.md +++ b/README.md @@ -32,6 +32,22 @@ https://github.com/dougforpres/NINASonyCameraPlugin/releases - For exposures <= 30s the driver chooses the nearest built-in shutter speed; longer exposures fall back to Bulb. - Powering off/unplugging the camera while connected can crash NINA via Windows' MTP stack (PortableDeviceApi.dll access violation); disconnect in NINA first to avoid it. +## Learning an unsupported lens + +The lens-learning tool is separate from the NINA plugin. The current Sony driver installer (1.0.1.21) includes `SonyCameraInfo.exe` and `SonyCameraInfo64.exe`, but not the lens-learning tool. `SonyCameraInfo64.exe /s` learns camera settings, not lens focus steps. + +The learning routine, `calcFocusRanges`, is in the native driver's [SonySettingsMonitor project](https://github.com/dougforpres/SonyCamera/blob/workerThread/SonySettingsMonitor/SonySettingsMonitor.cpp). To use it: + +1. Ask the maintainer at [retrodotkiwi@gmail.com](mailto:retrodotkiwi@gmail.com) for a lens-learning build of `SonySettingsMonitor.exe`. Include your camera model and lens manufacturer/model. An ordinary settings-monitor build does not start lens calibration; ask for one configured for learning. +2. Install the [Sony driver](https://github.com/dougforpres/ASCOMSonyCameraDriver/releases), attach the lens, set focus to **MF**, and connect only the camera you want to use over USB in **PC Remote** mode. Close NINA and Imaging Edge Remote before running the tool. +3. Open Command Prompt in the folder containing the supplied learning build and run: + + ```bat + SonySettingsMonitor.exe + ``` + +4. Follow the [Learning a new Lens guide](https://github.com/dougforpres/ASCOMSonyCameraDriver/wiki/Learning-a-new-Lens). Watch the focus indicator on the camera as you answer the tool's prompts. Send the maintainer the seven step-size/count pairs, along with the lens manufacturer, model, and name, so the lens profile can be added to the driver. + ## Support - Best contact is email via the Homepage link or the ASCOM driver troubleshooting page footer. @@ -39,6 +55,7 @@ https://github.com/dougforpres/NINASonyCameraPlugin/releases - Help links: - My camera is not supported (https://github.com/dougforpres/ASCOMSonyCameraDriver/wiki/Installation#if-you-dont-have-a-supported-camera-model); - Supported cameras (https://github.com/dougforpres/ASCOMSonyCameraDriver/wiki/Supported-Cameras); + - Learning an unsupported lens: see the instructions above and the [lens-learning guide](https://github.com/dougforpres/ASCOMSonyCameraDriver/wiki/Learning-a-new-Lens); - Other known issues (https://github.com/dougforpres/ASCOMSonyCameraDriver/wiki/Troubleshooting) ## Metadata From 84e60471d45a5dd3d12bdd43af17943cea822fd5 Mon Sep 17 00:00:00 2001 From: Lucas Lepski Date: Sat, 3 Oct 2026 16:56:22 +0200 Subject: [PATCH 40/45] docs: explain how to keep captures on SD card --- README.md | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/README.md b/README.md index a98c5bd..fd6f35c 100644 --- a/README.md +++ b/README.md @@ -30,8 +30,19 @@ https://github.com/dougforpres/NINASonyCameraPlugin/releases - Temperature is not reported (the plugin does not request processed ARW metadata). - Binning and sub-sampling are not supported; captures use the full sensor frame. - For exposures <= 30s the driver chooses the nearest built-in shutter speed; longer exposures fall back to Bulb. +- To keep a copy on the camera's memory card as well as sending it to NINA, use the camera's **PC Remote Settings → Still Img. Save Dest. → PC+Camera** setting (see below). - Powering off/unplugging the camera while connected can crash NINA via Windows' MTP stack (PortableDeviceApi.dll access violation); disconnect in NINA first to avoid it. +## Keeping copies on the camera's memory card + +Saving to both NINA and the camera's memory card is controlled by the camera. Configure these settings before connecting the camera in NINA: + +1. Insert a writable memory card and open the camera's **PC Remote Settings**. +2. Set **Still Img. Save Dest.** to **PC+Camera**. **PC Only** sends the image to the computer without retaining a copy on the card. +3. If the camera offers **RAW+J PC Save Img**, choose **RAW** or **RAW & JPEG** so NINA receives the RAW image. + +Menu locations and labels vary by Sony model; consult your camera's manual if these options are elsewhere. For an example, see Sony's [Still Img. Save Dest. help page for the A7 III](https://helpguide.sony.net/ilc/1720/v1/en/contents/TP0001661953.html). The camera's choice controls the copy on the card, while NINA's image-saving settings control the copy on the computer. + ## Support - Best contact is email via the Homepage link or the ASCOM driver troubleshooting page footer. From a795857fbed2ee69333347b88efd2aeeb9228805 Mon Sep 17 00:00:00 2001 From: Lucas Lepski Date: Sat, 3 Oct 2026 17:39:12 +0200 Subject: [PATCH 41/45] test: isolate capture test source files --- Tests/CaptureController.Tests.csproj | 2 ++ 1 file changed, 2 insertions(+) diff --git a/Tests/CaptureController.Tests.csproj b/Tests/CaptureController.Tests.csproj index 3ca46b0..b1c68cb 100644 --- a/Tests/CaptureController.Tests.csproj +++ b/Tests/CaptureController.Tests.csproj @@ -3,8 +3,10 @@ net8.0 true false + false + From 7bbbf1a9c7c3a600c435b1ed40c5765ca13b28e8 Mon Sep 17 00:00:00 2001 From: Lucas Lepski Date: Sat, 3 Oct 2026 17:41:50 +0200 Subject: [PATCH 42/45] fix: report Sony camera temperature from ARW --- .github/workflows/dotnet.yml | 5 +- CHANGELOG.md | 3 + Drivers/CameraDriver.cs | 29 ++-- Drivers/SonyCameraTemperature.cs | 85 +++++++++++ NINASonyCameraPlugin.csproj | 3 +- README.md | 2 +- Tests/Temperature/Fixtures/README.md | 15 ++ Tests/Temperature/Fixtures/a7iii-metadata.bin | Bin 0 -> 42806 bytes .../Temperature/SonyCameraTemperatureTests.cs | 135 ++++++++++++++++++ Tests/Temperature/Temperature.Tests.csproj | 16 +++ 10 files changed, 273 insertions(+), 20 deletions(-) create mode 100644 Drivers/SonyCameraTemperature.cs create mode 100644 Tests/Temperature/Fixtures/README.md create mode 100644 Tests/Temperature/Fixtures/a7iii-metadata.bin create mode 100644 Tests/Temperature/SonyCameraTemperatureTests.cs create mode 100644 Tests/Temperature/Temperature.Tests.csproj diff --git a/.github/workflows/dotnet.yml b/.github/workflows/dotnet.yml index 6410f60..68e1fd2 100644 --- a/.github/workflows/dotnet.yml +++ b/.github/workflows/dotnet.yml @@ -34,6 +34,9 @@ jobs: - name: Restore Packages run: nuget restore NINASonyCameraPlugin.sln + + - name: Test Sony camera temperature metadata + run: dotnet test Tests/Temperature/Temperature.Tests.csproj --configuration Release - name: Build run: msbuild.exe NINASonyCameraPlugin.sln /nologo /nr:false /p:DeployOnBuild=true /p:DeployDefaultTarget=WebPublish /p:WebPublishMethod=FileSystem /p:DeleteExistingFiles=True /p:platform="Any CPU" /p:configuration="Release" /p:PublishUrl="../_build" @@ -43,4 +46,4 @@ jobs: with: name: SonyCamera-DLL path: | - **/bin/Release/**/NINA.RetroKiwi.Plugin.SonyCamera.dll \ No newline at end of file + **/bin/Release/**/NINA.RetroKiwi.Plugin.SonyCamera.dll diff --git a/CHANGELOG.md b/CHANGELOG.md index 870006f..c9ce9a3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,8 @@ # Sony Camera Plugin +## Unreleased +* Report the latest downloaded ARW's Sony camera temperature in NINA and supply the same reading to that image's metadata. Missing or invalid readings remain unavailable; reconnecting clears the cached value. + ## 1.0.0.4 * Added the `UpdateSubSampleArea` implementation and bumped the minimum NINA version so the plugin loads in 3.2. * Restored the ISO/Gain UI by notifying NINA whenever the camera connection updates ISO data and by handling cameras without ISO option lists. diff --git a/Drivers/CameraDriver.cs b/Drivers/CameraDriver.cs index f2d6826..413dd3d 100644 --- a/Drivers/CameraDriver.cs +++ b/Drivers/CameraDriver.cs @@ -40,6 +40,7 @@ public class CameraDriver : BaseINPC, ICamera { private const uint CAPTURE_PROCESSING = 0x8003; private SonyCameraInfo _camera = null; + private double _lastCameraTemperature = double.NaN; private SonyDevice _device = null; private IProfileService _profileService; private readonly IExposureDataFactory _exposureDataFactory; @@ -97,22 +98,9 @@ private void NotifyGainPropertiesChanged() { public bool HasShutter => true; - // Although the driver supports camera temperature, it gets it from the ARW's - // metadata after a photo is taken, because this code doesn't request processed - // ARW, the temp cannot be determined. - public double Temperature { - get => double.NaN; - /*{ - - if (_camera != null) { - PropertyValue value = GetPropertyValue(PROPID_TEMPERATURE); - - return (value.Value) / 10.0; - } else { - return double.NaN; - } - }*/ - } + // Last downloaded ARW's camera reading; unavailable before the first + // photo or when the current image does not contain a valid reading. + public double Temperature => _camera == null ? double.NaN : Volatile.Read(ref _lastCameraTemperature); public short BinX { get => 1; set => throw new NotImplementedException(); } public short BinY { get => 1; set => throw new NotImplementedException(); } @@ -455,6 +443,7 @@ public void StopLiveView() { public Task Connect(CancellationToken token) { return Task.Run(() => { + Volatile.Write(ref _lastCameraTemperature, double.NaN); try { _camera = SonyDriver.GetInstance().OpenCamera(_device.Id); } catch (Exception ex) { @@ -476,6 +465,7 @@ public void Disconnect() { } _camera = null; + Volatile.Write(ref _lastCameraTemperature, double.NaN); NotifyGainPropertiesChanged(); } } @@ -568,9 +558,14 @@ public async Task WaitUntilExposureIsReady(CancellationToken token) { public Task DownloadExposure(CancellationToken token) { return Task.Run(() => { + token.ThrowIfCancellationRequested(); byte[] rawImageData = SonyDriver.GetInstance().GetLastImage(); var metaData = new ImageMetaData(); + double temperature = SonyCameraTemperature.Read(rawImageData); + metaData.Camera.Temperature = temperature; + Volatile.Write(ref _lastCameraTemperature, temperature); + RaisePropertyChanged(nameof(Temperature)); return _exposureDataFactory.CreateRAWExposureData( converter: _profileService.ActiveProfile.CameraSettings.RawConverter, @@ -578,7 +573,7 @@ public Task DownloadExposure(CancellationToken token) { rawType: "arw", bitDepth: this.BitDepth, metaData: metaData); - }); + }, token); } #endregion diff --git a/Drivers/SonyCameraTemperature.cs b/Drivers/SonyCameraTemperature.cs new file mode 100644 index 0000000..717c4e6 --- /dev/null +++ b/Drivers/SonyCameraTemperature.cs @@ -0,0 +1,85 @@ +using System; +using System.Buffers.Binary; + +namespace NINA.RetroKiwi.Plugin.SonyCamera.Drivers { + // Sony's CameraTemperature is distinct from battery/ambient temperature. It is + // recorded in tag 0x9403 after a photo, not exposed as a live MTP property. + internal static class SonyCameraTemperature { + internal static double Read(byte[] rawBytes) { + if (rawBytes == null || rawBytes.Length < 8) return double.NaN; + ReadOnlySpan data = rawBytes; + bool littleEndian = data[0] == 'I' && data[1] == 'I'; + if (!littleEndian && !(data[0] == 'M' && data[1] == 'M')) return double.NaN; + if (Read16(data, 2, littleEndian) != 42) return double.NaN; + + uint firstIfd = Read32(data, 4, littleEndian); + if (!FindEntry(data, firstIfd, data.Length, 0x010F, littleEndian, out int make) || + Read16(data, make + 2, littleEndian) != 2 || + Read32(data, make + 4, littleEndian) != 5) return double.NaN; + uint makeOffset = Read32(data, make + 8, littleEndian); + if (!InRange(makeOffset, 5, data.Length) || + !data.Slice((int)makeOffset, 5).SequenceEqual("SONY\0"u8)) return double.NaN; + + if (!FindEntry(data, firstIfd, data.Length, 0x8769, littleEndian, out int exif) || + Read16(data, exif + 2, littleEndian) != 4 || + Read32(data, exif + 4, littleEndian) != 1) return double.NaN; + uint exifIfd = Read32(data, exif + 8, littleEndian); + if (!FindEntry(data, exifIfd, data.Length, 0x927C, littleEndian, out int maker) || + Read16(data, maker + 2, littleEndian) != 7) return double.NaN; + uint makerLength = Read32(data, maker + 4, littleEndian); + uint makerOffset = Read32(data, maker + 8, littleEndian); + if (makerLength < 6 || !InRange(makerOffset, makerLength, data.Length)) return double.NaN; + int makerEnd = (int)((long)makerOffset + makerLength); + + // ARW maker notes may be a bare IFD (A7 III) or have a 12-byte Sony + // header. In both layouts, value offsets are relative to the TIFF. + uint makerIfd = makerOffset; + if (makerLength >= 12 && data.Slice((int)makerOffset, 12).SequenceEqual("SONY DSC \0\0\0"u8)) { + makerIfd += 12; + } + if (!FindEntry(data, makerIfd, makerEnd, 0x9403, littleEndian, out int temperature) || + Read16(data, temperature + 2, littleEndian) != 7) return double.NaN; + uint length = Read32(data, temperature + 4, littleEndian); + uint offset = Read32(data, temperature + 8, littleEndian); + if (length < 6 || offset < makerOffset || !InRange(offset, length, makerEnd)) return double.NaN; + + // Sony enciphers bytes 0..248 as (value^3) mod 249; 249..255 are + // unchanged. The validity flag and signed Celsius byte follow the + // documented ExifTool Tag9403 layout (bytes 4 and 5). + byte valid = Decipher(data[(int)offset + 4]); + if (valid == 0 || valid >= 100) return double.NaN; + return unchecked((sbyte)Decipher(data[(int)offset + 5])); + } + + private static bool FindEntry(ReadOnlySpan data, uint offset, int end, ushort tag, + bool littleEndian, out int entry) { + entry = 0; + if (!InRange(offset, 2, end)) return false; + int count = Read16(data, (int)offset, littleEndian); + if (!InRange(offset, 2L + 12L * count + 4, end)) return false; + for (int i = 0; i < count; i++) { + int current = (int)offset + 2 + 12 * i; + if (Read16(data, current, littleEndian) == tag) { + entry = current; + return true; + } + } + return false; + } + + private static bool InRange(uint offset, long length, int end) => offset <= end && length <= end - (long)offset; + private static ushort Read16(ReadOnlySpan data, int offset, bool littleEndian) => littleEndian + ? BinaryPrimitives.ReadUInt16LittleEndian(data.Slice(offset, 2)) + : BinaryPrimitives.ReadUInt16BigEndian(data.Slice(offset, 2)); + private static uint Read32(ReadOnlySpan data, int offset, bool littleEndian) => littleEndian + ? BinaryPrimitives.ReadUInt32LittleEndian(data.Slice(offset, 4)) + : BinaryPrimitives.ReadUInt32BigEndian(data.Slice(offset, 4)); + private static byte Decipher(byte encoded) { + if (encoded >= 249) return encoded; + for (int value = 0; value < 249; value++) { + if (value * value * value % 249 == encoded) return (byte)value; + } + return encoded; + } + } +} diff --git a/NINASonyCameraPlugin.csproj b/NINASonyCameraPlugin.csproj index 949eaca..608d7c4 100644 --- a/NINASonyCameraPlugin.csproj +++ b/NINASonyCameraPlugin.csproj @@ -40,6 +40,7 @@ + @@ -68,7 +69,7 @@ - + diff --git a/README.md b/README.md index a98c5bd..946dd9d 100644 --- a/README.md +++ b/README.md @@ -27,7 +27,7 @@ https://github.com/dougforpres/NINASonyCameraPlugin/releases ## Usage notes - ISO/gain list may be empty until the camera has learned ISO options; set an ISO on-camera once if needed. -- Temperature is not reported (the plugin does not request processed ARW metadata). +- Temperature shows the **camera temperature from the most recently downloaded ARW**, when Sony records a valid `CameraTemperature` maker note. It updates after each photo and is unavailable before the first photo, after reconnecting, or when the latest image lacks that reading. It is not a live sensor probe, a battery/ambient reading, or a cooling control. The same per-image reading is supplied to NINA's image metadata; the original ARW remains unchanged. - Binning and sub-sampling are not supported; captures use the full sensor frame. - For exposures <= 30s the driver chooses the nearest built-in shutter speed; longer exposures fall back to Bulb. - Powering off/unplugging the camera while connected can crash NINA via Windows' MTP stack (PortableDeviceApi.dll access violation); disconnect in NINA first to avoid it. diff --git a/Tests/Temperature/Fixtures/README.md b/Tests/Temperature/Fixtures/README.md new file mode 100644 index 0000000..86a76c0 --- /dev/null +++ b/Tests/Temperature/Fixtures/README.md @@ -0,0 +1,15 @@ +# A7 III temperature fixture + +`a7iii-metadata.bin` is the first 42,806 bytes of the CC0 Sony ILCE-7M3 +compressed 14-bit sample from [raw.pixls.us](https://raw.pixls.us/getfile.php/2414/nice/sample.ARW). +It preserves the original TIFF header, Exif IFD, and complete Sony maker note; +image pixels and the embedded preview are omitted. It is not a decodable ARW. + +Original ARW SHA-256: +`250784580ea527442c09004417bb0eead484f2bf3ee8f9121a776ac65bb50d0f`. + +ExifTool reports `CameraTemperature = 28 C`, `BatteryTemperature = 33.9 C`, +and `AmbientTemperature = 22 C`. The test deliberately uses the camera reading. +Sony's Tag9403 validity flag (byte 4), signed Celsius value (byte 5), and byte +enciphering are documented in [ExifTool's Sony source](https://github.com/exiftool/exiftool/blob/master/lib/Image/ExifTool/Sony.pm). +No ExifTool executable or native decoder is needed by the plugin or tests. diff --git a/Tests/Temperature/Fixtures/a7iii-metadata.bin b/Tests/Temperature/Fixtures/a7iii-metadata.bin new file mode 100644 index 0000000000000000000000000000000000000000..645ec0eaa4666e408259d74cb0e665aa68dda615 GIT binary patch literal 42806 zcmeEv3tUvi+y4x*aTM9SRG8akSC1_zbP1g{OJ)#1~OUbgbc}aKK)(a^W;%(4~ zEG*Qdk}NZmyj3V-YQ|YzxVyT@B43-v*+14 zGtWHp%rno-dFGiRKK>3O5JGMumq{?8fcVvdw(#Ar1FWV&M2q`PcyK*n72vx8w*lM| zun};(Kze|rBUSnG4#E#j03HfBB9JBsX?h{=^|+5jd=%gt08aqi59#^y#{u^T%*)RO zY^HwN)PsTu4f4Y)hX;|M0Q_QJThca2MOq;JR-|tqq|1-eqYzFNI!MlguY1w2L)2&*L__dnLgdsw?~f|GiG$35#9aKsS|tjy62vIdi0Fy z5f#-9DY{L|bWTs3+08kvGcagi+Pf={;tqxq5)RJvb_@5ybaRN}E2(Ik8_)V~=LPP)-kyxLHA<-WLV(Ie4Y! z9MOM$_`8t5#N)r&^*6iz5(oZL;=iTqZ+8794*aFWe@oZj?D|U__)CfZmaf0q^_MvC zmlFRiU4OIdFLB^6CH`Bw{w?fkb@gR`yc2KuGw>=ueCj0U^!NnQGn!i-TELzHLjmrc z@J%^LJ`aaMq$?pR7=HNuOPF-{y=!a#eM$%aeI_gmJpM!E;rFz_@0)J*|GtAPpX;~v z^o0TE!@C{5art0eCK8n(xO$VgCur_tTb(_!f&>`M9tcaBu)#4p_C=pJpZC zki~wQxqV9rzyL|h01OinX%$HS7ScbtIE2hxOvrY?Ie<-ge)np86$R?jt7lJIJ)n*}_*Uk@Vf0l&g&Tjj^UiNgrI{3u)zxLR@e8aA$L z#YOh))t5`=Z|~d+$#Gt%4sH+ ziyzhu9v*b8V~SA2uj`E&>(;C4*xa|@czlK--h`3ROq*BjYCD(^O5+A(BJ0hLI%e6iS8L4 z9n~``DykOPe~bDH)&laPG&TM-AsH3xT&@ z5Y{2@itzmmd{HtxsF-qX@}CVH5%2Y`@S^LtgziJP^sk}uyKh?H(}7L$qo@8kGDgcM zpjG^du4qXv0kk)neHu5wrjQbzV5P8<4^h6rsf^?h^%Fgg94I-_jC8dR&h>p~`Mx#2 zZ+~h(Onfo^Z-06}>`&u|PdCfukMU#k!@h@JZ%MEZ@Z$^cypl zSDJmQ(}_F3JUfpZw}=-+R<@im#pFz~Se-FUKA5BwidL)L{)&Mq%);c@(ooULbnLL3 zRWni1spLEs8kT33V%XQ724*c}PD$J?Gv@Ks3}r>kX0wU0%x)A_l3tb+=3pXItZB?> zk@Y3=9fl{E%$670byjO~g42>Mr6()0%pw`nOUjXI3zK@V&|#AKs3=;JMMJ2?bO)=p zi=t7|StK!Ds##qzQT^Dw7+_6cqm%8QGbZZgUPi0pP!bf7Y_#oTItO!=T<35Fd6kieIt{b3gbb4*FZ zfqNtBDtCljg?`K$T-%i9Pu*w#j)ayY|5V0iiDG-k`ju(3xT_$48hDPI>*~*}0#b$U zlxp^xeN(|e7D~pS#XQPJ$k|1Nd{a!w^67-k$31SZAND7_!i%d#{SEy!ra)bRWnM45 zCjNEsKhOM*4l;2f(fquJkZLlAx=iG*Jr-3B5Y}JtUt2kH)Xf_~9Qe@bsv~6gh20lc zc@^)S_yo!pNlXCx*{9kHxc9^a(w+1oZAn+!j=Yye$VB?x<+bEFZ}WKM@|NSdS?EWp z2ERt=2-&!pl6H8tOn!ini(e2DE)$YIn2=IjtH+RMoB%qOo*-mBx{vl4rmIL3 znThFX1E#vj`vTx)V^A;TAAqhUgXW9@gnWm*Z59(!A`-IW9VnXCLk4#qJfyX%C_ink zd2U*MQD&N%hqTPWy?j8g!B=9gq`8vIO#V15Sb`ZBuBNb-4990CE^`RI35|UOwDSb~ zZpEF)osA~s;4#z{eE9$a{(RgyTxVO=GfTC3BwoVFoj`Fa+Oudy#!nf;Gv;OF zW?ZZoiSO?!hAi5fq0OkxXw1-L++A_B;`NGKD$Z0aT%^v3t*Fc}W;~YBs^X4{t`$)g zZ&loxF*hSIqgO^$#?Xv+DoPOBt0KIj^&)*nT*eGw9GcOu!cvi#(J>>v;yiM`j`(LY z+ApFRiz_lRsuxu+QZjDOSd_6nL(V9z*k18%eq6=F8Dka=Ma@@|p%*;(V933YeIb?B z*_uA4CJr{g8~r~@61;J|uOKvkiYOlHY$q<9ur0CiYl#hW2{MwPpCItL&Oda54e~wtf!so-WxSK|WyT3qg`+hI|vv>@7dX&{XCup@D;PfQqPOgAH@77*`31bWDy zp5OmWFlkJhETSc@x}Ht18Rbya4MkTobBbmg)#NKR0D-nrw*tU>AJHfn1OF(P$i>`0 zdiF>0m_PdFuD<8I+fmds6$JRIyJIX2MVojuNezMu&NBI;eTjefbqhu#)C)E9*G-^4 zVg-($k|1)NmK-1l$f2|5kR4Yt^J!dfG8H3Hj&DlW2sOb6@?X83&OqB7pycI8Fv2b) zT(lMer8evAZz$gS`?3g6~==TEV5!2A3??Peis6~1=Mwv?BLj#pL@eHG+ zIhzpoVc-YNYY`vbnvg?C^E_~_1RWYE!5tW-2Vg=veF;V)4M_ffAv*CMYSV`L?H(fh5}M2^vLq*zIwCi&!M?*MX$oTe=?{fi}~B!s--jU#>O zm08M_dCQfV%%7Rt{EOL1An*Z|UrpgH43@W$2XXmy4oSrwm%oFdx0unE|I8IpwsRM&onuz3t2WpYg(Yp{DqZIR7X>|81qTzq?)9pni}X%GRYTIO%E|JezdIl=eKjsD+#Xvd@g!* z2*$7Brvl<5mt)kp&#=3SR*_(XK(t<7wua>Ly!14QEw zw|x1iHLdy2SNT`-P;Dkqc&~cBuX-~HF3Yi|@Z-T3GY`W4Y zf2F*?)BfM*KnaPZLX}Vj3E{em3(gL%D!4!qp^6BCI+Y3|@gtzh($gQoJfY>J-1;gc z-OiI?Ezf_8gqy=mMDqYFT6_q^^&G8(5@21R!5&R1I@ZlakG}+jhl$shB z8IKajDHhn}6vsE(BOr7GG>179H zR-G<-M5CyGrqnGTiK$SYo*%B@Ez5;cFgVrqE%xF!~DkuE%V^LdIg$?nSzgk1!<_X&&uOd-yVet?a8X zB)<~=3@lt}T}<+>*^gmAN8Z zm97w1t!u2Sv+IH@$u-K=!L`Blsp~e^1lQxPn_Zu{VqF(qB_;pc^ZV@~zFCt`tu=4) zre`;g^na98|7UwsfE+geG|&6b;HqZw7D%h2bTIET=so`K3_ZkcBA}}PuUzF+6uN5q zo4QtsYB4yb{roD0ZXT=z97A{g$>CoWfvXEgesPg5`c4xeu}vg3NDvZ}Q&fz0q6jHDOKcA3wZ@ zYJ49PF_EmMq5$()?hWJr_#b%F`R6h|R}uz*Hqajt;4bkVs!AkJYw}4aVHoD`!5B|0 zguif%#mnd%qEe}#9>A&J)YktKFZcm`{yZ?mV%TV*D@!zHI`butT`sZCd)h3V;b1%t|W z#}Fz^A?XIahJu$aI+*_CyZp59?}AE8N{HzDc2oXJ9F0IoA_7d#2v?IfK?-AcF-6ae zc4o34cH0s>%w1@eWJNLLSk7F>;v>T((Z^M;ld4kvRmZo0)}0gh21m?j^75BnqEyZK zPHr9SZ*+Zd&SnkNo6)7So1ulz=k8UtgQed5;RQL~}HdU&tB*@_+Y@ z_g^#>N-@32--Z1!Q95~-ksrK0NY;Uohf4&^Kp} zAjA+soNeL{=ad(iFVWdBw8Uy$koRllQAEighnng>7T5`vOZJL3e~a=;vml#lO?-i5hVuC07I=CgJVJD#y<7FJ}5 zQ5!2TD+-YmS-fN|v+11en7O8n=2n5U97U4Ibj=0Li?m$WH*~tjt+@*tr+ih*YXl~S z>A)e2-YVxzGNu_lqGd^qEQxkzkQ+GVa%8o>R=u-D09fn zGf!qZ3mhhNmNuQiWUkSCI*refn_oV4o2UsVTGG;c)HDrCM`_+RritVjR%?sNX73l| z5$`Ip5^_6W{X1Rz)75kWeKVkjesmqI{c+O4vV|($cdmko8vY3=D3Xei>gk2O>-kkt z|FM=|vMKVrujy--%UxnFEW3^56coQdT)F?Amg7VEuQ`H~3fgSXANf)JtqbcL!{@!5 z`OTQO8dsg|vALJ6dt>H@W5t{(jwpZf_oujI^bNVVURv;B%*u}Gy>yu`?c;GT7Q5vPQt+%WZA^ma^uNNn!j2Cnfd#8BYonEey` z=W4YBo9O0xZC*Xkoz1^^@DC$C&_%LTRT`37MsD!T9Kg%=Mf<;ejG|pBJqw68LsQ_1 z;zZ?nex1d&OWf(Z`0pmTC9)v!;gqB0i23CoTAC2y-pE}zc{nt;QTw4EJlyezX?q)A zXmd5jAJ4znlo9e^9#l4r#p_I0T|0+IO za@CH{d9lsLI|Ek}R?Y_w^ZNVanxMXB>iCG_;rkkD>baX9!o!(9zw3Bc|bC+<2&JbMi>^MZ7 zv=_C~H0l*@#gfBuI*U%Dd*~2)2Gd;|JqlcLG@hQJRYE4srQec?INfBH@ErY=zD(`3 z4qc-U_S@V`hS2YY^+F3;N(a!#k-tBgM!V5M(iVn`esmpuUD%~LNB5Jj={}9DIVoJG zZ_#Rc2Th{4k}t?7Bt#gm=~;CHYB9xhy=g^Oop1_^Ahp6W8f_Yuwam1}bWk`#AHn-& zTUtP4g;Y~3;TL*bI3S#-UxNN~^lNnSU`=<_@-ccFJw+$cOnL+}t2p|JFo-78>*#m1 zUf7EGPNW|xqB-EgN%{c8*e`JKZ+vOrx}|o<-mRIvlQ(&WCR4F^%!Xqh zJpWS@Ga>Yv%AVk2TI)Gfcdc)S~#Fr~~3& zsf))m<}1(J8^;8_#bxnrOqcKu|CeZWhZK?5%}wx74zQ@l;C9lULn_{EwC z&(7zcXdEhsHI5%+uHENk9Y2y)9m}@e9u&4F%yVJQreiZkhSByb0}hKzuG~f32>4L~U(M7f*Q1 zSg}(~XV2#3p2v3YdnogVm~EL6F%_B4_KyS&jLF$NOgbh0w71T4!`_?4r;|2%K9jEZ zG>pk8UL0i#dLgbp$a2;cv^dU~nYDLvX8!a8;?(K))h^p>%8+gIU9sWj=~!;cJ9K?{@O!MXdP~yu&rUA73L~AgZ{kk2)tfCit|v zM^&gPDEhYOU!p%OZ&_Y^V#^6z)dcma;B%*=Q@3Twrkx`f*DtD{T>sR_bt9#bcGJiz zbt*npn@rzmj+#bPtxbKVYGhW9=99qtynoX4_xE4n02cgw!4z#Cgn`Is)ez7(e!A@S zK1UYOrjNhnX_gafmjBSU4q288*+INy8O5DUk2gk>Y+!vEJI`b(Lt>silHFml-Ou2- zuG2=cJc%2bjaGFebGWm`Vo6@W(k$9=5wELaPMtQq)FPMWv0hgDe4E0K7kQY;x+TR? z$ZS{_X4YhfTXY!Vi#kdU$Dxm?!BRPKP9>*bVNJDS1qh zY_^bjStU9}WMZR!3m+gvVOp_#?uGEk&DQ<;DuGiY` z4|ZP!@>pXLQ%ZxGA<4!(Otd5@itGRbz!%wGj|A8)(3jO1jVZI-R-+|>$+H|sbfU40 zv8AHB3?;CNXQX*xrHWy*fNI}dLrf_vaY(XbP))c}7+xB}Y@v20>zK}|3^lM2RwS~d zG8Sx=z|jZ`5THd=uoeL5z~uFIG^o|iP)S9K(JQD*c%{{%xE!pyQqB{R%+X3GNs<<} z0$8?~yAp<^GbR|o<9(Vb;cioTysVe) zqGHn<9s2i~B%xW<%qB8B+r#9MW@C0Nb|chST#8Xq%EWRpK|Yesv@&Xm9Ts>E&(n(r z>|)R|>HH>TDvKAHCEVYD2F7%S*uJ0#{mj&ByV&sPvMy1h4lj zBobA28{MdH_1!4k4v9NFA~H>96qN<(GBcCAsWB|QuY;G2Q_Qp7N3 zja4qO*sWn$*I_X%I8St2!u5O?2P{o7bMagT|Lk&lp%|u!vN~Dk(97s&N(vVd^Q|_SBsK96!nk$)W|d{l%UvkqBTm4 zkZl$@n3>m^7|XL(D~ghw2ulVSkzy%v%LX%2GJ{RZlT{+CmdddY#0is!+r4K#^T0+T z$_lt0nQ|W7u&_Pofz{07Mvu=!Q;Vts4>Q=)m}p1Kp&Oh^7DXEjHb%Qa#B(XEYp`m66GT%JdstAR<)Y zDv}KRyLa{RrQ!G|jhOF)0g}y&pS^;AWdq;<{_vB2*n~7yJM>rZ*92gW{|*iV{}|wZ zR`uo;{I6d0!$?AuSL{L){hoDz1NigH=KEk1(!A*H4mg#2Q;`Pph%cVg*=KKm9}JMV zG83-g`M4Nx0ME%+{ICgWD(H{Cc+Q7g0x+lZO%8+3fuQrdW&SiA|7V}Pd!6u}sxb%b%=JeMDV2*zkhk+mdS)@<5LB2E`KNgSKOz%vuZ?efJoPkdi zrY}{3=J>^!zR~GteN!1<>i_7geL1GT3^X`9rc32qhcamcm zJ>+qXaSw3`WFy3W;lrt9Gu8>Ftcbpnuc<_hCWsVbR`q5+rF%j9cw9~Q!cM8=NMkB# zx&v_#$1VY9fgzQAh_v>A(MPRToA@&x{x>*B{Y|64t^U<-t`C0I^{e7C$*)t8jc+g? z*O=g#O*H}OxOOQvCe6S0li$Zu(bsCmnj6}`o@#q=@n!vCp| zjlTY$%J^T9>GEf-@uthXACBnq09>lc6z}|pUR(+%cJ%5g&Z!S$Ll^1iee{Ewcj4p& ztm{6!VcZyO{v!8#3)aoY)5j5Ksk`qBxUeJd+4paF8v2X$_HLUd^u^g2fSwq%Z32M< z^=sZP8-BeT8?DGM-h<8uY6!UtaVIMp2NC$VT=Y&{^zlYGi(?tG@30=Dp&78Yc~_Eq zDz;%^RlMuDg*RhlCAWIR&&}AtciYYOelzByfV^s~_D0_jkBu ze|mh&eQigaB8P*H*fC>k0L*3pa-+Ghd zG#&UE$rrufCFS3a6$MgD9v@1F5%LV6A$t=5g~365ZF}<*a5#s5!TM?6z-<04u*46_ zgai8zfcB?dZ}Vw0v)JKwnUFIi%)4!)06Rk=C|y71-EM^F(54mAq7QNY3~Ct{vlm?B zPUI`)L}=q9Ff^PSR@5G28rtLau?Lbc5`jZAGUtPTKf@utji-7IwkE(Dac9QKH1s9- z6cj8uGz}XGk+J;4jKM=Y6LNsI zCPNmqTYxoRI)@y3URjTM4b376llk_A#q?3{cjd>y@zzvJo{AnX!EK-JB158;Xv_!T z7xG5MNNgW)fR?nOFM;<(^fm9sUD+owQcwrEZ^_(Kgk(~zVzqCdj2?`Wy~~nE?Ze!L zZYLL_Mxy4$RPkEQCV?BrXqdO_=u@)@8B2-z&25id!1hF1=zU-M2>ct2r(2R+Z<~>| zm46I?50(WKF-Mh5?u@%@P zLZ9`9$8<&eIzjWYCtgK=dmbF>JNLC8an=uw^?olOdj#hp(I>r=*F3#qlWHTKgweU* z$pN`(w1KqAzbow})c_hn1|{9I409Gxa7)m0khL|)5hvaRi{L=i_qokm!KupX z7o$2A$>z8_(HCmKd&xEmZS*zme?-XZX!G0K40{8XLwcvTTi^Luj+;w+d)GYu zKKk?q`ib}GZLBvT@8fCJ^wo%4PgBY6VWnu*JW5Gf@rj49egtT|$9^NWUx0$u(dGnf zwWTIfesrMr-p9rn@Hy8x50sk0$sHNDL7t6t38~+@C=$N!bTRp5d?Iqp z;59GuqHo;?4h46#1Rr@g0WW!RLZ+d&LA*vJDZ-j+nAG{y>55CORD@N8q|Ir|_f9@bqwI39? zcC4BQ{YOp2J8IuF=s%pC6#C(K#B~QP-tn8kt4GiSx^3;;o}Y5Roy;A2v<)`wfab9= zI+XSq@-EGO*lUK410@swoAQey~7aOg16!ObYL$3*kuD36OP3i&2E!i*(wttxzKca)0~S zT(r?aP}MH~l7ty2T6%lO(U9!#z|-8lV{d@|gGNe!!rKM<572`<#w~~bqaS&_!QZ!auJ z3o6neovTKape6dVU0%mrUsz01vRgn-O~PgGmdK5e&MbkLm!J4}Fy5MkwO)DHCMP!0 z2w~n&quW3_`v^O|U!?H=0dOlYzm@ro4y`W)lRLx|jIcKezj)gQWxj~k7cxkc=XyO_ zA09K`guMM8S|4TTPrs<}dGSx)@xjOMMC&8RnA6#K+Fh6gSE{a;K|T8BBLcb>YD7N1 zZ_@Wcyhh~XUXB3~h>Rj14=foJ5e2t+@^SY`hXF+aT6AYbP!!n+sA|hc$k7UrBPV|x ze3$|C8Mo_gY>5VRxp5SpBGbpS8}|YViW+#iXx!*quqQXFON*NtKSO=n;^~UUpWewK z(ST}lu3wo$UXI$@qN^|sy!rugE@9){IpiKdOTG@4jB*V<(yyQbI8o7 zr1IQxPwWI=qJEk*B%N?f}NbShdPNVG&GsqGVOFVG4QzPC{%hjA-1= zhe=M!2-A;?Sz5iqq{_kx?&`=$QIf)p%xYtbgZ(7M7>piRT$C6)D}lK(Tlsl}@q1Bu z$PIguWR;h*1gDxwdf2*_W-}A4GqDB{MbF1(=rCEkE5XUwmZdhBk488=R!fX%Xrm;v z1W}d@Mw6CRs})8?*xPnWimfOnR<4d?#WA+^3{ z?1Q*13r7|S=MvQt4-GHK26Ar?CteO(k!i^JTB<7AQGqOrhYjUJn<#}?<7~`o5x7^7t znk575cg*B44rH-#!_uqVk__{k$E_%)LyFFkfJ0`$ZkZj-!(suG#|3YZWO#~IDkV}O zyv~@Ut5!y!8712(3#%Kg66KaKVzQI6T`l+0smH~%V(cr^5zBnbk9OvaH7izkO(#c8 zU)c354g(C!29^YF!$vBzEo^0q+OdL3SVRzGP3f>VzybZ*ILcI!$@PJs;3qI2XZiXGYQQk*_?Gzi|rt=l-y zCP6XS4%yU(`EsF*B@59gTa1_F5_rd9m4P{|vSe-p-h_r&Q$#sI(zElTw1!)pjj+b* zMHYT0r4x$}5y2qGEEb046;`rmT^+Y$qBN$fV8Kd~X)eS_lEP&jcFVyLbh(4q$Ro4) ze0>1!Vj-NZ4u+0}c!%V0=tV)XqqPj-dIyBi%|zOY1q|v~4yjSuJt1lOu)-QA zlLVMsOU&iDd1j;iYw^EHELnNXWjF4Euj12(;idA-7%DyLARr zp3W4b2To;=d7e8wGQCh@mNUq(^fNaaQbgA;f3+T3ko#8QWIp;P*@}0MQ}b3~#)6q` z`QdUA`{|y?8r9fx9+RTmz4F};3~ykN0UvnGza#K>@`bPzaT^d1MX=iZq%{>)@t>pzIaDrS&mn)C|83fegB7W*k@k$ukGFx;z)HFqxqhE^ zKg&-wA#LtkY9HR#WOVsu&|tbo+KSh=rq&|u4LAB}Xdv5QRojep-D}{j7@Qh|w8z?K z`S5NhZD1kWOfFrEUQJ}G8)-`ne!P=lX;l9a-d4v_z25fa-AhjU@Ya)2VMn4U?WL|kL@Gd~wv{QY+->Yf&M{Q1BjI@?9exB8%Zk%3@ck6G` zRwM?cx{x+o@uv+2y(*-g(?ml+wm1#@V?|7U_m+R$&R@4&l2*P4X{)Z0w&E>M>QAWK zuOIm9=E9p1=kJ+o@NEBv+SCHjz_@!Ef|f|-8l-J^jkFcfjj3mVxBv6OfwX+>bu$@v zjkNo#I;Ktn-lr1$c*|gwEd_tiUrBqFXRw$0oeKZx?knxT?kmrs4OMKCY{{`;w17^u zYVVfAWftsY?)XK5A|I3!F^JpbaiV4ZhgP>fHYHxPdlGbZyB&%WdiFXwuVx>Xt^m5N z2Dtbd?BR+zfLKU7a#ZuK+l~#$gW1$aAf>PwAY9rLB%syop>@ln< zj#EmF#l}?d`E}b=j*<$6?Sf9VipBGwd&RK)B&*vf+Dt0xPRu;Zn?_0Q@PsSDM=W;JGQHD#NOSv|5$SsOJSQ!CZp)Dh}_njWT=8Y6tk4$~A| zi{Qt$lkBBm!QHb5yv)|p?esgm%bh3NgpGo}>L{@8&pM(hH+8A%lG?WFK-E*4jY6m} zRj9_Mo!)eX=~vSZ!KGO$BnXeg|L$Sfk9xz(5kwcmUu-kB?>|KE5Vi_^>CLcXbwGJH z2&059ro&Z(s(PfpXBwVWmsL~sSXMt_op8HwQZriUCn&-z*hyW9odB71D}5gxX^+q% zx*L|R=YV@59Z5UWGJ1*JNs9#y9Ssk(u5`8LS)o7ADNGSAYSbn})zekCXFZ^KSGY^_ zm1exDPBTHXS$K@Pgd%!?4y9X#24NN*1s>dwEdZnF$JlR>OYtAO=oHwM){?v7I((e0 z)jX@|Eo>1IHB&X4Ov_CBG@~``Oh1_BXsQL3<}D#gGuX69cnS2)q)U+VP3#1d>13*c zuie|Qcg2FU9dY!)U>XW4SAqixur0+2Nv5aZZ+DyUndW&@ds7>YMpFS_#Z9yiJfGhe zW(&^?m706$M>L(jO-BlP_$zJ&_OEb);#062-3u(+X(XLW7SNYqE&ByFr`}|wFiUup zs_8AlbHZ3n0z6TM3JZiTXww078@wHBg#DWC!fNcR`G`vJlWZ-#Mjt|(FF>2$3G3TO zu$Ya6M_@L#V1&?}uxR~AUL;lYURcdi!R;Nw)52O|1AG|A3d7+)IRkk2fs-=5p3b8W zpw?Rd3*aL465&U(zM0^FC>I2}&D(q!CiMTgm`+JQ{K`I?t2%pYUItyJXw-MKl?1ogmOlFgG@ZbY-1C9yVK|TXL z&jN=5^k)cW;cKA-bwkD@eEsG;dj3fI3u-w8ezJ4mDcp`cAlyQq!@l6_==Es(3FK4I z`4!}=6m9qbTJIcca2!}aK|RC4iATr_R1ls-?$c=VROrsn@&6HXX-`@U|Jh-5I3%qL zB=BQRJG9|^jYD`5cm+t}TfnpqWq%5;g#vyRHFyO)d=PaF$AKmBG>pCkf8Gxvd9Bg^ z`jAG*TW@f83=T|*13!TTj8#{3dv( z0PiN`{|2)55a@da^sNB(yCJ;;$@}=+hBog9d$S3x(ujS4^WhNIjpks4Ifp*G2OKT~ zZ*$;-d^03X0}b>8y$jE_(*(5qZs3h4uY=atfpHyrM?C1C1DW~4C)qbcW3H$Bpl2V2 z7xb;zanc2PA`T~`?*Tt|!pHkT`X22D9fmWJa74{`XtmzxbMJ!6Zou>f@J>KailA-r zs}B4b1m4_){#XaAa0cjg39+;veSzLb4KyEG_6JyeZv*v9AY=D|Tm2wW8tAi2==mYw z(iYTtAime3-g?MXM^L%}RBl4s4TqFXB#CsW&{e1xTB6p+f$Mf?up`hU^|TbdtUs{c zP40lKw}b?yV$3-}MaWoR@SrVHy@S2VufSgX6~>tN=_rk!4nSLeg+5gUJ|tr-u!6rG zpx2WiSt}sDF9GLW;M^@pGY%3`Ml;!e6it6&8^*(M;d)^D7^CuaXt!SEc3>SymZKjqj1|Y}{lcBXc8yJwq1hzNhgH@C zuYD~>v{C3AAL2xpOUUU#8Iv&j2BViv5?0bM@NqJYCwtM)x}$gHK@vt_^VJIIuhq~$ znMl13Bg8JveZsxMV$E7IM93HJ#Q4=7a#RgIET%f>^@~^^`T_s@bP~p-qiC&nFspeP zy98r!y#k##9MWV4cOUchxgF4bli{_W3hq`2yM;G|Eo73g8FH2nT4T`T+CUn(UgD$0 z_0XK3K|U$QqnpvnJ|q9D0%?Gb*ZB*kIto zrpU&ll0p5t4DT@bahAjwbSObY@!V=5fAM&=afBfy7N!xq?0!e>m zTT)`>k`5N>67m!)Axe#1l4XUTA}~S>79FBE&&f)eCq+!c99&T>a8oQcu}!kH%Oy(j zR&l;jG=^Y{sL5ch83Mmaskqt*W20S<;&d)$v^-jZ@y;Y^r93ST5l(?6a;_G}8W^Hr zzz`iWw#>nZ04oe^Gs#ksXtyQ96eGc-?I@dt$$Sj6JK-bR(jM{*!(87cOUx>1l>}L~ zqbA&r27X|}WLS8a-BD=g+yU|*^)NV`fuRh?Bj>fM*2`|`pk9g<(G-dqnr+ zp}~$@;m9i0Il?8ol()weHu^A=Z7`$4-SZh%!d(Po-Fg_T(iN!$BxCQQO#+eCEZm#| zBxx~{$X3i$>kF&JNW9!xO(;Q}tCx?6MQjhul_1e1!!g*kPjQb1fs)l=pDT(f><(Cl zSfnWhqA>#aS@_)iugo%keS;m)Ce9ZnMQ_tO(hF~s(%`Y^X6|aU-3r?(Gditq7))VK zG9;)>p;owCCcsxzrC8POu%XsV@V$gNODt4bm0$-~YJAjnot~ zbtra&#>lrmgVuv3SAu!k7H&N(*gOx=XJGnIw`#<3xEOJZZR|fYfr{Qo+ z4Xn(2;J#}^#h5JyE}%A9G`M}WXKvZ<2xd~XsTl6gUs($k<&q<^LJ2Lw=3TwsSmB6; z)Y>Gy0?VNykGm1MARpXeRd0#yRcsLr>phTvMXq3?EeU?4PrCIUWxk?vscdeetuk`G zl8%OnEoI?~h@NS{MsX1$r6}QMQ5(j}eOrzn-ei;P3)p>u9D5(AK?V^(wP)1*^PQdSHtX%bpJs50m z!%`HZk@I7z9DfQoIazcgr4uf^GSllr>|!-C#4$JQ%zO<38YTxq4TKnR6fC5Myb>sDl&{V)p7_ljl;O7u*_PXBSvClv#2*H$-#EV zK}Zw&j}Dv4nKq_UHW%t_C6c?P!b~Zm(Szli1eo{DOj-i3-4c|R0QcZL2CX?L9QuP< zs$~hyuBw()I`Je%$TjhOhFEixljzCqcY#269n?+G)gkIz(bY&^_ z%5%Gowp1)+?65He4(HI|OG_oSoTu0=VK%qHk;D}AR+IzHp_5{b>shd*IJ8jDaw$CO z-5_6rhd+$ir`SHpv5%FcSXqK(6I~zzeNt3HfmMb47^4`QC_-=8Ryab#<^bIpF;Zb) zB|DN)9oZ>HI$xLBDSKXWuqfZnjJw^)p~Vscw4<9@9QqoJ6qYuS0{IAJ*64<6(1)8X zawsdb+C^EX#zGC+70q3}1I;2@%+NAQl3Y@Q1Wflup$c3xn6ctEl=X6E&vPqz#)?X; zk6=u}8VtJx9h76Pa4R}(E6KVvygr#_J0W6<#S*E8SGmrphjM3yyl=!fWd*HZ4`=2) zrvH#x>XaHRQR&s91HDHvo#!2`Xl}X@J+H=XRWb44C8ZcM2Az_F`jn!N?lQ9KQ;yK3 zELPTHNm2>b&4mU=8y=ZoaofD5AQo#_AQpnhpyh(G#1m%lIH7fzR$QVK6-f>$kzwUd zV$ik*(IDx;FfmV7M5qrXLXL#Ip2x_D{3^v#X-_LKr5V3Y-ojK#2i=PP=M>o`Wxjj4 zbkcKBjFeR#g?DtvqmV$cIsqG4N?9Jng@sc-AeW>(1BIqiQglk8F48DfIu*$yDwh;7 z1xVcxMKLe5veKzijL9KdHqMeKkuW#n*wH8kyZfLhRq#sWfJ}R} zo70Mx+szl|^w5T~h;12|%Suj68LxGHh=#q}siS*wAU3Klnh!JJ#iB`AA3%6@kwyi;88L!89_ z=NUX-KyZ2@eCTk!TL6!;GA!Kngx4INe-4^jRx`JX+!~ha!<<_&XeW193IOI5Y)N_iY?{9MAwbR@K8u z1ChSKA$S$~&SafHnf!bPtE%?M7-la!3NSvj>Mp1Iil0q5XiC!zIr}o&dBYlS5elBU1)* zs18s~9EUjd{G4zXfo~ltY~&CxBO-)DNJ2Iyawr*4>SPYVZ;RXjE#QLL3oe8{O2Jq% zB%8-^uB~3eq2quajONhufUGGT$^i83Vh&9QG_tbk>05YOkN+zHw?~js4?`QAMNe>u zQ|bX3ey%lw2lX&%!UfLv^h-b$9v+7x$+-p&-45u7u^hS)(0SieuDFK6v8o;hFLe3C z9O?_`QxH@SQx%-++HwB#8sLZ>Z+bx$FVRD6#^;_eYQ3#1}VNnP@{s|7P2h`yhhw$^?&{Kv1 z%c}((AD5xIkRcbAg5ctf4(syih+i*o=u^b~>%AuqSf23p&<_C>4&glIigxTd4t;{S zscSg&@99yz&m{Z$ObnnIJve4ww_i{=v@M_w=-BmG!ovRoKrQ{U`>aoP^@tk^F{}4Y z3Eb%%x*c(EkL3`OkfaC>%>a~%PKSPkdaUfiq4t1IpbGUc(!*J_hC}NB&GJb$l910k z^Ee> z{A|QWDJ>l{?$xkP)tn5-$Q~7Pdp`s@p zttuZX=vP1s@(bQ~T<1t|tZI-OZfJPCA-`cogUhj_$G(CO6Aw8qBzADjN!;YfO*9wH zFDeA4@Q4MDP7yIhMGY?%g*3Q|T#mIpY75>hXl%H@;F*Y31zjTEOe~EsSH?8Zf({Xf zilQU33*Kx+h&wtmNv9T8S= z*l|T6qB2Kp z;>w~Gj$u7=3T79bYxn|XtSusmpCwK&%B}3+c(7n+M605^TgNy?RK6EcJ=q!INa;}- zm$;_razkUm<<^A--#7Fv$ObeRCMzTE4K8<7oz)xBKJY`*ZRT0=g?FY-?e~iU6?%jV9 zR!9j>un90?n)WeLdD8B1hQU=o-kmI)p25Og2)z4Y9&^J6qG%(RtKFvxvB<#WGTEVK zg<>pVv!rFGaCgRtZ2A!!(3Uw_Yz@m!htUK}2J=K1$r>zqvN2Y<)nPBSOP(_Lf=gzz z!y;mVq)30kvRj1%z~xzye1Qf3&QmpXDVABV&|%Xnbv7*S=|QJ`w5WH>PN{(}2UUkj zu~sa~h%p8ce(<6`-(z55p|Ei)WjLl}{Cph9pj5&_7q7&#a4{a0$C(d$m|aEoqoRZ9 z+$cXQ+UH{-!3g(k*vesgQ4VtxD~wa24D}OX;n_pHLC&maCb*MVYnGgi@`!4ei18T%!xY*a*PK9(6$uyKQ35{voivxR(p z$BgR!tCx?RKCb#3r|J8T&bh|x7S6f;lP6v0ZpCY-KU_KQ+VLmPyQVdPyqL2RoNmnn zZ+sJeauFDZ3qc@oZ}J77Tx5eEK8f+i5u=&%4^JksBz*IWho5r!Hyn1#sRd_RqX^so zYw!9(+p5C&xmklZgvBBBK_YUeVQa{PfxxIEZSP!dXF?ru*D!FmLO|uNLGB)u35Knf zTOmwjan+$PA1ppdUmWa~{c%FE)XOZE6&)Rp`kN^M^`+RyGgzi0hsHB)Y`+Nm zJpkLS=OKR|{Nr|^P#7H@6$BwZfnOoXc941qlJMAx_Rt7V`kg_!DKlsWp?MIoDl4F+ z@2vLdiJd^U-%DA0W9SkO3iBpZr{h!HppO-%3KRu5IR)f_5F&*qLKIFAP0{5((DVYr z|G;13l594ADlg-XCbmZq(}A1D5Iquk(mtN}8TW-nW2xvvRUPvnkJ-T&1hhw5NDE9L z@fb2OTyiUu(rE}$~*j^8lGz1qocTtekAmj9q>JAh%LSFN>d zUmT+^2g$4qaNT1FU|kD2EKC&u(XAUmj`GG*HXoM~D5lQU*+`2gl_4NUobeF=k&)V1 z1jqoa+Hn&crdt86;%5<0b-4Yd@VC-Z6kh?Hp#B`wL~b z?LdxaRDvZQ5B9~#w0J1;d3QGcsiv)P7#y9ejQBGUW6-4KC$;z#FXPBkGG0hzuWI4t zXAI-Svc#wVrUkxfeMeTMc=&Rxmgeu9XZ52rWZ-es!1^nNTlZt)_t+`b{T4S5wh`Prbh;`YW06tmI&)4Sh!Rh^!HuWt-px+@0o(W#Yqp;1mHjDx`T@-ud^Y=UI+Mck5<3n^6aqwj+O4x zx$U9?+&yf7Gvtgfz3 zO;1mIrac}HeA}(D-TWffFyuCjj23D%vTIRi>(t89el#dxeRG0sNEb7!rGec>?#&_F zux=yPvO58^J}nzb4+)jZ0Twu&UJ-8NSiO0UhoI25i*;cI$3<9ZNRT0-CT$a5oRIfH z{7HiFtzToWRj?)Y4TKXlXtBME?K#NA$??QzWNh5b?u9_)K`ag+>=ls4;t7cQ&+<&V zb08FclN365lM!spWOnYoZpa) value >= 249 ? value : (byte)(value * value * value % 249); + private static void Entry(byte[] data, int offset, ushort tag, ushort type, uint count, uint value, bool littleEndian) { + Write16(data, offset, tag, littleEndian); + Write16(data, offset + 2, type, littleEndian); + Write32(data, offset + 4, count, littleEndian); + Write32(data, offset + 8, value, littleEndian); + } + private static void Write16(byte[] data, int offset, ushort value, bool littleEndian) { + if (littleEndian) BinaryPrimitives.WriteUInt16LittleEndian(data.AsSpan(offset, 2), value); + else BinaryPrimitives.WriteUInt16BigEndian(data.AsSpan(offset, 2), value); + } + private static void Write32(byte[] data, int offset, uint value, bool littleEndian) { + if (littleEndian) BinaryPrimitives.WriteUInt32LittleEndian(data.AsSpan(offset, 4), value); + else BinaryPrimitives.WriteUInt32BigEndian(data.AsSpan(offset, 4), value); + } +} diff --git a/Tests/Temperature/Temperature.Tests.csproj b/Tests/Temperature/Temperature.Tests.csproj new file mode 100644 index 0000000..7396eed --- /dev/null +++ b/Tests/Temperature/Temperature.Tests.csproj @@ -0,0 +1,16 @@ + + + net8.0 + true + false + + + + + + all + + + + + From f6a9ac5aa866018d63bdfdf2572c3dc4fd163e67 Mon Sep 17 00:00:00 2001 From: Lucas Lepski Date: Sat, 17 Oct 2026 13:21:11 +0200 Subject: [PATCH 43/45] Adapt reviewed native stability fixes without unsafe recovery Integrate the shutter readback, manual ISO, native serialization, image consumption, DLL version and focuser improvements proposed in dougforpres/NINASonyCameraPlugin#13, while retaining the current capture controller and native image buffer cleanup. Defer automatic USB reconnect, retrying timed-out native workers, and forced busy-capture cancellation. Add regression tests for readout cancellation, timeout exclusion, shutter application and combined RAW temperature reporting. Co-authored-by: Robert Hasson --- Drivers/CameraDriver.cs | 45 ++-- Drivers/CaptureController.cs | 3 + Drivers/FocuserDriver.cs | 104 +++++++-- Drivers/ISonyCameraBackend.cs | 1 + Drivers/NativeSonyCameraBackend.cs | 1 + Drivers/SimulatedSonyCameraBackend.cs | 4 + NINASonyCameraPlugin.csproj | 9 +- .../ExposureTimeController.cs | 42 ++++ SonyCameraPluginNative/NativeCallExecutor.cs | 37 ++++ SonyCameraPluginNative/Sony.cs | 201 ++++++++++-------- SonyCameraPluginNative/SonyMTPCameraDll.cs | 21 +- Tests/CaptureController.Tests.csproj | 2 + Tests/CaptureControllerTests.cs | 11 + Tests/ExposureTimeControllerTests.cs | 53 +++++ Tests/NativeCallExecutorTests.cs | 80 +++++++ .../CameraDriverSimulatorTests.cs | 65 ++++++ .../PluginIntegration.Tests.csproj | 1 + 17 files changed, 538 insertions(+), 142 deletions(-) create mode 100644 SonyCameraPluginNative/ExposureTimeController.cs create mode 100644 SonyCameraPluginNative/NativeCallExecutor.cs create mode 100644 Tests/ExposureTimeControllerTests.cs create mode 100644 Tests/NativeCallExecutorTests.cs diff --git a/Drivers/CameraDriver.cs b/Drivers/CameraDriver.cs index d588df2..dd04287 100644 --- a/Drivers/CameraDriver.cs +++ b/Drivers/CameraDriver.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.IO; using System.Linq; @@ -35,6 +35,8 @@ public class CameraDriver : BaseINPC, ICamera { private readonly CaptureController _capture; private readonly object connectionLock = new object(); + private int _lastKnownGain = -1; + private int _lastKnownBatteryLevel; private SonyCameraInfo _camera = null; private double _lastCameraTemperature = double.NaN; private SonyDevice _device = null; @@ -77,7 +79,8 @@ private IReadOnlyList GetAvailableIsoOptions() { foreach (var propertyId in propertyCandidates) { try { - var options = _camera.GetPropertyInfo(propertyId)?.Options()?.Where(o => o.Value <= 0x00FFFFFF).ToList(); + var options = _camera.GetPropertyInfo(propertyId)?.Options()?.Where(o => o.Value > 0 && o.Value < 0x00FFFFFF) + .GroupBy(o => o.Value).Select(group => group.First()).OrderBy(o => o.Value).ToList(); if (options != null && options.Count > 0) { return options; } @@ -253,7 +256,12 @@ public bool LiveViewEnabled { public int BatteryLevel { get { if (_camera != null) { - return (int)GetPropertyValue(PROPID_BATTERY).Value; + try { + _lastKnownBatteryLevel = (int)GetPropertyValue(PROPID_BATTERY).Value; + } catch (Exception ex) { + Logger.Warning($"Unable to read Sony battery level: {ex.Message}"); + } + return _lastKnownBatteryLevel; } else { return 0; } @@ -308,10 +316,11 @@ public int Gain { try { PropertyValue value = GetPropertyValue(PROPID_ISO); - return (int)(value.Value == 0xffffff ? 0 : value.Value); + _lastKnownGain = value.Value > 0 && value.Value < 0x00FFFFFF ? (int)value.Value : -1; + return _lastKnownGain; } catch (Exception ex) { - Logger.Error("Problem getting gain", ex); - return -1; + Logger.Warning($"Unable to read Sony ISO: {ex.Message}"); + return _lastKnownGain; } } else { return -1; @@ -321,6 +330,7 @@ public int Gain { set { if (_camera != null) { try { + if (!Gains.Contains(value)) throw new ArgumentOutOfRangeException(nameof(value), "Select a supported manual ISO value."); _driver.SetProperty(_camera.Handle, PROPID_ISO, (uint)value); RaisePropertyChanged(nameof(Gain)); } catch (Exception ex) { @@ -330,21 +340,7 @@ public int Gain { } } - public IList Gains { - get { - List gains = new List(); - - foreach (var iso in GetAvailableIsoOptions()) { - if (iso.Value == 0xffffff) { - gains.Add(0); // AUTO - } else { - gains.Add((int)iso.Value); - } - } - - return gains; - } - } + public IList Gains => GetAvailableIsoOptions().Select(option => (int)option.Value).ToList(); public string Id => _device.Id == SimulatedSonyCameraBackend.DeviceId ? SimulatedSonyCameraBackend.DeviceId : "Sony"; @@ -362,7 +358,7 @@ public string DisplayName { public bool Connected { get { - return _camera != null; + return _camera != null && _driver.IsOpen(_camera.Handle); } } @@ -486,8 +482,10 @@ public Task Connect(CancellationToken token) { bool connected; lock (connectionLock) { token.ThrowIfCancellationRequested(); - if (_camera != null) return true; + if (Connected) return true; Volatile.Write(ref _lastCameraTemperature, double.NaN); + _lastKnownGain = -1; + _lastKnownBatteryLevel = 0; try { _camera = _driver.OpenCamera(_device.Id); } catch (Exception ex) { @@ -552,6 +550,7 @@ public void SetupDialog() { } public void StartExposure(CaptureSequence sequence) { + if (!Connected) throw new SonyException("Connect a Sony camera before starting an exposure."); if (_camera != null) { try { _capture.Start((float)sequence.ExposureTime); diff --git a/Drivers/CaptureController.cs b/Drivers/CaptureController.cs index fda0fdb..2e6830a 100644 --- a/Drivers/CaptureController.cs +++ b/Drivers/CaptureController.cs @@ -70,6 +70,9 @@ internal AbortResult Abort() { return AbortResult.AlreadyRequested; } uint status = getStatus(); + if (status == Reading || status == Processing) { + return AbortResult.SoftCancelRequested; + } if (status == StatusUnknown || (!IsBusy(status) && status != Created && status != Cancelled && status != Complete && status != Failed)) { return AbortResult.SoftCancelRequested; diff --git a/Drivers/FocuserDriver.cs b/Drivers/FocuserDriver.cs index 8f08915..558db2b 100644 --- a/Drivers/FocuserDriver.cs +++ b/Drivers/FocuserDriver.cs @@ -1,8 +1,9 @@ -using System; +using System; using System.Collections.Generic; using System.ComponentModel; using System.IO; using System.Linq; +using System.Runtime.CompilerServices; using System.Text; using System.Threading; using System.Threading.Tasks; @@ -30,11 +31,19 @@ namespace NINA.RetroKiwi.Plugin.SonyCamera.Drivers { public class FocuserDriver : IFocuser, IDisposable { + // NINA polls Position/MaxIncrement on the focuser's device-update timer, + // and the focuser shares its device handle with the camera. Rather than + // let a poll queue up behind a 25MB image download, give up quickly and + // report the last known value. + private const int POLL_READ_TIMEOUT_MS = 500; + private bool disposedValue; internal SonyLens _info; internal IProfileService _profileService; internal bool _moving = false; internal bool _connected = false; + private int _lastKnownPosition = 0; + private int _lastKnownMaxIncrement = 0; public FocuserDriver(IProfileService profileService, SonyLens info) { _profileService = profileService; @@ -43,12 +52,40 @@ public FocuserDriver(IProfileService profileService, SonyLens info) { public event PropertyChangedEventHandler PropertyChanged; + private void RaisePropertyChanged([CallerMemberName] string propertyName = null) { + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName)); + } + + private bool Moving { + get => _moving; + set { + if (_moving != value) { + _moving = value; + RaisePropertyChanged(nameof(IsMoving)); + } + } + } + + private bool IsConnected { + get => _connected; + set { + if (_connected != value) { + _connected = value; + RaisePropertyChanged(nameof(Connected)); + } + } + } + public bool IsMoving { get => _moving; } private uint GetCameraHandle() { - SonyCameraInfo camera = SonyDriver.GetInstance().CameraInfo; + SonyDriver driver = SonyDriver.GetInstance(); + SonyCameraInfo camera = driver.CameraInfo; - if (camera != null) { + // Also confirm the handle is still open: the driver drops it when the + // camera stops responding, and the focuser must not keep issuing + // focus commands against a handle the camera side has abandoned. + if (camera != null && driver.IsOpen(camera.Handle)) { return camera.Handle; } else @@ -62,7 +99,15 @@ public int MaxIncrement { uint hCamera = GetCameraHandle(); if (hCamera != 0) { - return (int)SonyDriver.GetInstance().GetFocusLimit(hCamera); + try { + if (SonyDriver.GetInstance().TryGetFocusLimit(hCamera, POLL_READ_TIMEOUT_MS, out uint limit)) { + _lastKnownMaxIncrement = (int)limit; + } + } catch (Exception ex) { + Logger.Debug($"Unable to read focus limit: {ex.Message}"); + } + + return _lastKnownMaxIncrement; } else { return 0; @@ -77,7 +122,15 @@ public int Position { uint hCamera = GetCameraHandle(); if (hCamera != 0) { - return (int)SonyDriver.GetInstance().GetFocusPosition(hCamera); + try { + if (SonyDriver.GetInstance().TryGetFocusPosition(hCamera, POLL_READ_TIMEOUT_MS, out uint position)) { + _lastKnownPosition = (int)position; + } + } catch (Exception ex) { + Logger.Debug($"Unable to read focus position: {ex.Message}"); + } + + return _lastKnownPosition; } else { return 0; } @@ -87,9 +140,16 @@ public int Position { uint hCamera = GetCameraHandle(); if (hCamera != 0) { - _moving = true; - SonyDriver.GetInstance().SetFocusPosition(hCamera, (uint)value); - _moving = false; + Moving = true; + + try { + SonyDriver.GetInstance().SetFocusPosition(hCamera, (uint)value); + _lastKnownPosition = value; + } catch (Exception ex) { + Logger.Error($"Problem moving focus to {value}", ex); + } finally { + Moving = false; + } } } } @@ -120,8 +180,8 @@ public bool Connected { if (hCamera == 0) { Logger.Debug("Sony camera was disconnected without closing focuser first, cleaning up"); - _connected = false; - _moving = false; + IsConnected = false; + Moving = false; } } @@ -142,17 +202,23 @@ public string Action(string actionName, string actionParameters) { } public Task Connect(CancellationToken token) { - _moving = false; + Moving = false; return Task.Run(() => { UInt32 hCamera = GetCameraHandle(); if (hCamera != 0) { - _connected = true; + Moving = true; - _moving = true; - SonyDriver.GetInstance().SetAttachedLens(hCamera, Id); - _moving = false; + try { + SonyDriver.GetInstance().SetAttachedLens(hCamera, Id); + IsConnected = true; + } catch (Exception ex) { + Logger.Error($"Unable to attach lens '{Id}'", ex); + Notification.ShowError($"Unable to attach lens '{Id}': {ex.Message}"); + } finally { + Moving = false; + } } else { Notification.ShowWarning(Loc.Instance["LblNoCameraConnected"]); } @@ -162,8 +228,8 @@ public Task Connect(CancellationToken token) { } public void Disconnect() { - _moving = false; - _connected = false; + Moving = false; + IsConnected = false; } public void Halt() { @@ -174,7 +240,7 @@ public Task Move(int position, CancellationToken ct, int waitInMs) { UInt32 hCamera = GetCameraHandle(); if (hCamera != 0) { - _moving = true; + Moving = true; return Task.Run(() => { try { @@ -183,7 +249,7 @@ public Task Move(int position, CancellationToken ct, int waitInMs) { Logger.Error(ex); } - _moving = false; + Moving = false; }); } else diff --git a/Drivers/ISonyCameraBackend.cs b/Drivers/ISonyCameraBackend.cs index 48c2a75..2cba108 100644 --- a/Drivers/ISonyCameraBackend.cs +++ b/Drivers/ISonyCameraBackend.cs @@ -6,6 +6,7 @@ public interface ISonyCameraBackend { IEnumerable Cameras(); SonyCameraInfo OpenCamera(string id); void CloseCamera(uint handle); + bool IsOpen(uint handle); PropertyValue GetProperty(uint handle, uint propertyId); void SetProperty(uint handle, uint propertyId, uint value); byte[] GetLiveView(uint handle); diff --git a/Drivers/NativeSonyCameraBackend.cs b/Drivers/NativeSonyCameraBackend.cs index bc674e9..c0a7858 100644 --- a/Drivers/NativeSonyCameraBackend.cs +++ b/Drivers/NativeSonyCameraBackend.cs @@ -8,6 +8,7 @@ internal sealed class NativeSonyCameraBackend : ISonyCameraBackend { public IEnumerable Cameras() => driver.Cameras(); public SonyCameraInfo OpenCamera(string id) => driver.OpenCamera(id); public void CloseCamera(uint handle) => driver.CloseCamera(handle); + public bool IsOpen(uint handle) => driver.IsOpen(handle); public PropertyValue GetProperty(uint handle, uint propertyId) => driver.GetProperty(handle, propertyId); public void SetProperty(uint handle, uint propertyId, uint value) => driver.SetProperty(handle, propertyId, value); public byte[] GetLiveView(uint handle) => driver.GetLiveView(handle); diff --git a/Drivers/SimulatedSonyCameraBackend.cs b/Drivers/SimulatedSonyCameraBackend.cs index 1d176e9..bcff5b7 100644 --- a/Drivers/SimulatedSonyCameraBackend.cs +++ b/Drivers/SimulatedSonyCameraBackend.cs @@ -83,6 +83,10 @@ public SonyCameraInfo OpenCamera(string id) { } } + public bool IsOpen(uint cameraHandle) { + lock (cameraLock) return connected && cameraHandle == handle; + } + public void CloseCamera(uint cameraHandle) { lock (cameraLock) { RequireConnection(cameraHandle); diff --git a/NINASonyCameraPlugin.csproj b/NINASonyCameraPlugin.csproj index 608d7c4..2bd3082 100644 --- a/NINASonyCameraPlugin.csproj +++ b/NINASonyCameraPlugin.csproj @@ -1,4 +1,4 @@ - + net8.0-windows10.0.17763.0 Library @@ -8,9 +8,6 @@ true true - - - @@ -69,7 +66,7 @@ - - + + diff --git a/SonyCameraPluginNative/ExposureTimeController.cs b/SonyCameraPluginNative/ExposureTimeController.cs new file mode 100644 index 0000000..916bc9c --- /dev/null +++ b/SonyCameraPluginNative/ExposureTimeController.cs @@ -0,0 +1,42 @@ +using System; +using System.Diagnostics; +using System.Threading; + +namespace Sony { + // Based on Robert Hasson's PR #13 readback/retry workaround. Keep the + // decision independent of the DLL so stalled shutter changes are testable. + internal static class ExposureTimeController { + private const uint NotSupported = 50; + + internal static bool Apply(double requested, Func setExposure, Func readShutter, + int timeoutMs = 12000, Action pause = null) { + if (!double.IsFinite(requested) || requested < 0) throw new ArgumentOutOfRangeException(nameof(requested)); + pause ??= () => Thread.Sleep(300); + Stopwatch elapsed = Stopwatch.StartNew(); + double actual = double.NaN; + do { + uint result = setExposure(); + if (result == NotSupported) return false; + if (result != 0) throw new InvalidOperationException($"Unable to set exposure time to {requested}s, error {result}."); + + uint raw = readShutter(); + // Sony uses zero for Bulb. Zero-duration captures retain the + // native driver's existing Bulb behavior. + if (raw == 0 && (requested == 0 || requested > 30)) return true; + uint numerator = raw >> 16; + uint denominator = raw & 0xFFFF; + if (numerator == 0 || denominator == 0 || raw == uint.MaxValue) { + throw new InvalidOperationException("The camera did not report a verifiable shutter speed."); + } + actual = (double)numerator / denominator; + // Sony's irregular ladder includes a 4/3 gap. The native + // driver chooses by absolute distance: its worst half-step + // ratio is 7/6, below 1.17 and the smallest whole step (1.20). + if (requested > 0 && Math.Max(actual / requested, requested / actual) <= 1.17) return true; + if (elapsed.ElapsedMilliseconds >= timeoutMs) break; + pause(); + } while (elapsed.ElapsedMilliseconds < timeoutMs); + throw new InvalidOperationException($"Exposure time was not applied: requested {requested}s, camera reports {actual}s. Capture was not started."); + } + } +} diff --git a/SonyCameraPluginNative/NativeCallExecutor.cs b/SonyCameraPluginNative/NativeCallExecutor.cs new file mode 100644 index 0000000..5078ae8 --- /dev/null +++ b/SonyCameraPluginNative/NativeCallExecutor.cs @@ -0,0 +1,37 @@ +using System; +using System.Threading; +using System.Threading.Tasks; + +namespace Sony { + // A timed-out P/Invoke keeps running. Never let a later call enter the DLL, + // even if the abandoned call eventually returns; recovery requires a restart. + internal sealed class NativeCallExecutor { + private readonly SemaphoreSlim gate = new SemaphoreSlim(1, 1); + private int unavailable; + + internal bool IsUnavailable => Volatile.Read(ref unavailable) != 0; + + internal T Invoke(Func action, int timeoutMs = 30000, int lockTimeoutMs = 30000) { + if (IsUnavailable) throw new InvalidOperationException("The Sony driver stopped responding. Restart NINA and power-cycle the camera before reconnecting."); + if (!gate.Wait(lockTimeoutMs)) throw new TimeoutException("The Sony driver is busy with another operation."); + try { + if (IsUnavailable) throw new InvalidOperationException("The Sony driver stopped responding. Restart NINA and power-cycle the camera before reconnecting."); + Task call = Task.Run(action); + try { + if (!call.Wait(timeoutMs)) { + Volatile.Write(ref unavailable, 1); + _ = call.ContinueWith(failed => { _ = failed.Exception; }, + TaskContinuationOptions.OnlyOnFaulted | TaskContinuationOptions.ExecuteSynchronously); + throw new TimeoutException("The Sony driver stopped responding. Restart NINA and power-cycle the camera before reconnecting."); + } + } catch (AggregateException) { + // Preserve the native operation's original exception. + return call.GetAwaiter().GetResult(); + } + return call.GetAwaiter().GetResult(); + } finally { + gate.Release(); + } + } + } +} diff --git a/SonyCameraPluginNative/Sony.cs b/SonyCameraPluginNative/Sony.cs index 366c4d0..8a7f408 100644 --- a/SonyCameraPluginNative/Sony.cs +++ b/SonyCameraPluginNative/Sony.cs @@ -1,112 +1,143 @@ -using System; +using System; using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; - -namespace Sony -{ - #region SonyBase +using System.Threading; +using NINA.Core.Utility; +using NINA.Core.Utility.Notification; + +namespace Sony { + // Adapted from Robert Hasson's PR #13: camera and focuser share one native + // entry point. A timeout permanently blocks further native calls, including + // close/open/enumeration, because the old P/Invoke cannot be interrupted. public class SonyDriver { private static readonly Lazy _instance = new Lazy(() => new SonyDriver()); + private static readonly NativeCallExecutor calls = new NativeCallExecutor(); + private readonly SonyMTPCameraDll _sonydll; + private volatile SonyCameraInfo _camera; + private int timeoutNotified; - private SonyMTPCameraDll _sonydll; - private SonyCameraInfo _camera = null; - - public static SonyDriver GetInstance() { - return _instance.Value; - } + public static SonyDriver GetInstance() => _instance.Value; public SonyDriver() { _sonydll = new SonyMTPCameraDll(); } - public IEnumerable Cameras() { - uint count = _sonydll.GetSupportedDeviceCount(); - - for (uint i = 0; i < count; i++) { - yield return new SonyDevice(_sonydll.GetSupportedDeviceInfo(i)); + private T Invoke(string operation, Func action, int timeoutMs = 30000, int lockTimeoutMs = 30000) { + try { + return calls.Invoke(action, timeoutMs, lockTimeoutMs); + } catch (Exception ex) { + if (calls.IsUnavailable && Interlocked.Exchange(ref timeoutNotified, 1) == 0) { + Logger.Error($"Sony driver stopped responding while trying to {operation}. Restart NINA and power-cycle the camera before reconnecting.", ex); + try { + Notification.ShowError("Sony driver stopped responding. Restart NINA and power-cycle the camera before reconnecting."); + } catch (Exception notificationError) { + Logger.Warning($"Could not show Sony driver timeout notification: {notificationError.Message}"); + } + } + throw; } } - public SonyCameraInfo CameraInfo { get { return _camera; } } - - public SonyCameraInfo OpenCamera(string path) { - uint hCamera = _sonydll.OpenDevice(path); - List descriptors = _sonydll.GetPropertyDescriptors(hCamera); - List properties = new List(); - - foreach (PropertyDescriptor descriptor in descriptors) { - List options = null; - - if (descriptor.ValueCount > 0) { - options = _sonydll.GetPropertyValueOptions(hCamera, descriptor); + private T InvokeCamera(uint handle, string operation, Func action, int timeoutMs = 30000, int lockTimeoutMs = 30000) { + return Invoke(operation, () => { + if (_camera == null || _camera.Handle != handle) { + throw new SonyException($"Cannot {operation}: the camera handle is no longer open."); } + return action(); + }, timeoutMs, lockTimeoutMs); + } - properties.Add(new PropertyInfo(descriptor, options)); + public IEnumerable Cameras() => Invoke("enumerate cameras", () => { + var devices = new List(); + uint count = _sonydll.GetSupportedDeviceCount(); + for (uint i = 0; i < count; i++) devices.Add(new SonyDevice(_sonydll.GetSupportedDeviceInfo(i))); + return devices; + }); + + public SonyCameraInfo CameraInfo => calls.IsUnavailable ? null : _camera; + public bool IsOpen(uint handle) => !calls.IsUnavailable && _camera != null && _camera.Handle == handle; + + public SonyCameraInfo OpenCamera(string path) => Invoke("open camera", () => { + if (_camera != null) throw new SonyException("A Sony camera is already open. Disconnect it before opening another camera; restart NINA if the earlier disconnect timed out."); + uint handle = _sonydll.OpenDevice(path); + if (handle == 0 || handle == SonyMTPCameraDll.INVALID_HANDLE_VALUE) throw new SonyException("Unable to open the Sony camera."); + try { + var properties = new List(); + foreach (PropertyDescriptor descriptor in _sonydll.GetPropertyDescriptors(handle)) { + List options = descriptor.ValueCount > 0 + ? _sonydll.GetPropertyValueOptions(handle, descriptor) : null; + properties.Add(new PropertyInfo(descriptor, options)); + } + _sonydll.DiscardLastImage(); + var camera = new SonyCameraInfo(handle, _sonydll.GetDeviceInfo(handle), _sonydll.GetCameraInfo(handle, 0), properties); + // Even a late successful open stays inaccessible after timeout: + // CameraInfo, IsOpen and every native entry check the sticky gate. + _camera = camera; + return camera; + } catch { + // Cleanup remains inside the same bounded operation. If this + // blocks, the executor stays unavailable until NINA restarts. + _sonydll.CloseDevice(handle); + throw; } - - _camera = new SonyCameraInfo(hCamera, _sonydll.GetDeviceInfo(hCamera), _sonydll.GetCameraInfo(hCamera, 0), properties); - - return _camera; - } + }, timeoutMs: 60000); public void CloseCamera(uint handle) { - _sonydll.CloseDevice(handle); - - _camera = null; - } - - public PropertyValue GetProperty(uint handle, uint propertyId) { - return _sonydll.GetSinglePropertyValue(handle, propertyId); + InvokeCamera(handle, "close camera", () => { + _camera = null; + _sonydll.DiscardLastImage(); + _sonydll.CloseDevice(handle); + return true; + }, timeoutMs: 5000, lockTimeoutMs: 5000); } - public void SetProperty(uint handle, uint propertyId, uint value) { - _sonydll.SetPropertyValue(handle, propertyId, value); - } - - public byte[] GetLiveView(uint handle) { - return _sonydll.GetPreviewImage(handle); - } - - public uint GetCaptureStatus(uint handle) { - return _sonydll.GetCaptureStatus(handle); - } + public PropertyValue GetProperty(uint handle, uint propertyId) => + InvokeCamera(handle, "read camera property", () => _sonydll.GetSinglePropertyValue(handle, propertyId), lockTimeoutMs: 500); + public void SetProperty(uint handle, uint propertyId, uint value) => + InvokeCamera(handle, "set camera property", () => { _sonydll.SetPropertyValue(handle, propertyId, value); return true; }); + public byte[] GetLiveView(uint handle) => InvokeCamera(handle, "download LiveView", () => _sonydll.GetPreviewImage(handle)); + public uint GetCaptureStatus(uint handle) => InvokeCamera(handle, "read capture status", () => _sonydll.GetCaptureStatus(handle)); public void StartCapture(uint handle, float exposureTime) { - _sonydll.StartCapture(handle, exposureTime); + InvokeCamera(handle, "start capture", () => { + _sonydll.DiscardLastImage(); + return _sonydll.StartCapture(handle, exposureTime); + }, timeoutMs: 60000); } - public void CancelCapture(uint handle) { - _sonydll.CancelCapture(handle); - } + public void CancelCapture(uint handle) => InvokeCamera(handle, "cancel capture", () => { _sonydll.CancelCapture(handle); return true; }); + public byte[] GetLastImage() => _sonydll.TakeLastImage() + ?? throw new SonyException("No completed Sony image is available to download."); - public byte[] GetLastImage() { - return _sonydll.LastImage; - } - public IEnumerable Lenses() { + public IEnumerable Lenses() => Invoke("enumerate lenses", () => { + var lenses = new List(); int count = _sonydll.GetLensCount(); - - for (uint i = 0; i < count; i++) { - yield return new SonyLens(_sonydll.GetLensInfo(i)); - } - } - - public void SetAttachedLens(uint handle, string path) { - _sonydll.SetAttachedLens(handle, path); - } - - public void SetFocusPosition(uint handle, uint position) { - _sonydll.SetFocusPosition(handle, position); - } - - public uint GetFocusPosition(uint handle) { - return _sonydll.GetFocusPosition(handle); - } - - public uint GetFocusLimit(uint handle) { - return _sonydll.GetFocusLimit(handle); - } + for (uint i = 0; i < count; i++) lenses.Add(new SonyLens(_sonydll.GetLensInfo(i))); + return lenses; + }); + + public bool TryGetFocusPosition(uint handle, int timeoutMs, out uint position) { + position = 0; + try { + position = InvokeCamera(handle, "read focus position", () => _sonydll.GetFocusPosition(handle), lockTimeoutMs: timeoutMs); + return true; + } catch (TimeoutException) { return false; } + } + + public bool TryGetFocusLimit(uint handle, int timeoutMs, out uint limit) { + limit = 0; + try { + limit = InvokeCamera(handle, "read focus limit", () => _sonydll.GetFocusLimit(handle), lockTimeoutMs: timeoutMs); + return true; + } catch (TimeoutException) { return false; } + } + + public void SetAttachedLens(uint handle, string path) => + InvokeCamera(handle, "attach lens", () => { _sonydll.SetAttachedLens(handle, path); return true; }); + public void SetFocusPosition(uint handle, uint position) => + InvokeCamera(handle, "move focus", () => { _sonydll.SetFocusPosition(handle, position); return true; }); + public uint GetFocusPosition(uint handle) => + InvokeCamera(handle, "read focus position", () => _sonydll.GetFocusPosition(handle), lockTimeoutMs: 500); + public uint GetFocusLimit(uint handle) => + InvokeCamera(handle, "read focus limit", () => _sonydll.GetFocusLimit(handle), lockTimeoutMs: 500); } - #endregion } diff --git a/SonyCameraPluginNative/SonyMTPCameraDll.cs b/SonyCameraPluginNative/SonyMTPCameraDll.cs index 07c152f..58a9500 100644 --- a/SonyCameraPluginNative/SonyMTPCameraDll.cs +++ b/SonyCameraPluginNative/SonyMTPCameraDll.cs @@ -1,4 +1,4 @@ -using NINA.Core.Utility; +using NINA.Core.Utility; using System; using System.Buffers; using System.Collections; @@ -11,6 +11,7 @@ using System.Runtime.InteropServices; using System.Text; using System.Threading.Tasks; +using System.Threading; namespace Sony { #region Kernel32 Imports @@ -118,7 +119,7 @@ internal SonyMTPCameraDll() { // Confirm the driver version meets the minimum required 1.0.1.16 FileVersionInfo dllVersion = FileVersionInfo.GetVersionInfo(filename); - int[] v = { dllVersion.ProductMajorPart, dllVersion.ProductMinorPart, dllVersion.ProductPrivatePart, dllVersion.ProductBuildPart }; + int[] v = { dllVersion.ProductMajorPart, dllVersion.ProductMinorPart, dllVersion.ProductBuildPart, dllVersion.ProductPrivatePart }; if (dllVersion == null) { throw new SonyException("The loaded Sony dll does not have version info - this is invalid"); @@ -171,11 +172,9 @@ internal void SetupDelegate(string functionName) { field.SetValue(this, d); } - internal byte[] LastImage { - get { - return _lastImage; - } - } + internal byte[] TakeLastImage() => Interlocked.Exchange(ref _lastImage, null); + + internal void DiscardLastImage() => Interlocked.Exchange(ref _lastImage, null); internal IntPtr GetExportedFunction(IntPtr hDll, string name) { IntPtr methodPointer = Kernel32.GetProcAddress(_dllHandle, name); @@ -288,8 +287,12 @@ public byte[] GetPreviewImage(UInt32 hDevice) { public UInt32 StartCapture(UInt32 hDevice, float exposureTime) { PropertyValue value = new PropertyValue(); - - fSetExposureTime(hDevice, exposureTime, ref value); + bool applied = ExposureTimeController.Apply(exposureTime, + () => fSetExposureTime(hDevice, exposureTime, ref value), + () => GetSinglePropertyValue(hDevice, 0xD20D).Value); + if (!applied) { + Logger.Warning("The camera has no learnt shutter-speed list. Its manually selected shutter speed will be used; run SonyCameraInfo to learn exposure settings before using the Flat Wizard."); + } ImageInfo info = new ImageInfo { ImageMode = IMAGEMODE_PASSTHRU, diff --git a/Tests/CaptureController.Tests.csproj b/Tests/CaptureController.Tests.csproj index f0464af..2b733e6 100644 --- a/Tests/CaptureController.Tests.csproj +++ b/Tests/CaptureController.Tests.csproj @@ -18,5 +18,7 @@ + + diff --git a/Tests/CaptureControllerTests.cs b/Tests/CaptureControllerTests.cs index f52c0f1..b84e653 100644 --- a/Tests/CaptureControllerTests.cs +++ b/Tests/CaptureControllerTests.cs @@ -5,6 +5,17 @@ using Xunit; public class CaptureControllerTests { + [Theory] + [InlineData(0x8002u)] + [InlineData(0x8003u)] + public void NativeAbortDoesNotCancelAnImageBeingReadOrProcessed(uint status) { + var camera = new Camera { NativeCancelEnabled = true }; + camera.Capture.Start(1); + camera.Status = status; + Assert.Equal(CaptureController.AbortResult.SoftCancelRequested, camera.Capture.Abort()); + Assert.Equal(0, camera.Cancels); + } + private sealed class Camera { internal uint Status = 2; // The native driver reports Failed when no capture exists. internal bool NativeCancelEnabled; diff --git a/Tests/ExposureTimeControllerTests.cs b/Tests/ExposureTimeControllerTests.cs new file mode 100644 index 0000000..a7aa869 --- /dev/null +++ b/Tests/ExposureTimeControllerTests.cs @@ -0,0 +1,53 @@ +using System; +using Sony; +using Xunit; + +public class ExposureTimeControllerTests { + [Fact] + public void BackToBackShutterChangeIsRetriedUntilItTakes() { + int attempts = 0; + Assert.True(ExposureTimeController.Apply(0.5, () => { attempts++; return 0; }, + () => attempts < 3 ? 0x00010001u : 0x00010002u, pause: () => { })); + Assert.Equal(3, attempts); + } + + [Theory] + [InlineData(1.0 / 80, 7.0 / 480)] + [InlineData(0.6, 0.699)] + public void NearestSupportedShutterIsAcceptedAcrossIrregularSteps(double actual, double requested) { + uint raw = actual < 0.1 ? 0x00010050u : 0x0006000Au; + Assert.True(ExposureTimeController.Apply(requested, () => 0, () => raw, timeoutMs: 0)); + } + + [Fact] + public void StalledShutterFailsInsteadOfExposingAtTheOldSpeed() { + var error = Assert.Throws(() => ExposureTimeController.Apply( + 0.5, () => 0, () => 0x00010001, timeoutMs: 0)); + Assert.Contains("Capture was not started", error.Message); + } + + [Fact] + public void APartialMoveDoesNotCountAsSuccessfulSettling() { + int attempts = 0; + Assert.Throws(() => ExposureTimeController.Apply(0.01, + () => ++attempts == 1 ? 0u : 31u, () => 0x00010002u, pause: () => { })); + Assert.Equal(2, attempts); + } + + [Fact] + public void UnsupportedExposureControlIsExplicit() { + Assert.False(ExposureTimeController.Apply(1, () => 50, () => throw new Exception("No readback expected"))); + } + + [Theory] + [InlineData(0u)] + [InlineData(0xFFFFFFFFu)] + public void InvalidOrUnexpectedBulbReadbackCannotStartShortExposures(uint raw) { + Assert.Throws(() => ExposureTimeController.Apply(1, () => 0, () => raw)); + } + + [Fact] + public void LongBulbExposureIsSupported() { + Assert.True(ExposureTimeController.Apply(120, () => 0, () => 0)); + } +} diff --git a/Tests/NativeCallExecutorTests.cs b/Tests/NativeCallExecutorTests.cs new file mode 100644 index 0000000..5866620 --- /dev/null +++ b/Tests/NativeCallExecutorTests.cs @@ -0,0 +1,80 @@ +using System; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Sony; +using Xunit; + +public class NativeCallExecutorTests { + [Fact] + public async Task NativeCallsNeverOverlap() { + var executor = new NativeCallExecutor(); + int active = 0; + var calls = Enumerable.Range(0, 12).Select(_ => Task.Run(() => executor.Invoke(() => { + Assert.Equal(1, Interlocked.Increment(ref active)); + Thread.Sleep(2); + Assert.Equal(0, Interlocked.Decrement(ref active)); + return 1; + }))); + Assert.Equal(12, (await Task.WhenAll(calls)).Sum()); + } + + [Fact] + public async Task TimeoutBlocksAllLaterNativeCallsEvenAfterTheWorkerReturns() { + var executor = new NativeCallExecutor(); + using var entered = new ManualResetEventSlim(); + using var release = new ManualResetEventSlim(); + var finished = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var first = Task.Run(() => Assert.Throws(() => executor.Invoke(() => { + entered.Set(); + release.Wait(); + finished.SetResult(true); + return 1; + }, timeoutMs: 200))); + try { + Assert.True(entered.Wait(5000)); + await first.WaitAsync(TimeSpan.FromSeconds(5)); + Assert.True(executor.IsUnavailable); + int calls = 0; + Assert.Throws(() => executor.Invoke(() => ++calls)); + release.Set(); + await finished.Task.WaitAsync(TimeSpan.FromSeconds(5)); + Assert.Throws(() => executor.Invoke(() => ++calls)); + Assert.Equal(0, calls); + } finally { + release.Set(); + } + } + + [Fact] + public async Task WaitingForABusyDriverNeverExecutesTheRejectedCall() { + var executor = new NativeCallExecutor(); + using var entered = new ManualResetEventSlim(); + using var release = new ManualResetEventSlim(); + var first = Task.Run(() => executor.Invoke(() => { + entered.Set(); + release.Wait(); + return 1; + }, timeoutMs: 5000)); + try { + Assert.True(entered.Wait(5000)); + int calls = 0; + Assert.Throws(() => executor.Invoke(() => ++calls, lockTimeoutMs: 10)); + Assert.Equal(0, calls); + Assert.False(executor.IsUnavailable); + } finally { + release.Set(); + Assert.Equal(1, await first); + } + Assert.Equal(2, executor.Invoke(() => 2)); + } + + [Fact] + public void NativeErrorsAreUnwrappedAndDoNotPoisonTheExecutor() { + var executor = new NativeCallExecutor(); + var failure = new InvalidOperationException("native error"); + Assert.Same(failure, Assert.Throws(() => executor.Invoke(() => throw failure))); + Assert.False(executor.IsUnavailable); + Assert.Equal(42, executor.Invoke(() => 42)); + } +} diff --git a/Tests/PluginIntegration/CameraDriverSimulatorTests.cs b/Tests/PluginIntegration/CameraDriverSimulatorTests.cs index 03fe733..cf72d91 100644 --- a/Tests/PluginIntegration/CameraDriverSimulatorTests.cs +++ b/Tests/PluginIntegration/CameraDriverSimulatorTests.cs @@ -1,4 +1,5 @@ using System; +using System.IO; using System.Linq; using System.Threading; using System.Threading.Tasks; @@ -239,6 +240,7 @@ public async Task RawBackendImageStillUsesExistingArwFactoryPath() { using var test = new Fixture(); byte[] bytes = { 1, 2, 3, 4 }; var backend = new Mock(); + backend.Setup(x => x.IsOpen(It.IsAny())).Returns(true); backend.Setup(x => x.OpenCamera(It.IsAny())).Returns(test.Backend.OpenCamera(SimulatedSonyCameraBackend.DeviceId)); backend.Setup(x => x.GetLastImage()).Returns(new SonyExposureFrame { RawBytes = bytes }); var expected = new RAWExposureData(new Mock().Object, bytes, "arw", 14, @@ -260,4 +262,67 @@ public async Task RawBackendImageStillUsesExistingArwFactoryPath() { driver.Disconnect(); } } + + [Fact] + public async Task ArwTemperatureReachesBothCameraAndImageAndClearsWhenUnavailable() { + using var test = new Fixture(); + byte[] raw = File.ReadAllBytes(Path.Combine(AppContext.BaseDirectory, "Fixtures", "a7iii-metadata.bin")); + var backend = new Mock(); + backend.Setup(x => x.IsOpen(It.IsAny())).Returns(true); + backend.Setup(x => x.OpenCamera(It.IsAny())).Returns(test.Backend.OpenCamera(SimulatedSonyCameraBackend.DeviceId)); + backend.Setup(x => x.GetLastImage()).Returns(() => new SonyExposureFrame { RawBytes = raw }); + ImageMetaData lastMetadata = null; + test.ExposureFactory.Setup(x => x.CreateRAWExposureData(It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny(), It.IsAny())) + .Callback((RawConverterEnum converter, byte[] bytes, string type, int bits, ImageMetaData metadata) => lastMetadata = metadata); + var driver = new CameraDriver(test.Profile.Object, test.ExposureFactory.Object, + test.Backend.Cameras().Single(), test.Options, backend.Object); + try { + Assert.True(await driver.Connect(CancellationToken.None)); + Assert.True(double.IsNaN(driver.Temperature)); + await driver.DownloadExposure(CancellationToken.None); + Assert.Equal(28, driver.Temperature); + Assert.Equal(28, lastMetadata.Camera.Temperature); + Assert.True(await driver.Connect(CancellationToken.None)); + Assert.Equal(28, driver.Temperature); + raw = new byte[] { 1, 2, 3 }; + await driver.DownloadExposure(CancellationToken.None); + Assert.True(double.IsNaN(driver.Temperature)); + Assert.True(double.IsNaN(lastMetadata.Camera.Temperature)); + raw = File.ReadAllBytes(Path.Combine(AppContext.BaseDirectory, "Fixtures", "a7iii-metadata.bin")); + await driver.DownloadExposure(CancellationToken.None); + driver.Disconnect(); + Assert.True(double.IsNaN(driver.Temperature)); + Assert.True(await driver.Connect(CancellationToken.None)); + Assert.True(double.IsNaN(driver.Temperature)); + } finally { + driver.Disconnect(); + } + } + + [Fact] + public async Task GainListFiltersAutoAndMultiFrameValuesAndSortsManualIsos() { + using var test = new Fixture(); + var options = new System.Collections.Generic.List(); + foreach (uint value in new uint[] { 0x00FFFFFF, 800, 100, 0x01000064, 0, 200, 100 }) { + options.Add(new Sony.PropertyValueOption { Value = value, Name = value.ToString() }); + } + var camera = new Sony.SonyCameraInfo(1, new Sony.DeviceInfo(), new Sony.CameraInfo(), + new System.Collections.Generic.List { + new Sony.PropertyInfo(new Sony.PropertyDescriptor { Id = 0xFFFE, ValueCount = (uint)options.Count }, options) + }); + var backend = new Mock(); + backend.Setup(x => x.IsOpen(It.IsAny())).Returns(true); + backend.Setup(x => x.OpenCamera(It.IsAny())).Returns(camera); + var driver = new CameraDriver(test.Profile.Object, test.ExposureFactory.Object, + test.Backend.Cameras().Single(), test.Options, backend.Object); + try { + Assert.True(await driver.Connect(CancellationToken.None)); + Assert.Equal(new[] { 100, 200, 800 }, driver.Gains); + Assert.Equal(100, driver.GainMin); + Assert.Equal(800, driver.GainMax); + } finally { + driver.Disconnect(); + } + } } diff --git a/Tests/PluginIntegration/PluginIntegration.Tests.csproj b/Tests/PluginIntegration/PluginIntegration.Tests.csproj index b47b219..4dab238 100644 --- a/Tests/PluginIntegration/PluginIntegration.Tests.csproj +++ b/Tests/PluginIntegration/PluginIntegration.Tests.csproj @@ -14,5 +14,6 @@ + From fdd3bb72795b7c079aca1793a07b64f39d1795ca Mon Sep 17 00:00:00 2001 From: Lucas Lepski Date: Sat, 17 Oct 2026 13:21:11 +0200 Subject: [PATCH 44/45] Prepare community preview release and manual installation guide --- .github/workflows/dotnet.yml | 89 ++++++++++++++++++--------------- CHANGELOG.md | 24 ++++----- INSTALLATION.md | 69 +++++++++++++++++++++++++ Properties/AssemblyInfo.cs | 21 +++++--- README.md | 28 +++++++---- docs/UPSTREAM-REVIEW-1.0.0.5.md | 81 ++++++++++++++++++++++++++++++ 6 files changed, 245 insertions(+), 67 deletions(-) create mode 100644 INSTALLATION.md create mode 100644 docs/UPSTREAM-REVIEW-1.0.0.5.md diff --git a/.github/workflows/dotnet.yml b/.github/workflows/dotnet.yml index cf7167e..b023fd2 100644 --- a/.github/workflows/dotnet.yml +++ b/.github/workflows/dotnet.yml @@ -1,55 +1,66 @@ -# This workflow will build a .NET project -# For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-net - -name: Build Driver DLL +name: Build and test Sony Camera Plugin on: push: - branches: [ "master" ] + branches: [master] pull_request: - branches: [ "master" ] + branches: [master] + workflow_dispatch: + +permissions: + contents: read jobs: build: runs-on: windows-latest - + timeout-minutes: 20 steps: - - uses: actions/checkout@v3 -# - name: Setup .NET -# uses: actions/setup-dotnet@v3 -# with: -# dotnet-version: 4.8.1 - - - name: Setup MSBuild - uses: microsoft/setup-msbuild@v1.1 - - - name: Setup NuGet - uses: nuget/setup-nuget@v1.2.0 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0 + with: + dotnet-version: 8.0.x - - name: Navigate to Workspace - run: cd $GITHUB_WORKSPACE + - name: Test capture, simulator and native call policies + run: dotnet test Tests/CaptureController.Tests.csproj --configuration Release --logger "trx;LogFileName=capture.trx" - - name: Create Build Directory - run: mkdir _build + - name: Test Sony camera temperature metadata + run: dotnet test Tests/Temperature/Temperature.Tests.csproj --configuration Release --logger "trx;LogFileName=temperature.trx" - - name: Restore Packages - run: nuget restore NINASonyCameraPlugin.sln + - name: Test plugin integration on Windows + run: dotnet test Tests/PluginIntegration/PluginIntegration.Tests.csproj --configuration Release --logger "trx;LogFileName=integration.trx" - - name: Test capture coordination - run: dotnet test Tests/CaptureController.Tests.csproj --configuration Release + - name: Build release plugin + run: dotnet build NINASonyCameraPlugin.sln --configuration Release -p:InstallPluginOnBuild=false - - name: Test plugin with simulated camera - run: dotnet test Tests/PluginIntegration/PluginIntegration.Tests.csproj --configuration Release + - name: Package manual installation release + shell: pwsh + run: | + $dll = Get-Item 'bin/Release/net8.0-windows10.0.17763.0/NINA.RetroKiwi.Plugin.SonyCamera.dll' + $version = $dll.VersionInfo.FileVersion + $package = New-Item -ItemType Directory -Path 'release-package' + $assets = New-Item -ItemType Directory -Path 'release-assets' + Copy-Item $dll.FullName $package.FullName + Copy-Item 'LICENSE.txt', 'INSTALLATION.md' $package.FullName + "Original author: Doug Henderson`nMaintained by: Lucas Lepski and contributors`nLicense: MPL-2.0`nSource: https://github.com/$env:GITHUB_REPOSITORY/tree/$env:GITHUB_SHA`nBuild: https://github.com/$env:GITHUB_REPOSITORY/actions/runs/$env:GITHUB_RUN_ID`n" | Set-Content "$package/SOURCE.txt" -Encoding utf8 + Compress-Archive -Path "$package/*" -DestinationPath "$assets/SonyCameraPlugin-$version.zip" + Copy-Item $dll.FullName $assets.FullName + $hashes = Get-ChildItem $assets.FullName -File | Sort-Object Name | ForEach-Object { + $hash = (Get-FileHash $_.FullName -Algorithm SHA256).Hash.ToLowerInvariant() + "$hash $($_.Name)" + } + $hashes | Set-Content "$assets/SHA256SUMS.txt" -Encoding ascii - - name: Test Sony camera temperature metadata - run: dotnet test Tests/Temperature/Temperature.Tests.csproj --configuration Release - - - name: Build - run: msbuild.exe NINASonyCameraPlugin.sln /nologo /nr:false /p:DeployOnBuild=true /p:DeployDefaultTarget=WebPublish /p:WebPublishMethod=FileSystem /p:DeleteExistingFiles=True /p:platform="Any CPU" /p:configuration="Release" /p:PublishUrl="../_build" + - name: Upload verified release files + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: SonyCamera-Release + path: release-assets/* + if-no-files-found: error - - name: Upload SonyCamera DLL - uses: actions/upload-artifact@v4 - with: - name: SonyCamera-DLL - path: | - **/bin/Release/**/NINA.RetroKiwi.Plugin.SonyCamera.dll + - name: Upload test results + if: always() + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: SonyCamera-TestResults + path: Tests/**/TestResults/*.trx + if-no-files-found: warn diff --git a/CHANGELOG.md b/CHANGELOG.md index b515dc5..666334c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,21 +1,21 @@ # Sony Camera Plugin -## Unreleased +## 1.0.0.5 — community preview -* Added an opt-in Sony Camera Simulator for development without the native driver or hardware. -* Added automated tests of camera discovery, profile settings, image delivery, and cancellation through the actual plugin driver. -* Preserved active camera connections when connect is called repeatedly or concurrently, and honored cancelled connection requests. +First maintained release from [Lucas Lepski’s fork](https://github.com/shurkanTwo/NINASonyCameraPlugin), preserving Doug Henderson’s original authorship and MPL-2.0. Install manually using [INSTALLATION.md](INSTALLATION.md). This preview is not distributed through NINA’s plugin manager. -## 1.0.0.5 +- Integrate current capture start/abort coordination and an opt-in managed Sony camera simulator with full-plugin integration tests (Lucas Lepski, upstream #10/#15). +- Report the latest downloaded ARW’s Sony camera temperature in NINA and that image’s metadata; missing readings remain unavailable (Lucas Lepski, #18 / issue #1). +- Release native image and metadata allocations after copying, including error paths, avoiding a per-frame unmanaged memory leak (Robert Hasson, #14). +- Retry and verify shutter changes for back-to-back exposures. A readable shutter mismatch now fails before capture rather than silently using the previous exposure. Exclude AUTO/Multi Frame NR from gain choices and fix native driver version comparisons (adapted from Robert Hasson’s #13 / issue #5). +- Serialize native camera/lens calls with bounded waits. Execution timeout blocks further native access until NINA restarts. Keep native cancellation off by default and always soft-cancel during image readout/processing (adapted #13, preserving current #10 safeguards / issue #4). +- Return focuser capability information without throwing (Christian Palm, #11), improve focuser state cleanup and notifications (Robert Hasson, #13). +- Document lens-learning tools and camera SD-card saving (Lucas Lepski, #16/#17 / issues #9/#3). +- Add Windows-tested manual-install ZIP/DLL packaging, checksums, source/build provenance, and installation/rollback instructions. -* Added a profile-specific native cancel option, defaulted to off to avoid abort-related crashes. -* Serialized native capture starts and status/cancel calls; busy or unknown states reject new exposures without cancelling the previous capture. -* Made aborts idempotent, kept their lock wait bounded, and prevented delayed aborts from cancelling a newer exposure. -* Reported cancelled and failed captures correctly rather than downloading a stale image. -* Added automated capture coordination regression tests. +Reviewed but deferred: experimental USB auto-reconnection, retrying a still-running timed-out native call, forced cancellation after repeated busy starts, and closed WIP #12 video intervals. Existing NINA 3.2/empty-ISO fixes from 1.0.0.4 (including @naixx’s contribution) remain included. See the [full review and issue coverage](docs/UPSTREAM-REVIEW-1.0.0.5.md). -## Unreleased -* Report the latest downloaded ARW's Sony camera temperature in NINA and supply the same reading to that image's metadata. Missing or invalid readings remain unavailable; reconnecting clears the cached value. +Validation uses automated tests and Windows CI. Physical camera/USB operation for this combined release has not been verified. Native access violations, newer camera support, and complete NINA Sky Flat behavior are not claimed fixed. Existing legacy-package NU1701 restore warnings remain. ## 1.0.0.4 * Added the `UpdateSubSampleArea` implementation and bumped the minimum NINA version so the plugin loads in 3.2. diff --git a/INSTALLATION.md b/INSTALLATION.md new file mode 100644 index 0000000..e9bf987 --- /dev/null +++ b/INSTALLATION.md @@ -0,0 +1,69 @@ +# Manual installation of the maintained Sony Camera Plugin + +These instructions install the community-maintained build from +[shurkanTwo/NINASonyCameraPlugin](https://github.com/shurkanTwo/NINASonyCameraPlugin/releases). +The original plugin was developed by **Doug Henderson**. This fork is maintained +by **Lucas Lepski and contributors** while shared upstream maintenance is being discussed. + +The first community release, **1.0.0.5**, is a preview for manual testing. Windows +builds and automated tests are checked; physical Sony camera/USB operation has +not been verified for this combined release. See the release notes for scope. + +## Requirements + +- Windows 10 1809 or newer and N.I.N.A. 3.2 or newer (minimum 3.2.0.3001). +- Install the [Sony ASCOM driver package](https://github.com/dougforpres/ASCOMSonyCameraDriver/releases), version 1.0.1.17 or newer; 1.0.1.21 is the currently documented package. The native `SonyMTPCamera.dll` is a separate dependency and is not bundled here. You select the direct **Sony** camera in NINA; the package supplies the shared native components. +- Set the camera to PC Remote/MTP, manual exposure and focus, and RAW output. Close other applications that control it. + +## Install or replace an existing version + +1. Download **`SonyCameraPlugin-1.0.0.5.zip`** from the [1.0.0.5 release](https://github.com/shurkanTwo/NINASonyCameraPlugin/releases/tag/1.0.0.5). Choose the release asset, rather than GitHub's automatic “Source code” archives. Extract it to a temporary folder. The plugin is `NINA.RetroKiwi.Plugin.SonyCamera.dll`. +2. Disconnect the camera and focuser in NINA, then **close every NINA window** before changing plugin files. +3. Press **Win+R**, enter `%LOCALAPPDATA%\NINA\Plugins`, and open the plugin-version folder used by your NINA installation. NINA 3.x installations commonly use **`3.0.0`**: this folder tracks plugin compatibility, so it need not match the NINA version in About. If several version folders exist, use the one in NINA's startup log (`%LOCALAPPDATA%\NINA\Logs`); look for the path from which the Sony plugin was loaded. +4. If Sony Camera Plugin is already installed, locate its existing `NINA.RetroKiwi.Plugin.SonyCamera.dll` and copy the entire containing plugin folder **outside `%LOCALAPPDATA%\NINA\Plugins`** as a backup. Replace the DLL **in that existing folder**, even if the folder name differs from the example. Do not create another plugin folder containing a second copy. Keep the rest of the existing folder's files. +5. For a fresh installation, create a folder named **`Sony Camera Plugin`** in the active plugin-version folder. The typical complete path is: + + ```text + %LOCALAPPDATA%\NINA\Plugins\3.0.0\Sony Camera Plugin\NINA.RetroKiwi.Plugin.SonyCamera.dll + ``` + +6. Copy the extracted DLL into the existing or newly created folder. The ZIP also contains the license, source reference, and these instructions; you can keep those files alongside it. It contains no replacement NINA runtime libraries or native Sony driver. +7. If Windows shows an **Unblock** checkbox in the downloaded ZIP or DLL's Properties, unblock the file. Alternatively run `Unblock-File` in PowerShell on that downloaded ZIP/DLL before copying/extracting it. +8. Restart NINA. In its plugin manager, confirm **Sony Camera Plugin 1.0.0.5** is loaded; the description identifies the community-maintained build. Select the Sony camera in Equipment and connect it. +9. Leave **Enable native cancel** disabled initially. Try one short exposure and check the downloaded image, ISO and exposure before using the build for a sequence. Temperature appears only after downloading an ARW that contains a supported Sony temperature reading. + +This is a replacement for the upstream plugin, with the same identifier so existing +profile settings can be retained. **Use only one copy in the active plugin folder.** +This fork is currently distributed manually. The NINA plugin manager still uses the +upstream release source; it will not automatically install updates from this fork. +Check this fork's releases for newer builds. If you install an upstream version +through the plugin manager later, check which version is loaded afterwards. + +## Verify the download + +The release includes **`SHA256SUMS.txt`** for its ZIP and standalone DLL. In +PowerShell, calculate a downloaded file's hash and compare it to the matching entry: + +```powershell +Get-FileHash "$env:USERPROFILE\Downloads\SonyCameraPlugin-1.0.0.5.zip" -Algorithm SHA256 +``` + +## Roll back + +Disconnect equipment and close NINA. Replace the maintained DLL with the DLL from +your backup in the same plugin folder, or remove this fork's copy and reinstall +Sony Camera Plugin through NINA's plugin manager. Keep the backup outside the +plugins directory to avoid duplicate loading. Restart NINA and confirm the loaded +version. The separate Sony driver installation and your images do not need changing. + +## Troubleshooting + +- **Plugin does not load:** check the version requirement, native driver installation, Windows file blocking, duplicate DLL copies, and NINA's startup log. Verify the folder used by this specific NINA installation. +- **Missing ISO choices:** use the native driver's `SonyCameraInfo64.exe /s` learning/scanning procedure. This release offers manual ISO choices; AUTO and Multi Frame NR modes are excluded. +- **Cannot set the requested exposure:** the shutter change is retried before capture. If it cannot be verified, the exposure fails rather than being silently captured at the previous shutter speed. A camera without learnt shutter settings still uses its manually selected speed; learn its settings before using automatic flats. +- **Abort appears to leave the camera busy:** soft cancellation stops NINA's wait while the camera finishes its current exposure. Wait for it to finish before starting another. Reading/processing is always soft-cancelled. +- **Driver stopped responding:** close NINA, power-cycle the camera, and restart NINA before reconnecting. The plugin deliberately blocks further native calls after an execution timeout. USB unplugging and power-off while connected can still cause native crashes; disconnect in NINA first. + +Report problems with this build in [this fork's issue tracker](https://github.com/shurkanTwo/NINASonyCameraPlugin/issues). +Include camera/lens model, NINA/plugin/native-driver versions, and relevant logs +with private paths and device identifiers removed. diff --git a/Properties/AssemblyInfo.cs b/Properties/AssemblyInfo.cs index b4e7953..859cc54 100644 --- a/Properties/AssemblyInfo.cs +++ b/Properties/AssemblyInfo.cs @@ -1,4 +1,4 @@ -using Microsoft.VisualBasic; +using Microsoft.VisualBasic; using System.Reflection; using System.Runtime.CompilerServices; using System.Runtime.InteropServices; @@ -14,7 +14,7 @@ // [MANDATORY] The name of your plugin [assembly: AssemblyTitle("Sony Camera Plugin")] // [MANDATORY] A short description of your plugin -[assembly: AssemblyDescription("Support for Sony Cameras (and optionally, Camera controlled lenses)")] +[assembly: AssemblyDescription("Community-maintained Sony camera and lens support; originally developed by Doug Henderson")] // The following attributes are not required for the plugin per se, but are required by the official manifest meta data @@ -32,18 +32,18 @@ // The url to the license [assembly: AssemblyMetadata("LicenseURL", "https://www.mozilla.org/en-US/MPL/2.0/")] // The repository where your pluggin is hosted -[assembly: AssemblyMetadata("Repository", "https://github.com/dougforpres/NINASonyCameraPlugin")] +[assembly: AssemblyMetadata("Repository", "https://github.com/shurkanTwo/NINASonyCameraPlugin")] // The following attributes are optional for the official manifest meta data //[Optional] Your plugin homepage URL - omit if not applicaple -[assembly: AssemblyMetadata("Homepage", "https://retro.kiwi")] +[assembly: AssemblyMetadata("Homepage", "https://github.com/shurkanTwo/NINASonyCameraPlugin")] //[Optional] Common tags that quickly describe your plugin [assembly: AssemblyMetadata("Tags", "Sony,Camera")] //[Optional] A link that will show a log of all changes in between your plugin's versions -[assembly: AssemblyMetadata("ChangelogURL", "https://github.com/dougforpres/NINASonyCameraPlugin/blob/master/CHANGELOG.md")] +[assembly: AssemblyMetadata("ChangelogURL", "https://github.com/shurkanTwo/NINASonyCameraPlugin/blob/master/CHANGELOG.md")] //[Optional] The url to a featured logo that will be displayed in the plugin list next to the name [assembly: AssemblyMetadata("FeaturedImageURL", "https://retro.kiwi/images/NINAPluginLogoV2.jpg")] @@ -52,7 +52,11 @@ //[Optional] An additional url to an example example screenshot of your plugin in action [assembly: AssemblyMetadata("AltScreenshotURL", "")] //[Optional] An in-depth description of your plugin -[assembly: AssemblyMetadata("LongDescription", @"## Camera +[assembly: AssemblyMetadata("LongDescription", @"Community-maintained fork of [Doug Henderson’s original plugin](https://github.com/dougforpres/NINASonyCameraPlugin), maintained by Lucas Lepski and contributors. + +[Manual installation](https://github.com/shurkanTwo/NINASonyCameraPlugin/blob/master/INSTALLATION.md). + +## Camera * Support for many different Sony Cameras. [See the list](https://github.com/dougforpres/ASCOMSonyCameraDriver/wiki/Supported-Cameras) * Provides LiveView function if the camera supports it * Images are downloaded in Sony's native ARW format (this is a N.I.N.A feature) so full meta-data is available @@ -65,7 +69,7 @@ *Note*: This addon uses the core (non-ASCOM) files present in the Sony ASCOM driver v1.0.1.17 and later, which must be installed for this addon to start up correctly. You can find the Sony ASCOM driver at [https://github.com/dougforpres/ASCOMSonyCameraDriver/releases](https://github.com/dougforpres/ASCOMSonyCameraDriver/releases). ## Support -The best way to get support is via email. You can find my email address by following the *Homepage* link above, or by looking at the bottom of the [ASCOM driver Troubleshooting page](https://github.com/dougforpres/ASCOMSonyCameraDriver/wiki/Troubleshooting). +Report problems with this fork in [its issue tracker](https://github.com/shurkanTwo/NINASonyCameraPlugin/issues). Doug Henderson remains the original author; his homepage is [retro.kiwi](https://retro.kiwi). For many bugs/problems I'm going to ask you for a driver log file, [here's a link to how you would do that](https://github.com/dougforpres/ASCOMSonyCameraDriver/wiki/Troubleshooting#the-driver-dll-log). @@ -75,6 +79,9 @@ ## Contributors * Lucas Lepski [@ShurkanTwo](https://github.com/shurkanTwo) +* Robert Hasson [@totoantibes](https://github.com/totoantibes) +* Christian Palm [@christian-photo](https://github.com/christian-photo) +* [@naixx](https://github.com/naixx) ")] // Setting ComVisible to false makes the types in this assembly not visible diff --git a/README.md b/README.md index 4751e18..14b173e 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,9 @@ # Sony Camera Plugin +This is a **community-maintained fork** of [Doug Henderson’s Sony Camera Plugin](https://github.com/dougforpres/NINASonyCameraPlugin). Doug remains the original author. Lucas Lepski ([@shurkanTwo](https://github.com/shurkanTwo)) maintains these releases, incorporating contributions from the upstream community while we await a reply from Doug about shared maintenance. + +**[Download maintained releases](https://github.com/shurkanTwo/NINASonyCameraPlugin/releases)** · **[Manual installation and rollback](INSTALLATION.md)** · **[Report a problem with this fork](https://github.com/shurkanTwo/NINASonyCameraPlugin/issues)** + Native NINA plugin for Sony mirrorless/DSLR bodies over the Sony MTP interface, so you can control the camera directly from NINA without ASCOM. ## Features @@ -7,7 +11,7 @@ Native NINA plugin for Sony mirrorless/DSLR bodies over the Sony MTP interface, - Supports many Sony cameras (supported list: https://github.com/dougforpres/ASCOMSonyCameraDriver/wiki/Supported-Cameras) - Connects Sony bodies and surfaces their model name in NINA - LiveView streaming (16-bit monochrome, where supported) plus normal captures saved as ARW through NINA's RAW converter so full metadata is preserved -- Gain dropdown populated with actual ISO values (including Auto when present); ISO is controllable via the Gain property +- Gain dropdown populated with supported manual ISO values; AUTO and Multi Frame NR values are excluded - Battery level reporting; exposes camera pixel size and resolution - Camera-controlled lenses exposed as a focuser inside NINA (supported lenses: https://github.com/dougforpres/ASCOMSonyCameraDriver/wiki/Supported-Lenses) - Handles cameras that do not expose ISO options until they are "learned" in-camera @@ -21,8 +25,9 @@ Native NINA plugin for Sony mirrorless/DSLR bodies over the Sony MTP interface, ## Installation -Install via the N.I.N.A. plugin manager or manually download and copy the DLL file into `%LOCALAPPDATA%\\NINA\\Plugins\\..\\Sony Camera Plugin\\` from: -https://github.com/dougforpres/NINASonyCameraPlugin/releases +Download a maintained build from [this fork’s releases](https://github.com/shurkanTwo/NINASonyCameraPlugin/releases) and follow the **[manual installation instructions](INSTALLATION.md)**. The first community release is a preview for manual testing. It replaces the upstream plugin; install one copy only. + +The N.I.N.A. plugin manager currently distributes the upstream release. This fork has not been submitted as its update source. ## Usage notes @@ -32,7 +37,9 @@ https://github.com/dougforpres/NINASonyCameraPlugin/releases - For exposures <= 30s the driver chooses the nearest built-in shutter speed; longer exposures fall back to Bulb. - To keep a copy on the camera's memory card as well as sending it to NINA, use the camera's **PC Remote Settings → Still Img. Save Dest. → PC+Camera** setting (see below). - Powering off/unplugging the camera while connected can crash NINA via Windows' MTP stack (PortableDeviceApi.dll access violation); disconnect in NINA first to avoid it. -- “Enable native cancel” is off by default and saved per NINA profile. With it off, abort cancels the wait and discards the exposure, while the camera finishes capturing; another exposure can start once the camera finishes. Enabling it allows native cancellation during aborts, which may crash NINA on some camera models and Windows drivers. New exposure requests never cancel an existing capture. +- Native driver calls are serialized across camera and lens operations. If a native call stops responding, restart NINA and power-cycle the camera before reconnecting. Automatic USB reconnection is not included. +- Shutter changes are read back and retried before capture. If a readable shutter remains outside supported-step rounding after the retry budget, capture fails instead of silently using the old exposure. Cameras without a learnt shutter list retain their manually selected shutter setting; learn that list before using automatic flats. Sky Flat behavior is not claimed to be resolved. +- “Enable native cancel” is off by default and saved per NINA profile. With it off, abort cancels the wait and discards the exposure, while the camera finishes capturing; another exposure can start once the camera finishes. Enabling it allows native cancellation during aborts before image readout/processing, which may crash NINA on some camera models and Windows drivers. New exposure requests never cancel an existing capture. ## Simulator development @@ -76,7 +83,7 @@ Run the actual plugin integration tests on Windows: dotnet test Tests/PluginIntegration/PluginIntegration.Tests.csproj --configuration Release ``` -The integration tests use the production camera driver, NINA profile settings, and NINA exposure objects with the simulated backend. A manual clock advances long exposures immediately. Both suites run in Windows CI; the portable suite also runs on Linux. On Linux, cross-compile with `dotnet build NINASonyCameraPlugin.csproj --configuration Release -p:EnableWindowsTargeting=true`. +The integration tests use the production camera driver, NINA profile settings, and NINA exposure objects with the simulated backend. A manual clock advances long exposures immediately. All three suites, including `dotnet test Tests/Temperature/Temperature.Tests.csproj --configuration Release`, run in Windows CI; the portable suite also runs on Linux. On Linux, cross-compile with `dotnet build NINASonyCameraPlugin.csproj --configuration Release -p:EnableWindowsTargeting=true`. ## Learning an unsupported lens @@ -106,7 +113,8 @@ Menu locations and labels vary by Sony model; consult your camera's manual if th ## Support -- Best contact is email via the Homepage link or the ASCOM driver troubleshooting page footer. +- For this maintained fork, [open an issue here](https://github.com/shurkanTwo/NINASonyCameraPlugin/issues). Include the NINA version, plugin version, camera model, native Sony driver version, and relevant logs. Remove private paths and device identifiers before sharing logs. +- Doug’s [original project](https://github.com/dougforpres/NINASonyCameraPlugin) and [homepage](https://retro.kiwi) remain linked for attribution and upstream driver information. Support for changes in this fork is handled here. - You may be asked for a driver log when reporting bugs: https://github.com/dougforpres/ASCOMSonyCameraDriver/wiki/Troubleshooting#the-driver-dll-log - Help links: - My camera is not supported (https://github.com/dougforpres/ASCOMSonyCameraDriver/wiki/Installation#if-you-dont-have-a-supported-camera-model); @@ -118,8 +126,10 @@ Menu locations and labels vary by Sony model; consult your camera's manual if th - Version: 1.0.0.5 - Author: Doug Henderson -- Contributors: Lucas Lepski [@ShurkanTwo](https://github.com/shurkanTwo) +- Maintainer of this fork: Lucas Lepski [@shurkanTwo](https://github.com/shurkanTwo) +- Contributors include Robert Hasson [@totoantibes](https://github.com/totoantibes), Christian Palm [@christian-photo](https://github.com/christian-photo), and [@naixx](https://github.com/naixx); original commit authorship is preserved. +- Review and release scope: [upstream work reviewed for 1.0.0.5](docs/UPSTREAM-REVIEW-1.0.0.5.md) - Minimum NINA version: 3.2.0.3001 - License: MPL-2.0 (https://www.mozilla.org/en-US/MPL/2.0/) -- Homepage: https://retro.kiwi -- Changelog: https://github.com/dougforpres/NINASonyCameraPlugin/blob/master/CHANGELOG.md +- Original author’s homepage: https://retro.kiwi +- Changelog: https://github.com/shurkanTwo/NINASonyCameraPlugin/blob/master/CHANGELOG.md diff --git a/docs/UPSTREAM-REVIEW-1.0.0.5.md b/docs/UPSTREAM-REVIEW-1.0.0.5.md new file mode 100644 index 0000000..82fa389 --- /dev/null +++ b/docs/UPSTREAM-REVIEW-1.0.0.5.md @@ -0,0 +1,81 @@ +# Upstream review for community preview 1.0.0.5 + +Reviewed against upstream `master`, commit +`8bd9042bcd477fd028c2aa2adfe34260192de936` (upstream release 1.0.0.4). +This fork preserves Doug Henderson's authorship, copyright notices, MPL-2.0, +and original commit history. Maintenance does not imply Doug has reviewed or +endorsed this combined build. Upstream issues/PRs remain under upstream control. + +## Included work + +- [#10](https://github.com/dougforpres/NINASonyCameraPlugin/pull/10), Lucas Lepski: current capture coordination, idempotent cancellation, and per-profile native cancel opt-in. The prerequisite is included through #15, retaining its original commits. Additional regression checks now keep Reading/Processing soft-cancelled even when native cancel is enabled. +- [#11](https://github.com/dougforpres/NINASonyCameraPlugin/pull/11), Christian Palm: focuser capability checks and unsupported temperature compensation no longer throw. +- [#14](https://github.com/dougforpres/NINASonyCameraPlugin/pull/14), Robert Hasson: release native image/metadata buffers in `finally` after copying, including errors. Native `DLL.cpp` was checked: `exportBytes` allocates with `CoTaskMemAlloc`, transferring the exported buffer to the caller. Capture COMPLETE exports a frame and cleans up the native capture; copying inline on the first COMPLETE result is required. +- [#15](https://github.com/dougforpres/NINASonyCameraPlugin/pull/15), Lucas Lepski: opt-in managed camera simulator, portable capture tests, and actual-plugin Windows integration tests. +- [#16](https://github.com/dougforpres/NINASonyCameraPlugin/pull/16), Lucas Lepski: obtaining and launching a configured lens-learning tool. +- [#17](https://github.com/dougforpres/NINASonyCameraPlugin/pull/17), Lucas Lepski: keeping RAW copies on the camera memory card. +- [#18](https://github.com/dougforpres/NINASonyCameraPlugin/pull/18), Lucas Lepski: bounded ARW camera-temperature extraction with independent metadata fixtures; merged with the simulator's connection/download paths. Integration checks verify that NINA's camera property and image metadata receive the same value and clear unavailable readings. + +## Adapted stability work from Robert Hasson's #13 + +[#13](https://github.com/dougforpres/NINASonyCameraPlugin/pull/13) includes older +copies of #10/#11. It was reviewed in full and adapted rather than merged over +the newer capture controller. Robert retains credit for the exposure readback, +ISO cleanup, native serialization/timeout, and focuser hardening work. + +Included: + +- Serialize all native camera and focuser calls through a shared executor; bound native operations and lock waits. A native execution timeout permanently blocks further native calls, including close, rescan and reconnect, until NINA restarts. A late result cannot make `IsOpen` or `CameraInfo` report a usable camera. +- Keep the COMPLETE status/image copy inline. Consume the managed image once and discard leftover frames before the next capture and at close/open. +- Read back and retry shutter changes with a 12-second retry budget, within the surrounding 60-second native-start watchdog. The native driver picks by absolute shutter-time distance; a 1.17 ratio accepts the arithmetic midpoint of the measured 4/3 shutter gaps (worst half-step ratio 7/6) while rejecting the measured smallest full step, 1.20. A readable mismatch, failed retry, or invalid readback fails before starting capture. A missing learnt shutter list retains the previous manual-setting behavior with a warning. +- Offer sorted, distinct manual ISO values, excluding zero, AUTO (`0x00FFFFFF`), and Multi Frame NR values. Gain bounds use the same filtered options. +- Correct native DLL version component order and remove the redundant unresolved ReachFramework reference. +- Preserve focuser state notifications, reset moving state on failures, and avoid queueing polled reads indefinitely behind another operation. + +Deferred after review: + +- Clearing the “driver wedged” flag to retry while a prior timed-out native worker is still running. That permits overlapping native operations and defeats serialization. +- Automatic forced native cancellation after two busy starts. A long capture/readout may be valid; forced recovery bypassed the native-cancel preference and readout guard. +- Experimental USB removal notifications and automatic reconnection. The PR itself reports remaining abnormal exits, requires related native-driver work, and has not established compatibility with the published driver. +- Treating any intermediate shutter movement as proof that the requested exposure reached its nearest supported step, even after a later error or timeout. + +## Issue coverage + +- [#1 temperature](https://github.com/dougforpres/NINASonyCameraPlugin/issues/1): implemented from #18; temperature is available after a supported ARW is downloaded, rather than continuously. +- [#2 A6600 connection/empty ISO list](https://github.com/dougforpres/NINASonyCameraPlugin/issues/2): already fixed in 1.0.0.4; retained. Native DLL version checking and ISO filtering are improved here. No new camera model support is claimed. +- [#3 SD card copies](https://github.com/dougforpres/NINASonyCameraPlugin/issues/3): documented camera-side setting. +- [#4 cancel crashes](https://github.com/dougforpres/NINASonyCameraPlugin/issues/4): mitigations and regression checks included; native DLL/MTP access violations cannot be claimed fixed by managed tests. +- [#5 flat exposure changes](https://github.com/dougforpres/NINASonyCameraPlugin/issues/5): shutter application is checked/retried, and AUTO ISO is excluded. NINA Sky Flat behavior remains unverified; this release does not claim a complete Sky Flat fix. +- [#6 NINA 3.2 interface](https://github.com/dougforpres/NINASonyCameraPlugin/issues/6): already resolved by merged #7/#8 in 1.0.0.4, retained here. Original contributors include @naixx and Lucas Lepski. +- [#9 lens learning](https://github.com/dougforpres/NINASonyCameraPlugin/issues/9): tool location and launch requirements documented; the installer does not include a ready-to-use lens-learning executable. + +Closed WIP [#12 video intervals](https://github.com/dougforpres/NINASonyCameraPlugin/pull/12) +is recorded but not revived as a finished feature in this maintenance release. + +## Standards + +The initial independent standards review found no non-tooling hard violations of +`.editorconfig`. It identified timeout/retry overlap, unbounded native cleanup, +unsafe forced cancel, old prerequisite regressions, and accepting failed shutter +application as release blockers. Those paths were replaced or deferred as above. + +## Spec + +The independent spec review compared each open PR to its body and linked issue. +The same native-call and shutter paths contradicted the PR's safety/correctness +promises. The release keeps the tested current capture controller and publishes +explicit limits instead of adopting those paths. + +## Validation and limits + +The repository's GitHub Actions build runs portable capture/simulator/native-call +policy tests, temperature metadata tests, actual-plugin integration tests on +Windows, and a Release build before packaging a manual-install ZIP, DLL and +SHA-256 checksums. The release links its successful build run and exact source +commit. Existing NU1701 warnings from legacy dependencies remain. + +Automated native-call policy tests use controlled callbacks; simulator tests do +not execute the Sony DLL, USB/MTP, LiveView, or physical lenses. The metadata fixture +is not a complete decodable ARW. No physical-camera validation of this combined +release has been performed. Robert's reported ILCE-7S field results apply to his +build, not automatically to this adapted integration. From b0be2f100149d1a88cb5d8bf12e4a31fe581157f Mon Sep 17 00:00:00 2001 From: Lucas Lepski Date: Sat, 17 Oct 2026 13:23:41 +0200 Subject: [PATCH 45/45] Verify learned Bulb selection and publish completed images atomically --- Drivers/CameraDriver.cs | 3 ++- Drivers/SimulatedSonyCameraBackend.cs | 4 +++- Properties/AssemblyInfo.cs | 2 +- README.md | 2 +- .../ExposureTimeController.cs | 22 ++++++++++++++++--- SonyCameraPluginNative/Sony.cs | 10 +++++++-- SonyCameraPluginNative/SonyMTPCameraDll.cs | 17 +++++++------- Tests/ExposureTimeControllerTests.cs | 15 +++++++++++++ Tests/SimulatedSonyCameraTests.cs | 1 + docs/UPSTREAM-REVIEW-1.0.0.5.md | 2 +- 10 files changed, 59 insertions(+), 19 deletions(-) diff --git a/Drivers/CameraDriver.cs b/Drivers/CameraDriver.cs index dd04287..4cbc9d2 100644 --- a/Drivers/CameraDriver.cs +++ b/Drivers/CameraDriver.cs @@ -358,7 +358,8 @@ public string DisplayName { public bool Connected { get { - return _camera != null && _driver.IsOpen(_camera.Handle); + SonyCameraInfo camera = _camera; + return camera != null && _driver.IsOpen(camera.Handle); } } diff --git a/Drivers/SimulatedSonyCameraBackend.cs b/Drivers/SimulatedSonyCameraBackend.cs index bcff5b7..a104ae5 100644 --- a/Drivers/SimulatedSonyCameraBackend.cs +++ b/Drivers/SimulatedSonyCameraBackend.cs @@ -177,7 +177,9 @@ public SonyExposureFrame GetLastImage() { if (!connected || lastImage == null) { throw new InvalidOperationException("No completed simulated exposure is available."); } - return lastImage; + SonyExposureFrame image = lastImage; + lastImage = null; + return image; } } diff --git a/Properties/AssemblyInfo.cs b/Properties/AssemblyInfo.cs index 859cc54..280982b 100644 --- a/Properties/AssemblyInfo.cs +++ b/Properties/AssemblyInfo.cs @@ -61,7 +61,7 @@ [Manual installation](https://github.com/shurkanTwo/NINASonyCameraPlugin/blob/ma * Provides LiveView function if the camera supports it * Images are downloaded in Sony's native ARW format (this is a N.I.N.A feature) so full meta-data is available * ISO is controllable via Gain property -* If the exposure time is 30 seconds or below the driver will select the nearest built-in exposure time, otherwise BULB mode will be used +* The driver selects the nearest learnt shutter speed, or verified BULB mode for longer exposures ## Focuser * Support for over 15 different camera-mounted lenses. [See the list](https://github.com/dougforpres/ASCOMSonyCameraDriver/wiki/Supported-Lenses) diff --git a/README.md b/README.md index 14b173e..9de6965 100644 --- a/README.md +++ b/README.md @@ -34,7 +34,7 @@ The N.I.N.A. plugin manager currently distributes the upstream release. This for - ISO/gain list may be empty until the camera has learned ISO options; set an ISO on-camera once if needed. - Temperature shows the **camera temperature from the most recently downloaded ARW**, when Sony records a valid `CameraTemperature` maker note. It updates after each photo and is unavailable before the first photo, after reconnecting, or when the latest image lacks that reading. It is not a live sensor probe, a battery/ambient reading, or a cooling control. The same per-image reading is supplied to NINA's image metadata; the original ARW remains unchanged. - Binning and sub-sampling are not supported; captures use the full sensor frame. -- For exposures <= 30s the driver chooses the nearest built-in shutter speed; longer exposures fall back to Bulb. +- The driver chooses the nearest learnt built-in shutter speed; durations beyond the longest numeric speed use Bulb. The requested mode is verified before capture. - To keep a copy on the camera's memory card as well as sending it to NINA, use the camera's **PC Remote Settings → Still Img. Save Dest. → PC+Camera** setting (see below). - Powering off/unplugging the camera while connected can crash NINA via Windows' MTP stack (PortableDeviceApi.dll access violation); disconnect in NINA first to avoid it. - Native driver calls are serialized across camera and lens operations. If a native call stops responding, restart NINA and power-cycle the camera before reconnecting. Automatic USB reconnection is not included. diff --git a/SonyCameraPluginNative/ExposureTimeController.cs b/SonyCameraPluginNative/ExposureTimeController.cs index 916bc9c..76a2562 100644 --- a/SonyCameraPluginNative/ExposureTimeController.cs +++ b/SonyCameraPluginNative/ExposureTimeController.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Generic; using System.Diagnostics; using System.Threading; @@ -8,12 +9,27 @@ namespace Sony { internal static class ExposureTimeController { private const uint NotSupported = 50; + internal static double LongestNonBulb(IEnumerable shutterSpeeds) { + double longest = 0; + if (shutterSpeeds != null) { + foreach (uint raw in shutterSpeeds) { + uint numerator = raw >> 16; + uint denominator = raw & 0xFFFF; + if (raw != uint.MaxValue && numerator > 0 && denominator > 0) { + longest = Math.Max(longest, (double)numerator / denominator); + } + } + } + return longest > 0 ? longest : 30; + } + internal static bool Apply(double requested, Func setExposure, Func readShutter, - int timeoutMs = 12000, Action pause = null) { + int timeoutMs = 12000, Action pause = null, double longestNonBulb = 30) { if (!double.IsFinite(requested) || requested < 0) throw new ArgumentOutOfRangeException(nameof(requested)); pause ??= () => Thread.Sleep(300); Stopwatch elapsed = Stopwatch.StartNew(); double actual = double.NaN; + bool requiresBulb = requested == 0 || requested > longestNonBulb; do { uint result = setExposure(); if (result == NotSupported) return false; @@ -22,7 +38,7 @@ internal static bool Apply(double requested, Func setExposure, Func uint raw = readShutter(); // Sony uses zero for Bulb. Zero-duration captures retain the // native driver's existing Bulb behavior. - if (raw == 0 && (requested == 0 || requested > 30)) return true; + if (raw == 0 && requiresBulb) return true; uint numerator = raw >> 16; uint denominator = raw & 0xFFFF; if (numerator == 0 || denominator == 0 || raw == uint.MaxValue) { @@ -32,7 +48,7 @@ internal static bool Apply(double requested, Func setExposure, Func // Sony's irregular ladder includes a 4/3 gap. The native // driver chooses by absolute distance: its worst half-step // ratio is 7/6, below 1.17 and the smallest whole step (1.20). - if (requested > 0 && Math.Max(actual / requested, requested / actual) <= 1.17) return true; + if (!requiresBulb && requested > 0 && Math.Max(actual / requested, requested / actual) <= 1.17) return true; if (elapsed.ElapsedMilliseconds >= timeoutMs) break; pause(); } while (elapsed.ElapsedMilliseconds < timeoutMs); diff --git a/SonyCameraPluginNative/Sony.cs b/SonyCameraPluginNative/Sony.cs index 8a7f408..4d9812d 100644 --- a/SonyCameraPluginNative/Sony.cs +++ b/SonyCameraPluginNative/Sony.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Linq; using System.Threading; using NINA.Core.Utility; using NINA.Core.Utility.Notification; @@ -54,7 +55,10 @@ public IEnumerable Cameras() => Invoke("enumerate cameras", () => { }); public SonyCameraInfo CameraInfo => calls.IsUnavailable ? null : _camera; - public bool IsOpen(uint handle) => !calls.IsUnavailable && _camera != null && _camera.Handle == handle; + public bool IsOpen(uint handle) { + SonyCameraInfo camera = _camera; + return !calls.IsUnavailable && camera != null && camera.Handle == handle; + } public SonyCameraInfo OpenCamera(string path) => Invoke("open camera", () => { if (_camera != null) throw new SonyException("A Sony camera is already open. Disconnect it before opening another camera; restart NINA if the earlier disconnect timed out."); @@ -100,7 +104,9 @@ public void SetProperty(uint handle, uint propertyId, uint value) => public void StartCapture(uint handle, float exposureTime) { InvokeCamera(handle, "start capture", () => { _sonydll.DiscardLastImage(); - return _sonydll.StartCapture(handle, exposureTime); + double longestNonBulb = ExposureTimeController.LongestNonBulb( + _camera.GetPropertyInfo(0xFFFF)?.Options()?.Select(option => option.Value)); + return _sonydll.StartCapture(handle, exposureTime, longestNonBulb); }, timeoutMs: 60000); } diff --git a/SonyCameraPluginNative/SonyMTPCameraDll.cs b/SonyCameraPluginNative/SonyMTPCameraDll.cs index 58a9500..05b8b32 100644 --- a/SonyCameraPluginNative/SonyMTPCameraDll.cs +++ b/SonyCameraPluginNative/SonyMTPCameraDll.cs @@ -285,11 +285,11 @@ public byte[] GetPreviewImage(UInt32 hDevice) { } } - public UInt32 StartCapture(UInt32 hDevice, float exposureTime) { + public UInt32 StartCapture(UInt32 hDevice, float exposureTime, double longestNonBulb = 30) { PropertyValue value = new PropertyValue(); bool applied = ExposureTimeController.Apply(exposureTime, () => fSetExposureTime(hDevice, exposureTime, ref value), - () => GetSinglePropertyValue(hDevice, 0xD20D).Value); + () => GetSinglePropertyValue(hDevice, 0xD20D).Value, longestNonBulb: longestNonBulb); if (!applied) { Logger.Warning("The camera has no learnt shutter-speed list. Its manually selected shutter speed will be used; run SonyCameraInfo to learn exposure settings before using the Flat Wizard."); } @@ -333,13 +333,12 @@ public UInt32 GetCaptureStatus(UInt32 hDevice) { // If the capture is complete, copy the image if (info.Status == STATUS_COMPLETE) { - _lastImage = new byte[info.ImageSize]; - - if (_lastImage == null) { - throw new SonyException("Unable to allocate memory for image"); + if (info.ImageData == IntPtr.Zero || info.ImageSize == 0 || info.ImageSize > int.MaxValue) { + throw new SonyException("The completed Sony capture returned an invalid image buffer."); } - - Marshal.Copy(info.ImageData, _lastImage, 0, (Int32)info.ImageSize); + byte[] image = new byte[(int)info.ImageSize]; + Marshal.Copy(info.ImageData, image, 0, image.Length); + Interlocked.Exchange(ref _lastImage, image); } return info.Status; @@ -526,4 +525,4 @@ public UInt32 GetFocusLimit(UInt32 hCamera) { #endregion } #endregion -} \ No newline at end of file +} diff --git a/Tests/ExposureTimeControllerTests.cs b/Tests/ExposureTimeControllerTests.cs index a7aa869..c266729 100644 --- a/Tests/ExposureTimeControllerTests.cs +++ b/Tests/ExposureTimeControllerTests.cs @@ -3,6 +3,21 @@ using Xunit; public class ExposureTimeControllerTests { + [Fact] + public void BulbBoundaryUsesTheLearnedNumericShutterList() { + double longest = ExposureTimeController.LongestNonBulb(new uint[] { 0, 0x00040001, 0x00010002 }); + Assert.Equal(4, longest); + Assert.True(ExposureTimeController.Apply(5, () => 0, () => 0, longestNonBulb: longest)); + Assert.Throws(() => ExposureTimeController.Apply( + 4.1, () => 0, () => 0x00040001, timeoutMs: 0, longestNonBulb: longest)); + } + + [Fact] + public void BulbRequestCannotAcceptAStaleNumericShutter() { + Assert.Throws(() => ExposureTimeController.Apply( + 31, () => 0, () => 0x001E0001u, timeoutMs: 0)); + } + [Fact] public void BackToBackShutterChangeIsRetriedUntilItTakes() { int attempts = 0; diff --git a/Tests/SimulatedSonyCameraTests.cs b/Tests/SimulatedSonyCameraTests.cs index b39aaac..4fb18c3 100644 --- a/Tests/SimulatedSonyCameraTests.cs +++ b/Tests/SimulatedSonyCameraTests.cs @@ -41,6 +41,7 @@ public void ExposureProgressesThroughNativeStatesAndCompleteConsumesCapture() { Assert.Equal(4u, camera.GetCaptureStatus(handle)); Assert.Equal(2u, camera.GetCaptureStatus(handle)); var frame = camera.GetLastImage(); + Assert.Throws(() => camera.GetLastImage()); Assert.Equal(320 * 240, frame.Pixels.Length); Assert.Equal(1, frame.Pixels[0]); Assert.All(frame.Pixels, pixel => Assert.InRange(pixel, (ushort)0, (ushort)16383)); diff --git a/docs/UPSTREAM-REVIEW-1.0.0.5.md b/docs/UPSTREAM-REVIEW-1.0.0.5.md index 82fa389..4808d7e 100644 --- a/docs/UPSTREAM-REVIEW-1.0.0.5.md +++ b/docs/UPSTREAM-REVIEW-1.0.0.5.md @@ -27,7 +27,7 @@ Included: - Serialize all native camera and focuser calls through a shared executor; bound native operations and lock waits. A native execution timeout permanently blocks further native calls, including close, rescan and reconnect, until NINA restarts. A late result cannot make `IsOpen` or `CameraInfo` report a usable camera. - Keep the COMPLETE status/image copy inline. Consume the managed image once and discard leftover frames before the next capture and at close/open. -- Read back and retry shutter changes with a 12-second retry budget, within the surrounding 60-second native-start watchdog. The native driver picks by absolute shutter-time distance; a 1.17 ratio accepts the arithmetic midpoint of the measured 4/3 shutter gaps (worst half-step ratio 7/6) while rejecting the measured smallest full step, 1.20. A readable mismatch, failed retry, or invalid readback fails before starting capture. A missing learnt shutter list retains the previous manual-setting behavior with a warning. +- Read back and retry shutter changes with a 12-second retry budget, within the surrounding 60-second native-start watchdog. The native driver picks by absolute shutter-time distance; a 1.17 ratio accepts the arithmetic midpoint of the measured 4/3 shutter gaps (worst half-step ratio 7/6) while rejecting the measured smallest full step, 1.20. Bulb selection uses the longest numeric shutter from the native driver’s learnt `0xFFFF` property (30 seconds as compatibility fallback), rather than the camera’s exposure maximum, which includes Bulb durations. A stale numeric setting cannot satisfy a Bulb request. A readable mismatch, failed retry, or invalid readback fails before starting capture. A missing learnt shutter list retains the previous manual-setting behavior with a warning. - Offer sorted, distinct manual ISO values, excluding zero, AUTO (`0x00FFFFFF`), and Multi Frame NR values. Gain bounds use the same filtered options. - Correct native DLL version component order and remove the redundant unresolved ReachFramework reference. - Preserve focuser state notifications, reset moving state on failures, and avoid queueing polled reads indefinitely behind another operation.