diff --git a/.github/workflows/dotnet.yml b/.github/workflows/dotnet.yml index 6410f60..b023fd2 100644 --- a/.github/workflows/dotnet.yml +++ b/.github/workflows/dotnet.yml @@ -1,46 +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 - - - name: Navigate to Workspace - run: cd $GITHUB_WORKSPACE - - - name: Create Build Directory - run: mkdir _build - - - name: Restore Packages - run: nuget restore NINASonyCameraPlugin.sln - - - 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 SonyCamera DLL - uses: actions/upload-artifact@v4 - with: - name: SonyCamera-DLL - path: | - **/bin/Release/**/NINA.RetroKiwi.Plugin.SonyCamera.dll \ No newline at end of file + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0 + with: + dotnet-version: 8.0.x + + - name: Test capture, simulator and native call policies + run: dotnet test Tests/CaptureController.Tests.csproj --configuration Release --logger "trx;LogFileName=capture.trx" + + - name: Test Sony camera temperature metadata + run: dotnet test Tests/Temperature/Temperature.Tests.csproj --configuration Release --logger "trx;LogFileName=temperature.trx" + + - name: Test plugin integration on Windows + run: dotnet test Tests/PluginIntegration/PluginIntegration.Tests.csproj --configuration Release --logger "trx;LogFileName=integration.trx" + + - name: Build release plugin + run: dotnet build NINASonyCameraPlugin.sln --configuration Release -p:InstallPluginOnBuild=false + + - 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: 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 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 870006f..666334c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,21 @@ -# Sony Camera Plugin +# Sony Camera Plugin + +## 1.0.0.5 — community preview + +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. + +- 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. + +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). + +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/Drivers/CameraDriver.cs b/Drivers/CameraDriver.cs index f2d6826..4cbc9d2 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; @@ -29,17 +29,16 @@ 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 readonly IPluginOptionsAccessor _pluginSettings; + private readonly ISonyCameraBackend _driver; + private readonly Action _showWarning; + 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; private IProfileService _profileService; private readonly IExposureDataFactory _exposureDataFactory; @@ -48,16 +47,27 @@ public class CameraDriver : BaseINPC, ICamera { private short _readoutModeForNormalImages; private AsyncObservableCollection _binningModes; - public CameraDriver(IProfileService profileService, IExposureDataFactory exposureDataFactory, SonyDevice device) { + 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(_driver, out var status, "capture status"); + return status; + }, + exposureTime => _driver.StartCapture(_camera.Handle, exposureTime), + TryNativeCancel, + () => NativeCancelEnabled); } #region Internal Helpers private PropertyValue GetPropertyValue(uint id) { - return SonyDriver.GetInstance().GetProperty(_camera.Handle, id); + return _driver.GetProperty(_camera.Handle, id); } private IReadOnlyList GetAvailableIsoOptions() { @@ -69,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; } @@ -91,28 +102,48 @@ private void NotifyGainPropertiesChanged() { RaisePropertyChanged(nameof(Gains)); } + private bool NativeCancelEnabled { + get { + 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; + } + } + } + + private bool TryNativeCancel() { + try { + _driver.CancelCapture(_camera.Handle); + return true; + } catch (Exception ex) { + Logger.Error("CancelCapture failed; letting capture finish.", ex); + return false; + } + } + + private bool TryGetCaptureStatus(ISonyCameraBackend 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}); camera may not have responded: {ex.Message}."); + status = CaptureController.StatusUnknown; + return false; + } + } + #endregion #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 (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(); } @@ -225,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; } @@ -280,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; @@ -293,7 +330,8 @@ public int Gain { set { if (_camera != null) { try { - SonyDriver.GetInstance().SetProperty(_camera.Handle, PROPID_ISO, (uint)value); + 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) { Logger.Error($"Problem setting gain to {value}", ex); @@ -302,23 +340,9 @@ 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 => "Sony"; + public string Id => _device.Id == SimulatedSonyCameraBackend.DeviceId ? SimulatedSonyCameraBackend.DeviceId : "Sony"; public string Name { get => _device.Model; @@ -334,7 +358,8 @@ public string DisplayName { public bool Connected { get { - return _camera != null; + SonyCameraInfo camera = _camera; + return camera != null && _driver.IsOpen(camera.Handle); } } @@ -455,34 +480,45 @@ public void StopLiveView() { public Task Connect(CancellationToken token) { return Task.Run(() => { - try { - _camera = SonyDriver.GetInstance().OpenCamera(_device.Id); - } catch (Exception ex) { - Logger.Error(ex); - _camera = null; + bool connected; + lock (connectionLock) { + token.ThrowIfCancellationRequested(); + if (Connected) return true; + Volatile.Write(ref _lastCameraTemperature, double.NaN); + _lastKnownGain = -1; + _lastKnownBatteryLevel = 0; + 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 { - SonyDriver.GetInstance().CloseCamera(_camera.Handle); + _driver.CloseCamera(_camera.Handle); } catch (Exception ex) { Logger.Error(ex); } _camera = null; - NotifyGainPropertiesChanged(); + Volatile.Write(ref _lastCameraTemperature, double.NaN); } + NotifyGainPropertiesChanged(); } 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 = @@ -515,20 +551,14 @@ public void SetupDialog() { } public void StartExposure(CaptureSequence sequence) { + if (!Connected) throw new SonyException("Connect a Sony camera before starting an exposure."); 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."); + try { + _capture.Start((float)sequence.ExposureTime); + } catch (InvalidOperationException ex) { + Logger.Warning(ex.Message); + throw new SonyException(ex.Message); } - - // 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); - - double exposureTime = sequence.ExposureTime; - driver.StartCapture(_camera.Handle, (float)exposureTime); //); } } @@ -537,30 +567,25 @@ public void StopExposure() { } public void AbortExposure() { - if (_camera != null) { - SonyDriver.GetInstance().CancelCapture(_camera.Handle); + if (_camera == null) { + return; + } + + if (_capture.Abort() == CaptureController.AbortResult.SoftCancelRequested) { + _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)) { - uint[] completionStates = { CAPTURE_CANCELLED, CAPTURE_COMPLETE, CAPTURE_FAILED }; - - SonyDriver driver = SonyDriver.GetInstance(); - try { - uint captureStatus = driver.GetCaptureStatus(_camera.Handle); - 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); - } - - Logger.Info($"Wait for image ready complete, completion state is {captureStatus}"); + await _capture.WaitUntilReady(token); + } catch (OperationCanceledException) { + Logger.Info("WaitUntilExposureIsReady cancelled."); + 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)"); } } @@ -568,17 +593,30 @@ 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(); + double temperature = image.Pixels == null ? SonyCameraTemperature.Read(image.RawBytes) : double.NaN; + metaData.Camera.Temperature = temperature; + Volatile.Write(ref _lastCameraTemperature, temperature); + RaisePropertyChanged(nameof(Temperature)); + 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); - }); + }, token); } #endregion diff --git a/Drivers/CameraProvider.cs b/Drivers/CameraProvider.cs index 7c78287..dab8049 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; @@ -23,16 +24,20 @@ 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] public CameraProvider(IProfileService profileService, IExposureDataFactory exposureDataFactory) { this.profileService = profileService; 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); } @@ -43,14 +48,13 @@ public CameraProvider(IProfileService profileService, IExposureDataFactory expos public IList GetEquipment() { var devices = new List(); - if (this.driver != null) { try { int count = 0; foreach (var sonyDevice in driver.Cameras()) { count++; - devices.Add(new CameraDriver(profileService, exposureDataFactory, sonyDevice)); + devices.Add(new CameraDriver(profileService, exposureDataFactory, sonyDevice, pluginSettings, driver)); } Logger.Info($"Found {count} Sony Cameras"); diff --git a/Drivers/CaptureController.cs b/Drivers/CaptureController.cs new file mode 100644 index 0000000..2e6830a --- /dev/null +++ b/Drivers/CaptureController.cs @@ -0,0 +1,134 @@ +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 == Reading || status == Processing) { + return AbortResult.SoftCancelRequested; + } + 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/Drivers/FocuserDriver.cs b/Drivers/FocuserDriver.cs index 9b3dfe3..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; + } } } } @@ -98,11 +158,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; } @@ -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/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..2cba108 --- /dev/null +++ b/Drivers/ISonyCameraBackend.cs @@ -0,0 +1,26 @@ +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); + bool IsOpen(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..c0a7858 --- /dev/null +++ b/Drivers/NativeSonyCameraBackend.cs @@ -0,0 +1,20 @@ +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 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); + 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..a104ae5 --- /dev/null +++ b/Drivers/SimulatedSonyCameraBackend.cs @@ -0,0 +1,218 @@ +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 bool IsOpen(uint cameraHandle) { + lock (cameraLock) return connected && cameraHandle == handle; + } + + 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."); + } + SonyExposureFrame image = lastImage; + lastImage = null; + return image; + } + } + + private void UpdateCapture() { + // 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; + 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/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/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/NINASonyCameraPlugin.csproj b/NINASonyCameraPlugin.csproj index 949eaca..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 - - - @@ -40,6 +37,7 @@ + @@ -68,7 +66,7 @@ - - + + diff --git a/Options.xaml b/Options.xaml index 893755b..b7cb9c5 100644 --- a/Options.xaml +++ b/Options.xaml @@ -6,15 +6,12 @@ - - - - - - - - - - + + + + + - \ No newline at end of file + diff --git a/Properties/AssemblyInfo.cs b/Properties/AssemblyInfo.cs index ad54320..280982b 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; @@ -8,13 +8,13 @@ // [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")] // [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,12 +52,16 @@ //[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 * 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) @@ -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 a98c5bd..9de6965 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,32 +25,111 @@ 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 - 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. +- 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. +- 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 + +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. 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 + +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. + +## 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. +- 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); - 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 -- Version: 1.0.0.4 +- 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/SonyCameraPlugin.cs b/SonyCameraPlugin.cs index 6b7cfc0..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; @@ -72,6 +73,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 +134,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)); diff --git a/SonyCameraPluginNative/ExposureTimeController.cs b/SonyCameraPluginNative/ExposureTimeController.cs new file mode 100644 index 0000000..76a2562 --- /dev/null +++ b/SonyCameraPluginNative/ExposureTimeController.cs @@ -0,0 +1,58 @@ +using System; +using System.Collections.Generic; +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 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, 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; + 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 && requiresBulb) 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 (!requiresBulb && 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..4d9812d 100644 --- a/SonyCameraPluginNative/Sony.cs +++ b/SonyCameraPluginNative/Sony.cs @@ -1,112 +1,149 @@ -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) { + 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."); + 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(); + double longestNonBulb = ExposureTimeController.LongestNonBulb( + _camera.GetPropertyInfo(0xFFFF)?.Options()?.Select(option => option.Value)); + return _sonydll.StartCapture(handle, exposureTime, longestNonBulb); + }, 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/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/SonyCameraPluginNative/SonyMTPCameraDll.cs b/SonyCameraPluginNative/SonyMTPCameraDll.cs index c23916e..05b8b32 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); @@ -231,69 +230,127 @@ 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) { + public UInt32 StartCapture(UInt32 hDevice, float exposureTime, double longestNonBulb = 30) { PropertyValue value = new PropertyValue(); - - fSetExposureTime(hDevice, exposureTime, ref value); + bool applied = ExposureTimeController.Apply(exposureTime, + () => fSetExposureTime(hDevice, exposureTime, ref 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."); + } ImageInfo info = new ImageInfo { ImageMode = IMAGEMODE_PASSTHRU, 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"); - } - - // If the capture is complete, copy the image - if (info.Status == STATUS_COMPLETE) { - _lastImage = new byte[info.ImageSize]; + try { + if (fGetCaptureStatus(hDevice, ref info) != ERROR_SUCCESS) { + throw new SonyException("Unable to get image status"); + } - if (_lastImage == null) { - throw new SonyException("Unable to allocate memory for image"); + // If the capture is complete, copy the image + if (info.Status == STATUS_COMPLETE) { + if (info.ImageData == IntPtr.Zero || info.ImageSize == 0 || info.ImageSize > int.MaxValue) { + throw new SonyException("The completed Sony capture returned an invalid image buffer."); + } + byte[] image = new byte[(int)info.ImageSize]; + Marshal.Copy(info.ImageData, image, 0, image.Length); + Interlocked.Exchange(ref _lastImage, image); } - 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) { @@ -468,4 +525,4 @@ public UInt32 GetFocusLimit(UInt32 hCamera) { #endregion } #endregion -} \ No newline at end of file +} diff --git a/Tests/CaptureController.Tests.csproj b/Tests/CaptureController.Tests.csproj new file mode 100644 index 0000000..2b733e6 --- /dev/null +++ b/Tests/CaptureController.Tests.csproj @@ -0,0 +1,24 @@ + + + net8.0 + true + false + false + + + + + + + all + + + + + + + + + + + diff --git a/Tests/CaptureControllerTests.cs b/Tests/CaptureControllerTests.cs new file mode 100644 index 0000000..b84e653 --- /dev/null +++ b/Tests/CaptureControllerTests.cs @@ -0,0 +1,261 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using NINA.RetroKiwi.Plugin.SonyCamera.Drivers; +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; + 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/Tests/ExposureTimeControllerTests.cs b/Tests/ExposureTimeControllerTests.cs new file mode 100644 index 0000000..c266729 --- /dev/null +++ b/Tests/ExposureTimeControllerTests.cs @@ -0,0 +1,68 @@ +using System; +using Sony; +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; + 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/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/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 new file mode 100644 index 0000000..cf72d91 --- /dev/null +++ b/Tests/PluginIntegration/CameraDriverSimulatorTests.cs @@ -0,0 +1,328 @@ +using System; +using System.IO; +using System.Linq; +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; +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 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(); + 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.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, + 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 { + 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(); + } + } + + [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 new file mode 100644 index 0000000..4dab238 --- /dev/null +++ b/Tests/PluginIntegration/PluginIntegration.Tests.csproj @@ -0,0 +1,19 @@ + + + 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..4fb18c3 --- /dev/null +++ b/Tests/SimulatedSonyCameraTests.cs @@ -0,0 +1,195 @@ +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.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)); + } + + [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 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(); + 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); + } +} 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 0000000..645ec0e Binary files /dev/null and b/Tests/Temperature/Fixtures/a7iii-metadata.bin differ diff --git a/Tests/Temperature/SonyCameraTemperatureTests.cs b/Tests/Temperature/SonyCameraTemperatureTests.cs new file mode 100644 index 0000000..e9d68ca --- /dev/null +++ b/Tests/Temperature/SonyCameraTemperatureTests.cs @@ -0,0 +1,135 @@ +using System; +using System.Buffers.Binary; +using System.IO; +using NINA.RetroKiwi.Plugin.SonyCamera.Drivers; +using Xunit; + +public class SonyCameraTemperatureTests { + [Fact] + public void RealA7IIIMetadataMatchesExifToolCameraReading() { + var bytes = File.ReadAllBytes(Path.Combine(AppContext.BaseDirectory, "Fixtures", "a7iii-metadata.bin")); + Assert.Equal(28, SonyCameraTemperature.Read(bytes)); + // The same photo reports battery 33.9 C and ambient 22 C. Neither is + // the camera temperature returned here. + } + + [Theory] + [InlineData(true, false)] + [InlineData(true, true)] + [InlineData(false, false)] + [InlineData(false, true)] + public void HandlesTiffByteOrdersAndBothSonyMakerNoteLayouts(bool littleEndian, bool header) { + Assert.Equal(28, SonyCameraTemperature.Read(CreateImage(28, littleEndian, header))); + } + + [Theory] + [InlineData(-20)] + [InlineData(-1)] + [InlineData(0)] + [InlineData(80)] + public void ReadsSignedCelsiusIncludingValidZero(int temperature) { + Assert.Equal(temperature, SonyCameraTemperature.Read(CreateImage(temperature))); + } + + [Theory] + [InlineData(0)] + [InlineData(100)] + [InlineData(130)] + [InlineData(148)] + [InlineData(255)] + public void InvalidSonyValidityFlagLeavesReadingUnavailable(int flag) { + byte[] image = CreateImage(28); + image[102] = Encipher((byte)flag); + Assert.True(double.IsNaN(SonyCameraTemperature.Read(image))); + } + + [Fact] + public void MissingTagAndOtherManufacturersLeaveReadingUnavailable() { + byte[] image = CreateImage(28); + image[40] = (byte)'N'; + Assert.True(double.IsNaN(SonyCameraTemperature.Read(image))); + image = CreateImage(28); + Write16(image, 82, 0x9406, true); // BatteryTemperature, not CameraTemperature. + Assert.True(double.IsNaN(SonyCameraTemperature.Read(image))); + Assert.True(double.IsNaN(SonyCameraTemperature.Read(null))); + } + + [Fact] + public void EveryTruncatedRequiredStructureLeavesReadingUnavailable() { + byte[] image = CreateImage(28); + for (int length = 0; length < 104; length++) { + Assert.True(double.IsNaN(SonyCameraTemperature.Read(image.AsSpan(0, length).ToArray()))); + } + } + + [Theory] + [InlineData(4)] // Main IFD pointer. + [InlineData(18)] // Make string pointer. + [InlineData(30)] // Exif IFD pointer. + [InlineData(58)] // Maker note pointer. + [InlineData(86)] // Temperature tag length. + [InlineData(90)] // Temperature tag pointer. + public void OverflowingOffsetsAndLengthsAreRejectedWithoutThrowing(int field) { + byte[] image = CreateImage(28); + Write32(image, field, uint.MaxValue, true); + Assert.True(double.IsNaN(SonyCameraTemperature.Read(image))); + } + + [Fact] + public void TemperaturePayloadMustBeInsideDeclaredMakerNote() { + byte[] image = CreateImage(28); + Write32(image, 90, 120, true); + image[124] = Encipher(32); + image[125] = Encipher(28); + Assert.True(double.IsNaN(SonyCameraTemperature.Read(image))); + } + + [Fact] + public void WrongTiffMagicTagTypeAndHugeDirectoryCountsAreRejected() { + byte[] image = CreateImage(28); + Write16(image, 2, 43, true); + Assert.True(double.IsNaN(SonyCameraTemperature.Read(image))); + image = CreateImage(28); + Write16(image, 84, 4, true); + Assert.True(double.IsNaN(SonyCameraTemperature.Read(image))); + image = CreateImage(28); + Write16(image, 80, ushort.MaxValue, true); + Assert.True(double.IsNaN(SonyCameraTemperature.Read(image))); + } + + private static byte[] CreateImage(int temperature, bool littleEndian = true, bool header = false) { + var data = new byte[128]; + data[0] = data[1] = (byte)(littleEndian ? 'I' : 'M'); + Write16(data, 2, 42, littleEndian); + Write32(data, 4, 8, littleEndian); + Write16(data, 8, 2, littleEndian); + Entry(data, 10, 0x010F, 2, 5, 40, littleEndian); + "SONY\0"u8.CopyTo(data.AsSpan(40)); + Entry(data, 22, 0x8769, 4, 1, 48, littleEndian); + Write16(data, 48, 1, littleEndian); + Entry(data, 50, 0x927C, 7, header ? 36u : 24u, 80, littleEndian); + int makerIfd = header ? 92 : 80; + if (header) "SONY DSC \0\0\0"u8.CopyTo(data.AsSpan(80)); + Write16(data, makerIfd, 1, littleEndian); + Entry(data, makerIfd + 2, 0x9403, 7, 6, (uint)(makerIfd + 18), littleEndian); + data[makerIfd + 22] = Encipher(32); + data[makerIfd + 23] = Encipher(unchecked((byte)temperature)); + return data; + } + + private static byte Encipher(byte value) => 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 + + + + + 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..4808d7e --- /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. 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. + +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.