diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 8fc7a85..4a52355 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -107,7 +107,7 @@ jobs: run: dotnet test CanonSDK.sln --configuration Release --no-build - name: Publish Canon.API as standalone executable - run: dotnet publish Canon.API/Canon.API.csproj --configuration Release --runtime win-x64 --self-contained true -p:PublishSingleFile=true --output ./publish + run: dotnet publish Canon.API/Canon.API.csproj --configuration Release --framework net10.0-windows --runtime win-x64 --self-contained true -p:PublishSingleFile=true --output ./publish - name: Create release package run: | diff --git a/CHANGELOG.md b/CHANGELOG.md index 2e10c7c..406670f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,41 @@ Versions match the Git tags (`v1.0.0.11`) that trigger the release workflow. ## [Unreleased] +### Added +- `tools/Test-Camera.ps1` (with `Test-Camera.cmd`): runs the photo booth camera checks against the API (live view, + captures, flash changes during the live view, power cycle) and saves the pictures, the results and the API log. +- Linux x64 support: the `net10.0` target of `Canon.API` runs on Linux with the Linux version of the EDSDK + (`EDSDK/linux-x64/libEDSDK.so`, needs `libusb-1.0`). No automatic update and no release package on Linux yet. +- Debug logs (`Logging:LogLevel:Canon.Core` = `Debug`): every EDSDK event, and each step of a capture (UI lock, flash + settings, shutter button, transfer) with its result and duration. + +### Changed +- `Canon.API` has two target frameworks: `dotnet run` and `dotnet publish` need `-f net10.0-windows` (Windows) or + `-f net10.0` (Linux). `Canon.Core` and the unit tests target `net10.0`. +- A failed UI unlock after setting the flash is logged as a warning; it was ignored. +- The problem title returned when the Canon EDSDK cannot be loaded now names both platforms: "Canon EDSDK could not be + loaded (EDSDK.dll or libEDSDK.so missing, wrong architecture, or libusb-1.0 missing on Linux)" (was "...(EDSDK.dll + missing or wrong architecture)"). Client-visible text. +- Regenerating `docs/openapi.json` at build time now uses + `dotnet build Canon.API -f net10.0 -t:Build -t:GenerateOpenApiDocuments` (Windows or Linux); the former + `-p:OpenApiGenerateDocumentsOnBuild=true` form generates nothing now that `Canon.API` has two target frameworks. + +### Fixed +- The camera froze when a picture was taken while the live view ran, as in the photo booth: the shutter stayed busy + (503 "Camera busy"), no picture was returned, and the camera did not respond until it was turned off and on. Writing + the flash setting during the live view freezes the camera (EOS R100), and `Canon:ForceFlashFiring` wrote it before + every capture. It is now written when the camera connects, before the live view starts (once per live view session) + and before a capture taken without live view; `POST /flash` pauses the live view while it writes it. With + `Canon:ForceFlashFiring`, a value set with `POST /flash` therefore stays until the live view stops (it was set back to + Fire before every capture), and `GET /flash` returns the last value set while the live view runs. +- Linux: the application crashed when the camera was turned off or unplugged. The SDK closes the session itself then, + and releasing the camera closed it a second time, which crashed (`CLinuxPtpHelper::Terminate` from `EdsRelease`). On + Linux the camera object of a camera that is gone is now left to the SDK. +- The session of a camera that is gone (turned off, unplugged, or a call failed because it is disconnected) is no longer + closed before the camera is released, on Windows too, as in the Canon samples. +- Linux: a camera turned back on or plugged back in was not reconnected, because the SDK lists it a moment after + reporting it (about 1 s). The connection is now retried for about 10 s. + ## [1.4.0.0] - 2026-09-30 ### Added diff --git a/CLAUDE.md b/CLAUDE.md index 578d13a..180b02a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -10,10 +10,10 @@ CanonWebAPI is a .NET 10 web API for remotely controlling Canon DSLR and mirrorl The solution consists of 4 projects: -- **Canon.API**: ASP.NET Core Web API (main entry point) +- **Canon.API**: ASP.NET Core Web API (main entry point); targets `net10.0-windows` (Windows release, AutoUpdater.NET) and `net10.0` (Linux, no automatic update) - **Canon.Core**: Core library wrapping Canon EDSDK functionality - **Canon.Core.Tests**: xUnit unit tests of Canon.Core (no camera needed, run on Linux too) -- **Canon.API.Tests**: xUnit unit tests of the pure parts of Canon.API, compiled from linked sources (Canon.API needs the Windows Desktop runtime) +- **Canon.API.Tests**: xUnit unit tests of the pure parts of Canon.API, compiled from the Canon.API sources so they do not depend on the API project and its packages ### Key Components @@ -30,9 +30,10 @@ The solution consists of 4 projects: ### Dependencies - Requires Canon EDSDK 13.20.21 **64-bit** DLLs (`EDSDK.dll`, `EdsImage.dll`, from `EDSDK_64/Dll` of the SDK) in the EDSDK folder +- Linux: `EDSDK/linux-x64/libEDSDK.so` (from `EDSDK/Library/x86_64` of the 13.20.21 Linux SDK), needs `libusb-1.0` - Uses Serilog for logging - Swagger/OpenAPI for API documentation -- Platform target: x64 (Windows only) +- Platform target: x64 (Windows; Linux through the `net10.0` target of Canon.API) ## Development Commands @@ -43,7 +44,7 @@ dotnet build CanonSDK.sln ### Run API Server ```bash -dotnet run --project Canon.API +dotnet run --project Canon.API -f net10.0-windows # Linux: -f net10.0 --launch-profile http ``` ### Run Unit Tests @@ -79,7 +80,7 @@ The Canon.API project exposes these REST endpoints (OpenAPI at `/openapi/v1.json - GET `/latestpicture` - Retrieve last captured image - POST `/autofocus` - Trigger autofocus - GET `/cameraname`, `/mode`, `/temperature`, `/status` - Camera information -- GET/POST `/flash` - "Flash firing" setting (forced to Fire before each capture by default) +- GET/POST `/flash` - "Flash firing" setting (forced to Fire by default, outside the live view) Errors are problem details: 400 invalid value, 409 capture refused or setting locked by the shooting mode, 503 not connected/busy, 504 capture timeout. @@ -88,7 +89,7 @@ Errors are problem details: 400 invalid value, 409 capture refused or setting lo - The camera can be connected via USB before or after the application starts (automatic connection and reconnection) - Canon EOS Utility must NOT be running (conflicts with EDSDK access) - Requires compatible Canon camera with EDSDK support -- All projects target .NET 10 (`net10.0-windows`) with Windows-specific dependencies +- Canon.Core and the tests target `net10.0`; Canon.API targets `net10.0-windows` (release, AutoUpdater.NET) and `net10.0` (Linux). Publish the release with `-f net10.0-windows` - Uses structured logging with Serilog (logs to console and `logs/canon-api.log` next to the executable; levels from `Logging:LogLevel`) - Content root is the executable folder: settings files are read next to the executable whatever the working directory - Operator settings go in `appsettings.Local.json`, never shipped: automatic updates overwrite `appsettings.json` @@ -97,6 +98,8 @@ Errors are problem details: 400 invalid value, 409 capture refused or setting lo ## AutoUpdater Integration +Applies to the `net10.0-windows` target only: the `net10.0` target (Linux) has no automatic update. + The application includes AutoUpdater.NET with these configurations: - Automatic version checking on startup - No admin privileges required (`RunUpdateAsAdmin = false`) @@ -122,9 +125,9 @@ The project uses GitHub Actions for automated releases (record every change in t - Downloads go to memory streams; the progress callback is registered before `EdsDownload` - Live view is started with `Evf_OutputDevice` and stopped when no client uses it - "Device busy" answers are retried after ~500 ms, as in the Canon samples; only the shutter press is retried, the release is always sent -- Flash (`EdsCreateFlashSettingRef`, `Flash_Target`, `Flash_Firing`): the UI must be locked while setting it; the SDK only reports values set remotely, so the setting is forced before each capture (studio flash on the shoe in the photo booth) +- Flash (`EdsCreateFlashSettingRef`, `Flash_Target`, `Flash_Firing`): the UI must be locked while setting it; the SDK only reports values set remotely, so the setting is forced when the camera connects, before the live view starts and before a capture without live view (studio flash on the shoe in the photo booth). Never write it while the live view runs: the EOS R100 freezes (shutter busy until it is turned off and on); POST /flash pauses the live view - Settings: `CameraSettingRules` (pure, tested) decides what is settable per AE mode; EdsGetPropertyDesc gives the accepted values (its `form`/`access` fields are reserved, always 0); an empty list means not settable now (as in the Canon samples). Set only values from that list (API reference 3.1.20) -- Memory management critical due to unmanaged EDSDK resources: release every ref (`EdsRelease`) +- Memory management critical due to unmanaged EDSDK resources: release every ref (`EdsRelease`), except, on Linux, a camera that is gone: the SDK has already closed its session and releasing it crashes (`CLinuxPtpHelper::Terminate`) ## Testing diff --git a/Canon.API.Tests/CameraExceptionHandlerTests.cs b/Canon.API.Tests/CameraExceptionHandlerTests.cs index 64c4d28..eff5de3 100644 --- a/Canon.API.Tests/CameraExceptionHandlerTests.cs +++ b/Canon.API.Tests/CameraExceptionHandlerTests.cs @@ -28,6 +28,8 @@ public class CameraExceptionHandlerTests { NotApplied(new EdsException(EDSDK.EDS_ERR_DEVICE_BUSY, "x")), 503 }, { NotApplied(new EdsException(EDSDK.EDS_ERR_INVALID_DEVICEPROP_VALUE, "x")), 400 }, { new InvalidOperationException(), 500 }, + { new DllNotFoundException("x"), 500 }, + { new BadImageFormatException("x"), 500 }, }; private static CameraSettingsException Refused(SettingErrorReason reason) => diff --git a/Canon.API.Tests/Canon.API.Tests.csproj b/Canon.API.Tests/Canon.API.Tests.csproj index b54fd69..c2ff423 100644 --- a/Canon.API.Tests/Canon.API.Tests.csproj +++ b/Canon.API.Tests/Canon.API.Tests.csproj @@ -1,7 +1,7 @@ - net10.0-windows + net10.0 enable enable x64 @@ -9,10 +9,7 @@ true - + diff --git a/Canon.API/Canon.API.csproj b/Canon.API/Canon.API.csproj index 371a095..6ecfb53 100644 --- a/Canon.API/Canon.API.csproj +++ b/Canon.API/Canon.API.csproj @@ -1,7 +1,8 @@ - net10.0-windows + + net10.0-windows;net10.0 enable enable x64 @@ -11,17 +12,20 @@ 1.4.0.0 1.4.0.0 - + + false + $(MSBuildProjectDirectory)/../docs + --file-name openapi + + + + true true win-x64 true true - - - false - $(MSBuildProjectDirectory)/../docs - --file-name openapi @@ -37,17 +41,45 @@ - - + all runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + PreserveNewest + PreserveNewest + true + + + PreserveNewest + PreserveNewest + true + + + + + + PreserveNewest + PreserveNewest + true + + + diff --git a/Canon.API/Controllers/CanonController.cs b/Canon.API/Controllers/CanonController.cs index 1417e0a..915c523 100644 --- a/Canon.API/Controllers/CanonController.cs +++ b/Canon.API/Controllers/CanonController.cs @@ -69,13 +69,13 @@ public async Task GetStatus() [HttpGet("flash")] [EndpointSummary("State of the \"flash firing\" setting")] - [EndpointDescription("The SDK only reports values set remotely: 'firing' is the value last set by the API (null before the first one). A change made in the camera menu is not visible. With Canon:ForceFlashFiring (default), the setting is set to Fire when the camera connects and before every capture.")] + [EndpointDescription("The SDK only reports values set remotely: 'firing' is the value last set by the API (null before the first one). A change made in the camera menu is not visible. With Canon:ForceFlashFiring (default), the setting is set to Fire when the camera connects, before the live view starts and before a capture taken without live view (never while the live view runs, which would freeze the camera).")] [ProducesResponseType(StatusCodes.Status200OK, "application/json")] public async Task GetFlash() => Ok(await camera.GetFlashStatus()); [HttpPost("flash")] [EndpointSummary("Set the \"flash firing\" setting (\"fire\" or \"off\")")] - [EndpointDescription("The camera must be in P, Tv, Av or M. With Canon:ForceFlashFiring (default), the setting is set back to Fire before the next capture.")] + [EndpointDescription("The camera must be in P, Tv, Av or M. A running live view is paused while the setting is written, because writing it during the live view freezes the camera. With Canon:ForceFlashFiring (default), the setting is set back to Fire the next time the live view starts or before the next capture taken without live view.")] [ProducesResponseType(StatusCodes.Status200OK, "application/json")] [ProducesResponseType(StatusCodes.Status400BadRequest, "application/problem+json")] [ProducesResponseType(StatusCodes.Status409Conflict, "application/problem+json")] diff --git a/Canon.API/Infrastructure/CameraExceptionHandler.cs b/Canon.API/Infrastructure/CameraExceptionHandler.cs index 50cb1b1..807f7bc 100644 --- a/Canon.API/Infrastructure/CameraExceptionHandler.cs +++ b/Canon.API/Infrastructure/CameraExceptionHandler.cs @@ -115,7 +115,7 @@ private static void AddSettingsExtensions(ProblemDetails problem, CameraSettings EdsException { IsRefused: true } => (StatusCodes.Status409Conflict, "Operation refused by the camera"), EdsException { IsInvalidValue: true } => (StatusCodes.Status400BadRequest, "Value not accepted by the camera"), EdsException => (StatusCodes.Status500InternalServerError, "Camera error"), - DllNotFoundException or BadImageFormatException => (StatusCodes.Status500InternalServerError, "Canon EDSDK could not be loaded (EDSDK.dll missing or wrong architecture)"), + DllNotFoundException or BadImageFormatException => (StatusCodes.Status500InternalServerError, "Canon EDSDK could not be loaded (EDSDK.dll or libEDSDK.so missing, wrong architecture, or libusb-1.0 missing on Linux)"), _ => (StatusCodes.Status500InternalServerError, "Internal server error") }; } diff --git a/Canon.API/Program.cs b/Canon.API/Program.cs index 5a0e1ea..24ab7db 100644 --- a/Canon.API/Program.cs +++ b/Canon.API/Program.cs @@ -1,6 +1,8 @@ using Canon.API.Infrastructure; using Serilog; +#if WINDOWS using AutoUpdaterDotNET; +#endif // Next to the executable, whatever the working directory (shortcut, scheduled task...). var logFile = Path.Combine(AppContext.BaseDirectory, "logs", "canon-api.log"); @@ -48,10 +50,14 @@ var app = builder.Build(); +#if WINDOWS if (app.Configuration.GetValue("AutoUpdate:Enabled", true)) ConfigureAutoUpdater(app); else Log.Information("Automatic updates are disabled"); +#else + Log.Information("Automatic updates are not available on this platform"); +#endif app.UseExceptionHandler(); @@ -75,6 +81,7 @@ Log.CloseAndFlush(); } +#if WINDOWS static void ConfigureAutoUpdater(WebApplication app) { // Configure AutoUpdater.NET for automatic updates @@ -99,3 +106,4 @@ static void ConfigureAutoUpdater(WebApplication app) AutoUpdater.Start(app.Configuration["AutoUpdate:Url"] ?? "https://dansleboby.github.io/CanonWebAPI/autoupdate.xml"); } +#endif diff --git a/Canon.Core.Tests/Canon.Core.Tests.csproj b/Canon.Core.Tests/Canon.Core.Tests.csproj index f5b5286..6759911 100644 --- a/Canon.Core.Tests/Canon.Core.Tests.csproj +++ b/Canon.Core.Tests/Canon.Core.Tests.csproj @@ -1,7 +1,7 @@ - net10.0-windows + net10.0 enable enable x64 diff --git a/Canon.Core.Tests/EdsdkHelperTests.cs b/Canon.Core.Tests/EdsdkHelperTests.cs index 57241f6..c3073f8 100644 --- a/Canon.Core.Tests/EdsdkHelperTests.cs +++ b/Canon.Core.Tests/EdsdkHelperTests.cs @@ -137,4 +137,23 @@ public void Capture_errors_are_translated(uint code, string message) { Assert.Equal(message, EdsdkHelper.GetCaptureErrorMessage(code)); } + + [Theory] + [InlineData(EDSDK.StateEvent_Shutdown, "StateEvent_Shutdown")] + [InlineData(EDSDK.StateEvent_CaptureError, "StateEvent_CaptureError")] + [InlineData(EDSDK.ObjectEvent_DirItemRequestTransfer, "ObjectEvent_DirItemRequestTransfer")] + [InlineData(EDSDK.PropertyEvent_PropertyChanged, "PropertyEvent_PropertyChanged")] + [InlineData(0xABCDu, "0xABCD")] + public void Events_are_named_after_their_constant(uint eventId, string name) + { + Assert.Equal(name, EdsdkHelper.DescribeEvent(eventId)); + } + + [Fact] + public void Results_are_described_with_their_code() + { + Assert.Equal("OK", EdsdkHelper.DescribeResult(EDSDK.EDS_ERR_OK)); + Assert.Equal("Device is busy (0x2019)", EdsdkHelper.DescribeResult(EDSDK.EDS_ERR_PTP_DEVICE_BUSY)); + Assert.Equal("0x12345678", EdsdkHelper.DescribeResult(0x12345678)); + } } diff --git a/Canon.Core.Tests/EdsdkInteropTests.cs b/Canon.Core.Tests/EdsdkInteropTests.cs index f06c031..3d06535 100644 --- a/Canon.Core.Tests/EdsdkInteropTests.cs +++ b/Canon.Core.Tests/EdsdkInteropTests.cs @@ -1,4 +1,5 @@ using System.Buffers.Binary; +using System.Reflection; using System.Runtime.InteropServices; using System.Text; using Canon.Core; @@ -6,10 +7,23 @@ namespace Canon.Core.Tests; /// -/// Layouts checked against EDSDKTypes.h (13.20.21), x64. +/// P/Invoke contract: library name, and layouts checked against EDSDKTypes.h (13.20.21), x64. /// public class EdsdkInteropTests { + [Fact] + public void Every_import_uses_the_cross_platform_library_name() + { + var imports = typeof(EDSDK).GetMethods(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Static) + .Select(method => method.GetCustomAttribute()) + .OfType() + .ToList(); + + // Guards against a reflection change returning nothing, which would make the test pass vacuously. + Assert.True(imports.Count > 50, $"{imports.Count} imports found"); + Assert.All(imports, import => Assert.Equal("EDSDK", import.Value)); + } + [Fact] public void Structures_have_the_size_of_the_headers() { diff --git a/Canon.Core/CameraModels.cs b/Canon.Core/CameraModels.cs index 14a14d1..71279d7 100644 --- a/Canon.Core/CameraModels.cs +++ b/Canon.Core/CameraModels.cs @@ -25,7 +25,10 @@ public sealed record CameraMode(uint AEModeCode, string AEMode, bool IsCreativeZ /// True: Fire, false: Off, null: unknown. The SDK only reports values set remotely: /// a change made in the camera menu is not visible until the value is set again by the API. /// -/// True when the setting is set to Fire before every capture (Canon:ForceFlashFiring). +/// +/// True when the setting is forced to Fire (Canon:ForceFlashFiring): when the camera connects, before the live view starts +/// and before a capture taken without live view. +/// /// Why the last attempt to set the flash failed, if it did. public sealed record FlashStatus(bool IsSupported, bool? Firing, bool ForcedBeforeCapture, string? LastError) { diff --git a/Canon.Core/Canon.Core.csproj b/Canon.Core/Canon.Core.csproj index 52604da..aae5df2 100644 --- a/Canon.Core/Canon.Core.csproj +++ b/Canon.Core/Canon.Core.csproj @@ -1,25 +1,12 @@  - net10.0-windows + net10.0 enable enable x64 - - - PreserveNewest - PreserveNewest - true - - - PreserveNewest - PreserveNewest - true - - - diff --git a/Canon.Core/CanonCamera.cs b/Canon.Core/CanonCamera.cs index 7ecc617..d60f80b 100644 --- a/Canon.Core/CanonCamera.cs +++ b/Canon.Core/CanonCamera.cs @@ -1,3 +1,4 @@ +using System.Diagnostics; using System.Runtime.InteropServices; using Microsoft.Extensions.Logging; @@ -37,12 +38,18 @@ private sealed class PendingCapture(IReadOnlySet fileTypes) private nint _cameraRef; private nint _flashRef; private bool? _flashFiring; + // Fire was written and the live view has not stopped since: starting the live view does not write it again. + // Each write keeps the camera busy for a moment, so a start retried after one would rewrite it in a loop. + private bool _flashFiringForced; private volatile string? _flashError; private volatile bool _connected; private volatile bool _liveViewActive; private volatile bool _liveViewRequested; private volatile bool _capturing; + // Set while the flash setting is written with the live view paused: frames are skipped and live view starts are + // left to it, so the live view is not restarted meanwhile. + private volatile bool _liveViewPaused; private volatile PendingCapture? _pendingCapture; private volatile CapturedImage? _latestImage; @@ -155,6 +162,8 @@ private void EnsureSdkInitialized() private void ConnectCore() { + // A hot plug retry can still be queued behind the cleanup of Dispose, which terminates the SDK. + ObjectDisposedException.ThrowIf(Volatile.Read(ref _disposed) != 0, this); EnsureSdkInitialized(); if (_cameraRef != nint.Zero) @@ -187,7 +196,8 @@ private void ConnectCore() // Handlers are registered before the session is opened, as in the SDK samples, so no event is missed. EDSDK.EdsSetObjectEventHandler(camera, EDSDK.ObjectEvent_All, _onCameraObject, nint.Zero).ThrowIfEdSdkError("Failed to subscribe to ObjectEvent"); - EDSDK.EdsSetCameraStateEventHandler(camera, EDSDK.StateEvent_All, _onCameraStateChanged, nint.Zero).ThrowIfEdSdkError("Failed to subscribe to StateEvent"); + // The camera is the context: a camera left to the SDK after a disconnection can still raise events (Linux). + EDSDK.EdsSetCameraStateEventHandler(camera, EDSDK.StateEvent_All, _onCameraStateChanged, camera).ThrowIfEdSdkError("Failed to subscribe to StateEvent"); EDSDK.EdsSetPropertyEventHandler(camera, EDSDK.PropertyEvent_All, _onCameraPropertyChanged, nint.Zero).ThrowIfEdSdkError("Failed to subscribe to PropertyEvent"); EDSDK.EdsOpenSession(camera).ThrowIfEdSdkError("Failed to open camera session"); @@ -222,7 +232,13 @@ private void ConnectCore() /// /// Closes the session and releases the camera. Runs on the Canon thread. /// - private void DisconnectCore(string reason, nint expectedCamera = 0) + /// + /// True when the camera was turned off or unplugged, or a call failed because it is disconnected. Its session is not + /// closed, as in the Canon samples. On Linux the SDK closes it itself while dispatching the removal, and releasing + /// the camera closes it again: the second close uses an object freed by the first one and crashes + /// (CLinuxPtpHelper::Terminate). The camera is then left to the SDK. + /// + private void DisconnectCore(string reason, nint expectedCamera = 0, bool cameraGone = false) { var camera = _cameraRef; @@ -246,9 +262,12 @@ private void DisconnectCore(string reason, nint expectedCamera = 0) _flashRef = nint.Zero; } _flashFiring = null; + _flashFiringForced = false; - EDSDK.EdsCloseSession(camera); - EDSDK.EdsRelease(camera); + if (!cameraGone) + EDSDK.EdsCloseSession(camera); + if (!cameraGone || !OperatingSystem.IsLinux()) + EDSDK.EdsRelease(camera); _logger?.LogWarning("Camera disconnected: {Reason}", reason); RaiseConnectionChanged(false); @@ -271,9 +290,7 @@ private uint OnCameraAdded(nint inContext) { try { - // Give the camera a moment to be ready before opening the session. - await Task.Delay(500); - await ConnectAsync(); + await ConnectPluggedCameraAsync(); if (_liveViewRequested) await StartLiveViewAsync(); @@ -288,6 +305,35 @@ private uint OnCameraAdded(nint inContext) return EDSDK.EDS_ERR_OK; } + /// + /// Connects to a camera that was just plugged in, retrying for about 10 s: on Linux the SDK can list the camera + /// only a moment after raising the camera added event (about 1 s on the EOS R100). + /// + private async Task ConnectPluggedCameraAsync() + { + const int attempts = 20; + + for (var attempt = 1; ; attempt++) + { + // Give the camera a moment to be ready before opening the session. + await Task.Delay(500); + + if (Volatile.Read(ref _disposed) != 0) + return; + + try + { + await ConnectAsync(); + _logger?.LogDebug("Plugged camera connected (attempt {Attempt})", attempt); + return; + } + catch (EdsException e) when (attempt < attempts) + { + _logger?.LogDebug("Plugged camera not ready (attempt {Attempt}): {Message}", attempt, e.Message); + } + } + } + /// /// Runs on the Canon thread with the connected camera. /// @@ -312,7 +358,7 @@ private async Task RunAsync(Func func) catch (EdsException e) when (e.IsDisconnected && e is not CameraNotConnectedException && usedCamera != nint.Zero) { // The session is no longer usable: drop it so the next call reconnects. - await _thread.InvokeAsync(() => DisconnectCore(e.Message, usedCamera)); + await _thread.InvokeAsync(() => DisconnectCore(e.Message, usedCamera, cameraGone: true)); throw; } } @@ -349,12 +395,28 @@ private Task RunWithBusyRetryAsync(Action action, CancellationToken cancel return true; }, cancellationToken); + /// + /// Runs an SDK call and logs its result and duration at Debug level. + /// + private uint TraceCall(string step, Func call) + { + if (_logger is not { } logger || !logger.IsEnabled(LogLevel.Debug)) + return call(); + + var start = Stopwatch.GetTimestamp(); + var result = call(); + logger.LogDebug("{Step}: {Result} ({Duration:0} ms)", step, EdsdkHelper.DescribeResult(result), Stopwatch.GetElapsedTime(start).TotalMilliseconds); + return result; + } + #endregion #region Event handlers private uint OnCameraPropertyChanged(uint inEvent, uint inPropertyId, uint inParam, nint inContext) { + _logger?.LogDebug("SDK event {Event}: property 0x{Property:X}, parameter 0x{Parameter:X}, thread {Thread}", EdsdkHelper.DescribeEvent(inEvent), inPropertyId, inParam, Environment.CurrentManagedThreadId); + if (inEvent != EDSDK.PropertyEvent_PropertyChanged || PropertyChanged == null) return EDSDK.EDS_ERR_OK; @@ -387,12 +449,18 @@ private uint OnCameraPropertyChanged(uint inEvent, uint inPropertyId, uint inPar /// private uint OnCameraStateChanged(uint inEvent, uint inParameter, nint inContext) { + _logger?.LogDebug("SDK event {Event}: parameter 0x{Parameter:X}, thread {Thread}", EdsdkHelper.DescribeEvent(inEvent), inParameter, Environment.CurrentManagedThreadId); + switch (inEvent) { case EDSDK.StateEvent_Shutdown: - // SDK calls are deferred until the callback has returned. - _connected = false; - _thread.Post(() => DisconnectCore("camera turned off or unplugged")); + // Ignored when raised by a camera that is no longer the current one. SDK calls are deferred until the + // callback has returned. + if (inContext == _cameraRef) + { + _connected = false; + _thread.Post(() => DisconnectCore("camera turned off or unplugged", inContext, cameraGone: true)); + } break; case EDSDK.StateEvent_WillSoonShutDown: @@ -421,7 +489,7 @@ private uint OnCameraStateChanged(uint inEvent, uint inParameter, nint inContext case EDSDK.StateEvent_InternalError: // The camera will probably not work properly anymore: drop the connection (EDSDK API reference 4.2.18). _logger?.LogError("EDSDK internal error 0x{Error:X}", inParameter); - _thread.Post(() => DisconnectCore("EDSDK internal error")); + _thread.Post(() => DisconnectCore("EDSDK internal error", inContext)); break; } @@ -439,6 +507,8 @@ private uint OnCameraProgress(uint inPercent, nint inContext, ref bool outCancel /// private uint OnCameraObject(uint inEvent, nint inRef, nint inContext) { + _logger?.LogDebug("SDK event {Event}, thread {Thread}", EdsdkHelper.DescribeEvent(inEvent), Environment.CurrentManagedThreadId); + try { if (inEvent == EDSDK.ObjectEvent_DirItemRequestTransfer && inRef != nint.Zero) @@ -475,10 +545,12 @@ private void TransferFile(nint dirItem) err.ThrowIfEdSdkError("Failed to get file info"); } + _logger?.LogDebug("Transfer requested: {File} ({Size} bytes), pending capture: {Pending}", info.szFileName, info.Size, pending != null); + if (!CaptureFileTypes.Matches(info.szFileName, pending?.FileTypes ?? _defaultFileTypes)) { _logger?.LogDebug("Skipping {File}: file type not requested", info.szFileName); - EDSDK.EdsDownloadCancel(dirItem); + TraceCall("Download cancel", () => EDSDK.EdsDownloadCancel(dirItem)); return; } @@ -512,14 +584,14 @@ private byte[] DownloadToMemory(nint dirItem, EDSDK.EdsDirectoryItemInfo info) // The progress callback must be registered before EdsDownload to be called during the transfer. EDSDK.EdsSetProgressCallback(stream, _onCameraProgress, EDSDK.EdsProgressOption.Periodically, nint.Zero); - err = EDSDK.EdsDownload(dirItem, info.Size, stream); + err = TraceCall("Download", () => EDSDK.EdsDownload(dirItem, info.Size, stream)); if (err != EDSDK.EDS_ERR_OK) { EDSDK.EdsDownloadCancel(dirItem); err.ThrowIfEdSdkError($"Failed to download file {info.szFileName}"); } - EDSDK.EdsDownloadComplete(dirItem).ThrowIfEdSdkError("Failed to complete download"); + TraceCall("Download complete", () => EDSDK.EdsDownloadComplete(dirItem)).ThrowIfEdSdkError("Failed to complete download"); return CopyStream(stream); } @@ -768,7 +840,7 @@ public async Task AutoFocus() try { await RunWithBusyRetryAsync(camera => - EDSDK.EdsSendCommand(camera, EDSDK.CameraCommand_PressShutterButton, (int)EDSDK.EdsShutterButton.CameraCommand_ShutterButton_Halfway) + TraceCall("Press shutter button halfway", () => EDSDK.EdsSendCommand(camera, EDSDK.CameraCommand_PressShutterButton, (int)EDSDK.EdsShutterButton.CameraCommand_ShutterButton_Halfway)) .ThrowIfEdSdkError("Could not send AF command")); try @@ -799,7 +871,7 @@ private async Task ReleaseShutterButtonAsync() try { await RunAsync(camera => - EDSDK.EdsSendCommand(camera, EDSDK.CameraCommand_PressShutterButton, (int)EDSDK.EdsShutterButton.CameraCommand_ShutterButton_OFF) + TraceCall("Release shutter button", () => EDSDK.EdsSendCommand(camera, EDSDK.CameraCommand_PressShutterButton, (int)EDSDK.EdsShutterButton.CameraCommand_ShutterButton_OFF)) .ThrowIfEdSdkError("Could not release the shutter button")); return; } @@ -833,6 +905,9 @@ public async Task TakePictureAsync(bool useAutoFocus = true, IEnu if (acceptedTypes.Count == 0) acceptedTypes = _defaultFileTypes; + var clock = Stopwatch.StartNew(); + _logger?.LogDebug("Capture requested (auto focus: {AutoFocus}, file types: {FileTypes})", useAutoFocus, string.Join(", ", acceptedTypes)); + await ConnectAsync(); await _shutterLock.WaitAsync(cancellationToken); @@ -840,9 +915,16 @@ public async Task TakePictureAsync(bool useAutoFocus = true, IEnu { await WaitForPendingTransfersAsync(cancellationToken); - // The setting may have been changed on the camera, and the SDK does not report it: set it again. + // The setting may have been changed on the camera, and the SDK does not report it: set it again, unless the + // live view runs (setting the flash then freezes the camera; it was set when the live view started). if (_options.ForceFlashFiring) - await RunAsync(camera => TrySetFlashFiring(camera, true)); + { + await RunAsync(camera => + { + if (!_liveViewActive) + TrySetFlashFiring(camera, true); + }); + } var timeout = await GetCaptureTimeout(); var pending = new PendingCapture(acceptedTypes); @@ -862,7 +944,7 @@ public async Task TakePictureAsync(bool useAutoFocus = true, IEnu try { await RunWithBusyRetryAsync(camera => - EDSDK.EdsSendCommand(camera, EDSDK.CameraCommand_PressShutterButton, (int)button) + TraceCall($"Press shutter button ({button})", () => EDSDK.EdsSendCommand(camera, EDSDK.CameraCommand_PressShutterButton, (int)button)) .ThrowIfEdSdkError("Could not press the shutter button"), cancellationToken); pressed = true; } @@ -871,10 +953,14 @@ await RunWithBusyRetryAsync(camera => await ReleaseShutterButtonAsync(); } - return await pending.Completion.Task.WaitAsync(timeout, cancellationToken); + _logger?.LogDebug("Waiting up to {Timeout} s for the file", timeout.TotalSeconds); + var image = await pending.Completion.Task.WaitAsync(timeout, cancellationToken); + _logger?.LogDebug("Capture completed in {Duration} ms: {File}", clock.ElapsedMilliseconds, image.FileName); + return image; } catch (TimeoutException) { + _logger?.LogDebug("Capture timed out after {Duration} ms", clock.ElapsedMilliseconds); _staleTransfers = true; throw new TimeoutException( $"The camera did not deliver a picture within {timeout.TotalSeconds:0.#} s. " + @@ -886,6 +972,11 @@ await RunWithBusyRetryAsync(camera => _staleTransfers = true; throw; } + catch (Exception e) when (e is not OperationCanceledException) + { + _logger?.LogDebug("Capture failed after {Duration} ms: {Error}", clock.ElapsedMilliseconds, e.Message); + throw; + } finally { _pendingCapture = null; @@ -980,6 +1071,10 @@ private void TrySetFlashFiring(nint camera, bool firing) /// private void SetFlashFiringCore(nint camera, bool firing) { + // Writing it while the live view runs freezes the camera (EOS R100) until it is turned off and on. + if (_liveViewActive) + throw new InvalidOperationException("The flash setting must not be written while the live view runs"); + try { if (_flashRef == nint.Zero) @@ -987,21 +1082,24 @@ private void SetFlashFiringCore(nint camera, bool firing) var target = string.Equals(_options.FlashTarget, "External", StringComparison.OrdinalIgnoreCase) ? 1u : 0u; - EDSDK.EdsSendStatusCommand(camera, EDSDK.CameraState_UILock, 1).ThrowIfEdSdkError("Could not lock the camera UI"); + TraceCall("UI lock", () => EDSDK.EdsSendStatusCommand(camera, EDSDK.CameraState_UILock, 1)).ThrowIfEdSdkError("Could not lock the camera UI"); try { - EDSDK.EdsSetPropertyData(_flashRef, EDSDK.PropID_Flash_Target, 0, sizeof(uint), target) + TraceCall("Flash target", () => EDSDK.EdsSetPropertyData(_flashRef, EDSDK.PropID_Flash_Target, 0, sizeof(uint), target)) .ThrowIfEdSdkError("Could not set the flash target"); - EDSDK.EdsSetPropertyData(_flashRef, EDSDK.PropID_Flash_Firing, 0, sizeof(uint), firing ? 1u : 0u) + TraceCall("Flash firing", () => EDSDK.EdsSetPropertyData(_flashRef, EDSDK.PropID_Flash_Firing, 0, sizeof(uint), firing ? 1u : 0u)) .ThrowIfEdSdkError("Could not set flash firing (the camera must be in P, Tv, Av or M)"); } finally { - EDSDK.EdsSendStatusCommand(camera, EDSDK.CameraState_UIUnLock, 0); + var unlock = TraceCall("UI unlock", () => EDSDK.EdsSendStatusCommand(camera, EDSDK.CameraState_UIUnLock, 0)); + if (unlock != EDSDK.EDS_ERR_OK) + _logger?.LogWarning("Could not unlock the camera UI: {Result}", EdsdkHelper.DescribeResult(unlock)); } _flashFiring = firing; + _flashFiringForced = firing; _flashError = null; } catch (EdsException e) @@ -1019,7 +1117,8 @@ public Task GetFlashStatus() => RunAsync(_ => var firing = _flashFiring; // The SDK only returns a value once it has been set remotely (EDSDK API reference 6.32.2). - if (firing.HasValue && _flashRef != nint.Zero + // Not read while the live view runs: writing it then freezes the camera, so it is not touched at all. + if (firing.HasValue && _flashRef != nint.Zero && !_liveViewActive && EDSDK.EdsGetPropertyData(_flashRef, EDSDK.PropID_Flash_Firing, 0, out uint value) == EDSDK.EDS_ERR_OK) firing = value != 0; @@ -1027,21 +1126,56 @@ public Task GetFlashStatus() => RunAsync(_ => }); /// - /// Sets the "flash firing" camera setting (Fire or Off). - /// With , it is set back to Fire before the next capture. + /// Sets the "flash firing" camera setting (Fire or Off). A running live view is paused meanwhile, because setting + /// the flash during the live view freezes the camera. + /// With , it is set back to Fire the next time the live view starts + /// or before the next capture taken without live view. /// Waits for a capture or an autofocus in progress to finish. /// public async Task SetFlashFiringAsync(bool firing, CancellationToken cancellationToken = default) { await _shutterLock.WaitAsync(cancellationToken); + _liveViewPaused = true; + var resumeLiveView = false; try { - await RunWithBusyRetryAsync(camera => SetFlashFiringCore(camera, firing), cancellationToken); + await RunWithBusyRetryAsync(camera => + { + if (_liveViewActive) + { + StopLiveViewCore(camera); + resumeLiveView = true; + } + + SetFlashFiringCore(camera, firing); + }, cancellationToken); } finally { - _shutterLock.Release(); + try + { + // Retried alone: the camera answers busy for a moment after the flash setting. + if (resumeLiveView) + { + await RunWithBusyRetryAsync(camera => + { + // Not when the last client left meanwhile: nothing would stop it again. + if (!_liveViewActive && _liveViewRequested) + StartLiveViewCore(camera); + }); + } + } + catch (Exception e) when (e is not OperationCanceledException) + { + // The outcome of the flash setting is what the caller needs; the next frame request restarts the live view. + _logger?.LogWarning("Could not restart the live view after the flash setting: {Message}", e.Message); + } + finally + { + _liveViewPaused = false; + _shutterLock.Release(); + } } } @@ -1052,28 +1186,46 @@ public async Task SetFlashFiringAsync(bool firing, CancellationToken cancellatio /// /// Starts streaming the live view to the PC (EDSDK API reference, sample 10). /// - public Task StartLiveViewAsync() + public async Task StartLiveViewAsync() { _liveViewRequested = true; - return RunWithBusyRetryAsync(camera => + // The flash setting being written restarts the live view itself. + if (_liveViewPaused) + return; + + // Setting the flash while the live view runs freezes the camera: this is the last chance until it stops. + if (_options.ForceFlashFiring) { - if (_liveViewActive) - return; + await RunAsync(camera => + { + if (!_liveViewActive && !_flashFiringForced) + TrySetFlashFiring(camera, true); + }); + } - // Live view cannot start when it is disabled in the camera settings. - if (EDSDK.EdsGetPropertyData(camera, EDSDK.PropID_Evf_Mode, 0, out uint evfMode) == EDSDK.EDS_ERR_OK && evfMode == 0) - EDSDK.EdsSetPropertyData(camera, EDSDK.PropID_Evf_Mode, 0, sizeof(uint), 1u).ThrowIfEdSdkError("Could not enable live view"); + // Retried alone: the camera answers busy for a moment after the flash setting. + await RunWithBusyRetryAsync(camera => + { + if (!_liveViewActive) + StartLiveViewCore(camera); + }); + } - EDSDK.EdsGetPropertyData(camera, EDSDK.PropID_Evf_OutputDevice, 0, out uint device); + private void StartLiveViewCore(nint camera) + { + // Live view cannot start when it is disabled in the camera settings. + if (EDSDK.EdsGetPropertyData(camera, EDSDK.PropID_Evf_Mode, 0, out uint evfMode) == EDSDK.EDS_ERR_OK && evfMode == 0) + EDSDK.EdsSetPropertyData(camera, EDSDK.PropID_Evf_Mode, 0, sizeof(uint), 1u).ThrowIfEdSdkError("Could not enable live view"); - var pcOutput = _options.LiveViewSmallImage ? EDSDK.EvfOutputDevice_PC_Small : EDSDK.EvfOutputDevice_PC; - device = _options.KeepCameraScreenOn ? device | pcOutput : pcOutput; + EDSDK.EdsGetPropertyData(camera, EDSDK.PropID_Evf_OutputDevice, 0, out uint device); - EDSDK.EdsSetPropertyData(camera, EDSDK.PropID_Evf_OutputDevice, 0, sizeof(uint), device).ThrowIfEdSdkError("Could not start live view"); - _liveViewActive = true; - _logger?.LogInformation("Live view started"); - }); + var pcOutput = _options.LiveViewSmallImage ? EDSDK.EvfOutputDevice_PC_Small : EDSDK.EvfOutputDevice_PC; + device = _options.KeepCameraScreenOn ? device | pcOutput : pcOutput; + + EDSDK.EdsSetPropertyData(camera, EDSDK.PropID_Evf_OutputDevice, 0, sizeof(uint), device).ThrowIfEdSdkError("Could not start live view"); + _liveViewActive = true; + _logger?.LogInformation("Live view started"); } /// @@ -1097,13 +1249,14 @@ await RunWithBusyRetryAsync(camera => private void StopLiveViewCore(nint camera) { - if (EDSDK.EdsGetPropertyData(camera, EDSDK.PropID_Evf_OutputDevice, 0, out uint device) == EDSDK.EDS_ERR_OK) - { - device &= ~(EDSDK.EvfOutputDevice_PC | EDSDK.EvfOutputDevice_PC_Small); - EDSDK.EdsSetPropertyData(camera, EDSDK.PropID_Evf_OutputDevice, 0, sizeof(uint), device).ThrowIfEdSdkError("Could not stop live view"); - } + // A failed read must not mark the live view stopped: a flash setting written next would freeze the camera. + EDSDK.EdsGetPropertyData(camera, EDSDK.PropID_Evf_OutputDevice, 0, out uint device).ThrowIfEdSdkError("Could not read the live view output"); + device &= ~(EDSDK.EvfOutputDevice_PC | EDSDK.EvfOutputDevice_PC_Small); + EDSDK.EdsSetPropertyData(camera, EDSDK.PropID_Evf_OutputDevice, 0, sizeof(uint), device).ThrowIfEdSdkError("Could not stop live view"); _liveViewActive = false; + // The camera buttons work again: the setting may change by hand until the live view restarts. + _flashFiringForced = false; _logger?.LogInformation("Live view stopped"); } @@ -1113,7 +1266,7 @@ private void StopLiveViewCore(nint camera) /// public async Task GetLiveView() { - if (_capturing) + if (_capturing || _liveViewPaused) return null; if (!_liveViewActive) @@ -1121,7 +1274,7 @@ private void StopLiveViewCore(nint camera) return await RunAsync(camera => { - if (_capturing) + if (_capturing || _liveViewPaused) return null; var evfImage = nint.Zero; diff --git a/Canon.Core/CanonCameraOptions.cs b/Canon.Core/CanonCameraOptions.cs index a4e08cd..d3a9f8d 100644 --- a/Canon.Core/CanonCameraOptions.cs +++ b/Canon.Core/CanonCameraOptions.cs @@ -49,8 +49,9 @@ public sealed class CanonCameraOptions public bool PreventAutoPowerOff { get; set; } = true; /// - /// Sets the "flash firing" camera setting to Fire when the camera connects and before every capture, - /// so a flash on the accessory shoe always fires even if the setting was changed on the camera. + /// Sets the "flash firing" camera setting to Fire when the camera connects, before the live view starts and before a + /// capture taken without live view, so a flash on the accessory shoe always fires even if the setting was changed on + /// the camera. Never while the live view runs: writing the flash setting then freezes the camera (EOS R100). /// The camera must be in a creative zone mode (P, Tv, Av, M). /// public bool ForceFlashFiring { get; set; } = true; diff --git a/Canon.Core/EDSDK.cs b/Canon.Core/EDSDK.cs index 093f840..0124b9b 100644 --- a/Canon.Core/EDSDK.cs +++ b/Canon.Core/EDSDK.cs @@ -8,7 +8,10 @@ namespace Canon.Core; internal class EDSDK { - [DllImport("EDSDK.dll")] + // Resolved to EDSDK.dll on Windows and libEDSDK.so on Linux. + private const string Library = "EDSDK"; + + [DllImport(Library)] public static extern uint EdsGetEvent(); #region Data Types @@ -1058,7 +1061,7 @@ Basic functions // // Returns: Any of the sdk errors. -----------------------------------------------------------------------------*/ - [DllImport("EDSDK.dll")] + [DllImport(Library)] public static extern uint EdsInitializeSDK(); /*----------------------------------------------------------------------------- @@ -1076,7 +1079,7 @@ Basic functions // // Returns: Any of the sdk errors. -----------------------------------------------------------------------------*/ - [DllImport("EDSDK.dll")] + [DllImport(Library)] public static extern uint EdsTerminateSDK(); @@ -1096,7 +1099,7 @@ Basic functions // // Returns: Any of the sdk errors. -----------------------------------------------------------------------------*/ - [DllImport("EDSDK.dll")] + [DllImport(Library)] public static extern uint EdsRetain(IntPtr inRef); /*----------------------------------------------------------------------------- @@ -1112,7 +1115,7 @@ Basic functions // Out: None // Returns: Any of the sdk errors. -----------------------------------------------------------------------------*/ - [DllImport("EDSDK.dll")] + [DllImport(Library)] public static extern uint EdsRelease(IntPtr inRef); @@ -1133,7 +1136,7 @@ Basic functions // // Returns: Any of the sdk errors. -----------------------------------------------------------------------------*/ - [DllImport("EDSDK.dll")] + [DllImport(Library)] public static extern uint EdsGetChildCount(IntPtr inRef, out int outCount); /*----------------------------------------------------------------------------- @@ -1151,7 +1154,7 @@ Basic functions // // Returns: Any of the sdk errors. -----------------------------------------------------------------------------*/ - [DllImport("EDSDK.dll")] + [DllImport(Library)] public static extern uint EdsGetChildAtIndex(IntPtr inRef, int inIndex, out IntPtr outRef); /*----------------------------------------------------------------------------- @@ -1167,7 +1170,7 @@ Basic functions // // Returns: Any of the sdk errors. -----------------------------------------------------------------------------*/ - [DllImport("EDSDK.dll")] + [DllImport(Library)] public static extern uint EdsGetParent(IntPtr inRef, out IntPtr outParentRef); @@ -1195,7 +1198,7 @@ Property operating functions // // Returns: Any of the sdk errors. -----------------------------------------------------------------------------*/ - [DllImport("EDSDK.dll")] + [DllImport(Library)] public static extern uint EdsGetPropertySize(IntPtr inRef, uint inPropertyID, int inParam, out EdsDataType outDataType, out int outSize); @@ -1218,7 +1221,7 @@ public static extern uint EdsGetPropertySize(IntPtr inRef, uint inPropertyID, in // // Returns: Any of the sdk errors. -----------------------------------------------------------------------------*/ - [DllImport("EDSDK.dll")] + [DllImport(Library)] public static extern uint EdsGetPropertyData(IntPtr inRef, uint inPropertyID, int inParam, int inPropertySize, IntPtr outPropertyData); @@ -1416,7 +1419,7 @@ public static EdsManualWBData MarshalPtrToManualWBData(IntPtr ptr) // // Returns: Any of the sdk errors. -----------------------------------------------------------------------------*/ - [DllImport("EDSDK.dll")] + [DllImport(Library)] public static extern uint EdsSetPropertyData(IntPtr inRef, uint inPropertyID, int inParam, int inPropertySize, [MarshalAs(UnmanagedType.AsAny)] [In] object inPropertyData); @@ -1436,7 +1439,7 @@ public static extern uint EdsSetPropertyData(IntPtr inRef, uint inPropertyID, // // Returns: Any of the sdk errors. -----------------------------------------------------------------------------*/ - [DllImport("EDSDK.dll")] + [DllImport(Library)] public static extern uint EdsGetPropertyDesc(IntPtr inRef, uint inPropertyID, out EdsPropertyDesc outPropertyDesc); @@ -1456,7 +1459,7 @@ public static extern uint EdsGetPropertyDesc(IntPtr inRef, uint inPropertyID, // // Returns: Any of the sdk errors. -----------------------------------------------------------------------------*/ - [DllImport("EDSDK.dll")] + [DllImport(Library)] public static extern uint EdsGetCameraList(out IntPtr outCameraListRef); /*-------------------------------------------- @@ -1479,7 +1482,7 @@ Camera operating functions // // Returns: Any of the sdk errors. -----------------------------------------------------------------------------*/ - [DllImport("EDSDK.dll")] + [DllImport(Library)] public static extern uint EdsGetDeviceInfo(IntPtr inCameraRef, out EdsDeviceInfo outDeviceInfo); /*----------------------------------------------------------------------------- @@ -1496,7 +1499,7 @@ Camera operating functions // // Returns: Any of the sdk errors. -----------------------------------------------------------------------------*/ - [DllImport("EDSDK.dll")] + [DllImport(Library)] public static extern uint EdsOpenSession(IntPtr inCameraRef); /*----------------------------------------------------------------------------- @@ -1512,7 +1515,7 @@ Camera operating functions // // Returns: Any of the sdk errors. -----------------------------------------------------------------------------*/ - [DllImport("EDSDK.dll")] + [DllImport(Library)] public static extern uint EdsCloseSession(IntPtr inCameraRef); /*----------------------------------------------------------------------------- @@ -1531,7 +1534,7 @@ Camera operating functions // // Returns: Any of the sdk errors. -----------------------------------------------------------------------------*/ - [DllImport("EDSDK.dll")] + [DllImport(Library)] public static extern uint EdsSendCommand(IntPtr inCameraRef, uint inCommand, int inParam); /*----------------------------------------------------------------------------- @@ -1550,7 +1553,7 @@ Camera operating functions // // Returns: Any of the sdk errors. -----------------------------------------------------------------------------*/ - [DllImport("EDSDK.dll")] + [DllImport(Library)] public static extern uint EdsSendStatusCommand(IntPtr inCameraRef, uint inCameraState, int inParam); /*----------------------------------------------------------------------------- @@ -1578,7 +1581,7 @@ Camera operating functions // // Returns: Any of the sdk errors. -----------------------------------------------------------------------------*/ - [DllImport("EDSDK.dll")] + [DllImport(Library)] public static extern uint EdsSetCapacity(IntPtr inCameraRef, EdsCapacity inCapacity); @@ -1598,7 +1601,7 @@ Volume operating functions // // Returns: Any of the sdk errors. -----------------------------------------------------------------------------*/ - [DllImport("EDSDK.dll")] + [DllImport(Library)] public static extern uint EdsGetVolumeInfo(IntPtr inCameraRef, out EdsVolumeInfo outVolumeInfo); /*----------------------------------------------------------------------------- @@ -1613,7 +1616,7 @@ Volume operating functions // // Returns: Any of the sdk errors. -----------------------------------------------------------------------------*/ - [DllImport("EDSDK.dll")] + [DllImport(Library)] public static extern uint EdsFormatVolume(IntPtr inVolumeRef); @@ -1634,7 +1637,7 @@ Volume operating functions // // Returns: Any of the sdk errors. -----------------------------------------------------------------------------*/ - [DllImport("EDSDK.dll")] + [DllImport(Library)] public static extern uint EdsGetDirectoryItemInfo(IntPtr inDirItemRef, out EdsDirectoryItemInfo outDirItemInfo); @@ -1655,7 +1658,7 @@ public static extern uint EdsGetDirectoryItemInfo(IntPtr inDirItemRef, // // Returns: Any of the sdk errors. -----------------------------------------------------------------------------*/ - [DllImport("EDSDK.dll")] + [DllImport(Library)] public static extern uint EdsDeleteDirectoryItem(IntPtr inDirItemRef); /*----------------------------------------------------------------------------- @@ -1678,7 +1681,7 @@ public static extern uint EdsGetDirectoryItemInfo(IntPtr inDirItemRef, // // Returns: Any of the sdk errors. -----------------------------------------------------------------------------*/ - [DllImport("EDSDK.dll")] + [DllImport(Library)] public static extern uint EdsDownload(IntPtr inDirItemRef, ulong inReadSize, IntPtr outStream); /*----------------------------------------------------------------------------- @@ -1696,7 +1699,7 @@ public static extern uint EdsGetDirectoryItemInfo(IntPtr inDirItemRef, // // Returns: Any of the sdk errors. -----------------------------------------------------------------------------*/ - [DllImport("EDSDK.dll")] + [DllImport(Library)] public static extern uint EdsDownloadCancel(IntPtr inDirItemRef); /*----------------------------------------------------------------------------- @@ -1716,7 +1719,7 @@ public static extern uint EdsGetDirectoryItemInfo(IntPtr inDirItemRef, // // Returns: Any of the sdk errors. -----------------------------------------------------------------------------*/ - [DllImport("EDSDK.dll")] + [DllImport(Library)] public static extern uint EdsDownloadComplete(IntPtr inDirItemRef); /*----------------------------------------------------------------------------- @@ -1736,7 +1739,7 @@ public static extern uint EdsGetDirectoryItemInfo(IntPtr inDirItemRef, // // Returns: Any of the sdk errors. -----------------------------------------------------------------------------*/ - [DllImport("EDSDK.dll")] + [DllImport(Library)] public static extern uint EdsDownloadThumbnail(IntPtr inDirItemRef, IntPtr outStream); /*----------------------------------------------------------------------------- @@ -1756,7 +1759,7 @@ public static extern uint EdsGetDirectoryItemInfo(IntPtr inDirItemRef, // // Returns: Any of the sdk errors. -----------------------------------------------------------------------------*/ - [DllImport("EDSDK.dll")] + [DllImport(Library)] public static extern uint EdsGetAttribute(IntPtr inDirItemRef, out EdsFileAttribute outFileAttribute); /*----------------------------------------------------------------------------- @@ -1775,7 +1778,7 @@ public static extern uint EdsGetDirectoryItemInfo(IntPtr inDirItemRef, // // Returns: Any of the sdk errors. -----------------------------------------------------------------------------*/ - [DllImport("EDSDK.dll")] + [DllImport(Library)] public static extern uint EdsSetAttribute(IntPtr inDirItemRef, EdsFileAttribute inFileAttribute); /*-------------------------------------------- @@ -1802,7 +1805,7 @@ Stream operating functions // // Returns: Any of the sdk errors. -----------------------------------------------------------------------------*/ - [DllImport("EDSDK.dll")] + [DllImport(Library)] public static extern uint EdsCreateFileStream(string inFileName, EdsFileCreateDisposition inCreateDisposition, EdsAccess inDesiredAccess, out IntPtr outStream); @@ -1821,7 +1824,7 @@ public static extern uint EdsCreateFileStream(string inFileName, EdsFileCreateDi // // Returns: Any of the sdk errors. -----------------------------------------------------------------------------*/ - [DllImport("EDSDK.dll")] + [DllImport(Library)] public static extern uint EdsCreateMemoryStream(ulong inBufferSize, out IntPtr outStream); /*----------------------------------------------------------------------------- @@ -1842,7 +1845,7 @@ public static extern uint EdsCreateFileStream(string inFileName, EdsFileCreateDi // // Returns: Any of the sdk errors. -----------------------------------------------------------------------------*/ - [DllImport("EDSDK.dll")] + [DllImport(Library)] public static extern uint EdsCreateFileStreamEx( [MarshalAs(UnmanagedType.LPWStr)] string inFileName, EdsFileCreateDisposition inCreateDisposition, @@ -1865,7 +1868,7 @@ out IntPtr outStream // // Returns: Any of the sdk errors. -----------------------------------------------------------------------------*/ - [DllImport("EDSDK.dll")] + [DllImport(Library)] public static extern uint EdsCreateMemoryStreamFromPointer(IntPtr inUserBuffer, ulong inBufferSize, out IntPtr outStream); @@ -1890,7 +1893,7 @@ public static extern uint EdsCreateMemoryStreamFromPointer(IntPtr inUserBuffer, // // Returns: Any of the sdk errors. -----------------------------------------------------------------------------*/ - [DllImport("EDSDK.dll")] + [DllImport(Library)] public static extern uint EdsGetPointer(IntPtr inStreamRef, out IntPtr outPointer); /*----------------------------------------------------------------------------- @@ -1911,7 +1914,7 @@ public static extern uint EdsCreateMemoryStreamFromPointer(IntPtr inUserBuffer, // // Returns: Any of the sdk errors. -----------------------------------------------------------------------------*/ - [DllImport("EDSDK.dll")] + [DllImport(Library)] public static extern uint EdsRead(IntPtr inStreamRef, ulong inReadSize, IntPtr outBuffer, out ulong outReadSize); @@ -1932,7 +1935,7 @@ public static extern uint EdsRead(IntPtr inStreamRef, ulong inReadSize, IntPtr o // // Returns: Any of the sdk errors. -----------------------------------------------------------------------------*/ - [DllImport("EDSDK.dll")] + [DllImport(Library)] public static extern uint EdsWrite(IntPtr inStreamRef, ulong inWriteSize, IntPtr inBuffer, out ulong outWrittenSize); @@ -1958,7 +1961,7 @@ public static extern uint EdsWrite(IntPtr inStreamRef, ulong inWriteSize, IntPtr // // Returns: Any of the sdk errors. -----------------------------------------------------------------------------*/ - [DllImport("EDSDK.dll")] + [DllImport(Library)] public static extern uint EdsSeek(IntPtr inStreamRef, long inSeekOffset, EdsSeekOrigin inSeekOrigin); /*----------------------------------------------------------------------------- @@ -1975,7 +1978,7 @@ public static extern uint EdsWrite(IntPtr inStreamRef, ulong inWriteSize, IntPtr // // Returns: Any of the sdk errors. -----------------------------------------------------------------------------*/ - [DllImport("EDSDK.dll")] + [DllImport(Library)] public static extern uint EdsGetPosition(IntPtr inStreamRef, out ulong outPosition); /*----------------------------------------------------------------------------- @@ -1991,7 +1994,7 @@ public static extern uint EdsWrite(IntPtr inStreamRef, ulong inWriteSize, IntPtr // // Returns: Any of the sdk errors. -----------------------------------------------------------------------------*/ - [DllImport("EDSDK.dll")] + [DllImport(Library)] public static extern uint EdsGetLength(IntPtr inStreamRef, out ulong outLength); /*----------------------------------------------------------------------------- @@ -2013,7 +2016,7 @@ public static extern uint EdsWrite(IntPtr inStreamRef, ulong inWriteSize, IntPtr // // Returns: Any of the sdk errors. -----------------------------------------------------------------------------*/ - [DllImport("EDSDK.dll")] + [DllImport(Library)] public static extern uint EdsCopyData(IntPtr inStreamRef, ulong inWriteSize, IntPtr outStreamRef); /*----------------------------------------------------------------------------- @@ -2046,7 +2049,7 @@ public static extern uint EdsWrite(IntPtr inStreamRef, ulong inWriteSize, IntPtr // // Returns: Any of the sdk errors. -----------------------------------------------------------------------------*/ - [DllImport("EDSDK.dll")] + [DllImport(Library)] public static extern uint EdsSetProgressCallback(IntPtr inRef, EdsProgressCallback inProgressFunc, EdsProgressOption inProgressOption, IntPtr inContext); @@ -2074,7 +2077,7 @@ Image operating functions // // Returns: Any of the sdk errors. -----------------------------------------------------------------------------*/ - [DllImport("EDSDK.dll")] + [DllImport(Library)] public static extern uint EdsCreateImageRef(IntPtr inStreamRef, out IntPtr outImageRef); /*----------------------------------------------------------------------------- @@ -2104,7 +2107,7 @@ Image operating functions // // Returns: Any of the sdk errors. -----------------------------------------------------------------------------*/ - [DllImport("EDSDK.dll")] + [DllImport(Library)] public static extern uint EdsGetImageInfo(IntPtr inImageRef, EdsImageSource inImageSource, out EdsImageInfo outImageInfo); @@ -2148,7 +2151,7 @@ public static extern uint EdsGetImageInfo(IntPtr inImageRef, EdsImageSource inIm // the image. // Returns: Any of the sdk errors. -----------------------------------------------------------------------------*/ - [DllImport("EDSDK.dll")] + [DllImport(Library)] public static extern uint EdsGetImage(IntPtr inImageRef, EdsImageSource inImageSource, EdsTargetImageType inImageType, EdsRect inSrcRect, EdsSize inDstSize, IntPtr outStreamRef); @@ -2171,7 +2174,7 @@ public static extern uint EdsGetImage(IntPtr inImageRef, EdsImageSource inImageS // // Returns: Any of the sdk errors. -----------------------------------------------------------------------------*/ - [DllImport("EDSDK.dll")] + [DllImport(Library)] public static extern uint EdsSetCameraAddedHandler(EdsCameraAddedHandler inCameraAddedHandler, IntPtr inContext); @@ -2195,7 +2198,7 @@ public static extern uint EdsSetCameraAddedHandler(EdsCameraAddedHandler inCamer // // Returns: Any of the sdk errors. -----------------------------------------------------------------------------*/ - [DllImport("EDSDK.dll")] + [DllImport(Library)] public static extern uint EdsSetPropertyEventHandler(IntPtr inCameraRef, uint inEvnet, EdsPropertyEventHandler inPropertyEventHandler, IntPtr inContext); @@ -2221,7 +2224,7 @@ public static extern uint EdsSetPropertyEventHandler(IntPtr inCameraRef, uint in // // Returns: Any of the sdk errors. -----------------------------------------------------------------------------*/ - [DllImport("EDSDK.dll")] + [DllImport(Library)] public static extern uint EdsSetObjectEventHandler(IntPtr inCameraRef, uint inEvnet, EdsObjectEventHandler inObjectEventHandler, IntPtr inContext); @@ -2246,7 +2249,7 @@ public static extern uint EdsSetObjectEventHandler(IntPtr inCameraRef, uint inEv // // Returns: Any of the sdk errors. -----------------------------------------------------------------------------*/ - [DllImport("EDSDK.dll")] + [DllImport(Library)] public static extern uint EdsSetCameraStateEventHandler(IntPtr inCameraRef, uint inEvnet, EdsStateEventHandler inStateEventHandler, IntPtr inContext); @@ -2266,7 +2269,7 @@ public static extern uint EdsSetCameraStateEventHandler(IntPtr inCameraRef, uint // // Returns: Any of the sdk errors. ------------------------------------------------------------------------------*/ - [DllImport("EDSDK.dll")] + [DllImport(Library)] public static extern uint EdsSetFramePoint(IntPtr inCameraRef, EdsPoint inFramePoint, [MarshalAs(UnmanagedType.U1)] bool inLockAfFrame); /*----------------------------------------------------------------------------- @@ -2281,7 +2284,7 @@ public static extern uint EdsSetCameraStateEventHandler(IntPtr inCameraRef, uint // // Returns: Any of the sdk errors. -----------------------------------------------------------------------------*/ - [DllImport("EDSDK.dll")] + [DllImport(Library)] public static extern uint EdsCreateEvfImageRef(IntPtr inStreamRef, out IntPtr outEvfImageRef); @@ -2304,7 +2307,7 @@ public static extern uint EdsSetCameraStateEventHandler(IntPtr inCameraRef, uint // // Returns: Any of the sdk errors. -----------------------------------------------------------------------------*/ - [DllImport("EDSDK.dll")] + [DllImport(Library)] public static extern uint EdsDownloadEvfImage(IntPtr inCameraRef, IntPtr outEvfImageRef); /*----------------------------------------------------------------------------- @@ -2315,7 +2318,7 @@ public static extern uint EdsSetCameraStateEventHandler(IntPtr inCameraRef, uint // Same as EdsGetPropertyDesc, for properties with 64-bit values // (e.g. kEdsPropID_MovieParamEx). Added in EDSDK 13.19.0. -----------------------------------------------------------------------------*/ - [DllImport("EDSDK.dll")] + [DllImport(Library)] public static extern uint EdsGetPropertyDescEx(IntPtr inRef, uint inPropertyID, out EdsPropertyDescEx outPropertyDesc); @@ -2335,13 +2338,13 @@ public static extern uint EdsGetPropertyDescEx(IntPtr inRef, uint inPropertyID, // Creates the object used to get and set flash-related settings // (kEdsPropID_Flash_Target, kEdsPropID_Flash_Firing). Added in EDSDK 13.19.0. -----------------------------------------------------------------------------*/ - [DllImport("EDSDK.dll")] + [DllImport(Library)] public static extern uint EdsCreateFlashSettingRef(IntPtr inCameraRef, out IntPtr outFlashRef); - [DllImport("EDSDK.dll")] + [DllImport(Library)] public static extern uint EdsGetCsdFileData(IntPtr inCameraRef, IntPtr outStream); - [DllImport("EDSDK.dll")] + [DllImport(Library)] public static extern uint EdsSetCsdFileData(IntPtr inCameraRef, int inDataSize, IntPtr inDataValue); #endregion diff --git a/Canon.Core/EdsdkHelper.cs b/Canon.Core/EdsdkHelper.cs index cafc124..9389caf 100644 --- a/Canon.Core/EdsdkHelper.cs +++ b/Canon.Core/EdsdkHelper.cs @@ -1,4 +1,6 @@ -namespace Canon.Core; +using System.Reflection; + +namespace Canon.Core; internal static class EdsdkHelper { @@ -171,6 +173,28 @@ public static void ThrowIfEdSdkError(this uint error, string message) public static string GetErrorMessage(uint errorCode) => ErrorMessages.TryGetValue(errorCode, out var message) ? message : $"EDSDK error 0x{errorCode:X8}"; + /// + /// "OK", the error message followed by its code, or the code alone when unknown. + /// + public static string DescribeResult(uint errorCode) => + errorCode == EDSDK.EDS_ERR_OK ? "OK" + : ErrorMessages.TryGetValue(errorCode, out var message) ? $"{message} ({FormatRawValue(errorCode)})" + : FormatRawValue(errorCode); + + // Property, object and state event constants of EDSDK, by value ("*_All" are subscription masks, not events). + private static readonly Dictionary EventNames = typeof(EDSDK) + .GetFields(BindingFlags.Public | BindingFlags.Static) + .Where(field => field.IsLiteral && field.FieldType == typeof(uint) && !field.Name.EndsWith("_All") + && (field.Name.StartsWith("PropertyEvent_") || field.Name.StartsWith("ObjectEvent_") || field.Name.StartsWith("StateEvent_"))) + .GroupBy(field => (uint)field.GetRawConstantValue()!) + .ToDictionary(group => group.Key, group => group.First().Name); + + /// + /// Name of an EDSDK event (e.g. "StateEvent_Shutdown"), or its code when unknown. + /// + public static string DescribeEvent(uint eventId) => + EventNames.TryGetValue(eventId, out var name) ? name : FormatRawValue(eventId); + /// /// Values of kEdsPropID_Av (EDSDK API reference 5.2.25). /// "(1/3)" labels are the values used when the exposure step is set to 1/3 in the custom functions. diff --git a/Directory.Build.props b/Directory.Build.props index f91b780..de8c0e7 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -1,6 +1,6 @@ - + true diff --git a/Dockerfile b/Dockerfile index cb4e5ca..fa2c10c 100644 --- a/Dockerfile +++ b/Dockerfile @@ -46,7 +46,7 @@ RUN dotnet test Canon.Core.Tests/Canon.Core.Tests.csproj -c Release --no-build ` # Publish: same self-contained single-file executable as the GitHub release # --------------------------------------------------------------------------- FROM test AS publish -RUN dotnet publish Canon.API/Canon.API.csproj -c Release -r win-x64 --self-contained true ` +RUN dotnet publish Canon.API/Canon.API.csproj -c Release -f net10.0-windows -r win-x64 --self-contained true ` -p:PublishSingleFile=true --no-restore -o C:\publish # --------------------------------------------------------------------------- diff --git a/EDSDK/linux-x64/libEDSDK.so b/EDSDK/linux-x64/libEDSDK.so new file mode 100644 index 0000000..21a6fe2 Binary files /dev/null and b/EDSDK/linux-x64/libEDSDK.so differ diff --git a/README.md b/README.md index c1d71ad..7cde9c1 100644 --- a/README.md +++ b/README.md @@ -28,7 +28,7 @@ A web API for remotely controlling Canon DSLR and mirrorless cameras. This proje * Retrieve the last taken picture. * Automatic reconnection when the camera is turned off or unplugged and plugged back (hot plug). * OpenAPI document and Swagger UI. -* **Automatic updates** via AutoUpdater.NET integration. +* **Automatic updates** via AutoUpdater.NET integration (Windows). * **Version display** in console on startup. ## Project Structure @@ -39,7 +39,7 @@ The solution is divided into the following projects: * `Canon.Core`: A .NET library that wraps the Canon EDSDK, providing a higher-level interface to interact with the camera. * `Canon.Core.Tests`: Unit tests (xUnit) of `Canon.Core` that do not need a camera. * `Canon.API.Tests`: Unit tests (xUnit) of the error mapping, the request models and the log levels of `Canon.API`. -* `EDSDK`: Contains the Canon EDSDK 13.20.21 64-bit libraries (`EDSDK.dll`, `EdsImage.dll`). +* `EDSDK`: Canon EDSDK 13.20.21 64-bit libraries: `EDSDK.dll` and `EdsImage.dll` (Windows), `linux-x64/libEDSDK.so` (Linux). ## Getting Started @@ -49,12 +49,12 @@ The solution is divided into the following projects: * The camera connected to the computer via USB. * **.NET 10 SDK** (or newer) to build the project. * The Canon EOS Utility software should not be running, as it can prevent this application from connecting to the camera. -* **Windows** operating system (x64 architecture required). +* **Windows** or **Linux** (x64). On Linux, see [Running on Linux](#running-on-linux). ### Installation 1. Clone this repository. -2. Ensure the `EDSDK` folder, containing `EDSDK.dll` and `EdsImage.dll`, is present in the project's root directory. These files are essential for the `Canon.Core` library to communicate with the camera. +2. Ensure the `EDSDK` folder is present in the project's root directory: `EDSDK.dll` and `EdsImage.dll` on Windows, `linux-x64/libEDSDK.so` on Linux. These files are essential for the `Canon.Core` library to communicate with the camera. 3. Build the solution using Visual Studio or the `dotnet build` command. 4. Run the `Canon.API` project. This will start the web server, on `http://localhost:5000` by default for the release executable (`http://localhost:5159` with `dotnet run`); set `ASPNETCORE_URLS` (or `--urls`) to change it. @@ -73,7 +73,7 @@ Setting values are sent as a JSON string, e.g. `"100"` with `Content-Type: appli | GET | `/mode` | Gets the shooting mode (mode dial position, whether exposure settings can be changed, movie mode). | N/A | | GET | `/temperature` | Gets the restrictions applied by the camera because of its internal temperature. | N/A | | GET | `/flash` | Gets the "flash firing" setting as last set by the API (a change made in the camera menu is not reported by the SDK). | N/A | -| POST | `/flash` | Sets the "flash firing" setting. The camera must be in P, Tv, Av or M. | `"fire"` or `"off"` | +| POST | `/flash` | Sets the "flash firing" setting. The camera must be in P, Tv, Av or M. A running live view is paused while the setting is written. | `"fire"` or `"off"` | | GET | `/settings` | Shooting mode, plus ISO, aperture, shutter speed, exposure compensation and white balance in one call: `{ value, supportedValues, settable }` each (`settable`: allowed by the shooting mode and the camera accepts at least one value now). | N/A | | POST | `/settings` | Applies several settings at once (e.g. a day / night preset). Every value is checked before anything is written, then they are written in a fixed order (iso, aperture, shutterSpeed, exposureCompensation, whiteBalance), never during a capture. Returns the settings read back from the camera, as `GET /settings`. Omitted fields are left unchanged; an unknown field is refused (400). | `{ "iso": "400", "aperture": "5.6", "shutterSpeed": "1/125", "exposureCompensation": "0", "whiteBalance": "Daylight" }` | | GET | `/iso` | Gets the current ISO speed and a list of supported values. | N/A | @@ -166,15 +166,15 @@ settings that differ from the defaults, e.g.: | `Canon:LiveViewSmallImage` | `false` | Uses the smaller live view image (less bandwidth, lower resolution; not supported by every camera). | | `Canon:KeepCameraScreenOn` | `false` | Keeps the camera screen on during the live view. When `false`, the live view is sent to the PC only, which turns the camera screen off and locks its buttons. | | `Canon:PreventAutoPowerOff` | `true` | Extends the camera auto power off timer when the camera announces it will turn off. | -| `Canon:ForceFlashFiring` | `true` | Sets "flash firing" to Fire when the camera connects and before every capture, so a flash on the accessory shoe always fires even if the setting was changed on the camera. Requires P, Tv, Av or M. | +| `Canon:ForceFlashFiring` | `true` | Sets "flash firing" to Fire when the camera connects, before the live view starts and before a capture taken without live view, so a flash on the accessory shoe always fires even if the setting was changed on the camera. Never while the live view runs: writing the flash setting then freezes the camera. Requires P, Tv, Av or M. | | `Canon:FlashTarget` | `Unspecified` | Target of the flash settings: `Unspecified` (e.g. studio flash triggered by the shoe center contact) or `External` (flash communicating with the camera). | | `Canon:BusyRetryCount` / `BusyRetryDelayMilliseconds` | `3` / `500` | Retries when the camera answers "device busy". | | `LiveView:FrameIntervalMilliseconds` | `30` | Delay between two live view frames. | | `LiveView:IdleStopDelayMilliseconds` | `3000` | The camera live view stops this long after the last `/videostream` client left. | | `LiveView:ErrorRetryDelayMilliseconds` | `1000` | Delay before retrying after a live view error (e.g. camera disconnected). | -| `AutoUpdate:Enabled` | `true` | Checks for updates at startup. | -| `AutoUpdate:Url` | GitHub Pages `autoupdate.xml` | Update feed read by AutoUpdater.NET. | -| `Logging:LogLevel` | `Default`: `Information`, `Microsoft.AspNetCore`: `Warning` | Minimum log level, by category prefix (`Trace`, `Debug`, `Information`, `Warning`, `Error`, `Critical`, `None`). | +| `AutoUpdate:Enabled` | `true` | Checks for updates at startup. Windows only. | +| `AutoUpdate:Url` | GitHub Pages `autoupdate.xml` | Update feed read by AutoUpdater.NET. Windows only. | +| `Logging:LogLevel` | `Default`: `Information`, `Microsoft.AspNetCore`: `Warning` | Minimum log level, by category prefix (`Trace`, `Debug`, `Information`, `Warning`, `Error`, `Critical`, `None`). `Canon.Core`: `Debug` logs every EDSDK event and each step of a capture (UI lock, flash settings, shutter button, transfer) with its result and duration. | ## Automatic Updates @@ -185,6 +185,8 @@ This application includes automatic update functionality powered by AutoUpdater. * Updates are downloaded from GitHub releases automatically * `appsettings.json` is replaced by each update: keep the settings of a photo booth in `appsettings.Local.json` +Linux has no automatic update yet (the `net10.0` target does not include AutoUpdater.NET). + ## Development & Building ### Building from Source @@ -201,7 +203,7 @@ dotnet restore CanonSDK.sln dotnet build CanonSDK.sln --configuration Release # Run the API -dotnet run --project Canon.API +dotnet run --project Canon.API -f net10.0-windows # on Linux: -f net10.0 ``` ### Creating Releases @@ -220,7 +222,36 @@ This project uses automated GitHub Actions for releases: ### Running the API ```bash -dotnet run --project Canon.API +dotnet run --project Canon.API -f net10.0-windows # on Linux: -f net10.0 +``` + +### Running on Linux + +The `net10.0` target of `Canon.API` runs on Linux x64 with the Linux version of the EDSDK (`EDSDK/linux-x64/libEDSDK.so`). +There is no automatic update and no release package for Linux yet: run it from the sources. + +Prerequisites: .NET 10 SDK and `libusb-1.0` (Ubuntu: `sudo apt install dotnet-sdk-10.0 libusb-1.0-0`). + +```bash +dotnet run --project Canon.API -f net10.0 --launch-profile http + +# Every EDSDK event and each step of a capture (result and duration) in the logs: +dotnet run --project Canon.API -f net10.0 --launch-profile http -- --Logging:LogLevel:Canon.Core=Debug +``` + +* The user running the API needs read/write access to the camera USB device; desktop sessions get it automatically. +* Nothing else may use the camera: if the desktop mounted it, unmount it (GNOME: `gio mount -l`, then `gio mount -u `), and do not pass it to a virtual machine. +* Logs: console and `Canon.API/bin/Debug/net10.0/logs/`. + +### Camera Test Script + +`tools/Test-Camera.ps1` (Windows PowerShell 5.1 or PowerShell 7, with `curl`) runs the photo booth checks against the API: +live view, captures, flash changes during the live view and a camera power cycle. Copy it with `tools/Test-Camera.cmd` +next to `Canon.API.exe`, then run `Test-Camera.cmd`. It starts the API with detailed camera logs when it does not +answer, and saves the pictures, the result of every step and the API log in `test-results\`. + +```powershell +.\Test-Camera.ps1 -Captures 6 -SkipPowerCycle # options: -BaseUrl, -CameraTimeoutSeconds, -NoBrowser ``` ### Unit Tests @@ -258,12 +289,16 @@ Windows containers cannot access USB devices, so the camera is not reachable fro ### OpenAPI Document -The document is always available at `/openapi/v1.json`. To regenerate `docs/openapi.json` at build time (on Windows): +The document is always available at `/openapi/v1.json`. To regenerate `docs/openapi.json` at build time (Windows or Linux): ```bash -dotnet build Canon.API -p:OpenApiGenerateDocumentsOnBuild=true +dotnet build Canon.API -f net10.0 -t:Build -t:GenerateOpenApiDocuments ``` +The build-time hook of the `Microsoft.Extensions.ApiDescription.Server` package skips the inner builds of a multi-targeted +project, so `-p:OpenApiGenerateDocumentsOnBuild=true` with `-f` generates nothing; the `net10.0` target keeps the Windows +update check out of the generator, which runs the application's entry point. + ## Compatibility ### Tested Camera Models @@ -271,8 +306,12 @@ dotnet build Canon.API -p:OpenApiGenerateDocumentsOnBuild=true * **Canon T7** ✅ ### System Requirements -* **OS**: Windows 10/11 (x64) +* **OS**: Windows 10/11 (x64); Linux x64 from the sources (see [Running on Linux](#running-on-linux)) * **Runtime**: none to install: the release is a self-contained executable (.NET 10) * **Dependencies**: Canon EDSDK 13.20.21 64-bit libraries (included; up to 1.0.0.11: EDSDK 13.19.0) > **Note**: While this software has been tested with the above camera models, it should work with other Canon cameras that support the EDSDK. However, functionality may vary depending on the specific camera model and its supported features. + +## License notices + +The Canon EDSDK libraries in `EDSDK/` are based in part on the work of the Independent JPEG Group. diff --git a/build.bat b/build.bat index cccc2b3..dba1912 100644 --- a/build.bat +++ b/build.bat @@ -15,7 +15,7 @@ dotnet build Canon.API --configuration Release --no-restore rem Publish Canon.API for deployment as standalone executable echo Publishing Canon.API as standalone executable... -dotnet publish Canon.API/Canon.API.csproj --configuration Release --runtime win-x64 --self-contained true -p:PublishSingleFile=true --output ./publish --verbosity minimal +dotnet publish Canon.API/Canon.API.csproj --configuration Release --framework net10.0-windows --runtime win-x64 --self-contained true -p:PublishSingleFile=true --output ./publish --verbosity minimal echo. echo Build completed successfully! diff --git a/docs/openapi.json b/docs/openapi.json index 449e040..a25addb 100644 --- a/docs/openapi.json +++ b/docs/openapi.json @@ -165,7 +165,7 @@ "Canon" ], "summary": "State of the \"flash firing\" setting", - "description": "The SDK only reports values set remotely: 'firing' is the value last set by the API (null before the first one). A change made in the camera menu is not visible. With Canon:ForceFlashFiring (default), the setting is set to Fire when the camera connects and before every capture.", + "description": "The SDK only reports values set remotely: 'firing' is the value last set by the API (null before the first one). A change made in the camera menu is not visible. With Canon:ForceFlashFiring (default), the setting is set to Fire when the camera connects, before the live view starts and before a capture taken without live view (never while the live view runs, which would freeze the camera).", "responses": { "503": { "description": "Service Unavailable", @@ -204,7 +204,7 @@ "Canon" ], "summary": "Set the \"flash firing\" setting (\"fire\" or \"off\")", - "description": "The camera must be in P, Tv, Av or M. With Canon:ForceFlashFiring (default), the setting is set back to Fire before the next capture.", + "description": "The camera must be in P, Tv, Av or M. A running live view is paused while the setting is written, because writing it during the live view freezes the camera. With Canon:ForceFlashFiring (default), the setting is set back to Fire the next time the live view starts or before the next capture taken without live view.", "requestBody": { "content": { "application/json": { diff --git a/specs/2026-10-04-linux-edsdk-design.md b/specs/2026-10-04-linux-edsdk-design.md new file mode 100644 index 0000000..dbfffca --- /dev/null +++ b/specs/2026-10-04-linux-edsdk-design.md @@ -0,0 +1,114 @@ +# Linux support, step 1: run the API on Linux + +Date: 2026-10-04. Status: approved design. + +## Context + +- A photo booth (Windows, EOS R100, dummy battery, no memory card) froze when taking a picture: the API reported the + camera as disconnected and no picture was returned. The cause is unknown and the photo booth log is not available. +- The R100 is now plugged into a Linux development machine (Ubuntu 26.04, x86_64, .NET 10 SDK installed). +- Canon ships EDSDK 13.20.21 for Linux (x86_64, ARM64, ARM32). Its headers are identical to the Windows ones. + `libEDSDK.so` needs `libusb-1.0` and `libudev`. +- Linux becomes a supported platform in two steps: + - Step 1 (this spec): the API runs on Linux, so the camera can be tested on the development machine with full logs. + - Step 2 (separate spec): distribution (release package, service, USB permissions, CI, other architectures). +- No automatic update on Linux for now. + +## Goals + +- `dotnet run --project Canon.API -f net10.0` runs the API on Linux x64 against a real camera, with the same behavior + as on Windows. +- The Windows build, release package and automatic update are unchanged, apart from `-f net10.0-windows` in the + publish commands. +- Debug logs show every EDSDK event and every step of a capture, to find where the photo booth capture fails. + +## Non-goals (step 2 or later) + +Linux release package, self-contained Linux publish, systemd service, udev rules, Linux CI, ARM64/ARM32, automatic +update on Linux, macOS. Fixing the capture failure itself is a separate change, made once its root cause is known. + +## Design + +### 1. Canon.Core: cross-platform library + +- Target framework `net10.0` (was `net10.0-windows`). The STA apartment of the Canon thread stays Windows only (already + guarded by `OperatingSystem.IsWindows()`). `Canon.Core.Tests` and `Canon.API.Tests` also target `net10.0`. +- Every P/Invoke declaration uses the library name `"EDSDK"` (a constant of the `EDSDK` class) instead of + `"EDSDK.dll"`. .NET resolves it to `EDSDK.dll` on Windows and `libEDSDK.so` on Linux. +- No other declaration changes: the headers are identical, and the type sizes are the same on 64-bit Linux + (`EdsInt32` is `int`, `EdsUInt64` is 64-bit, `char` arrays are marshalled as UTF-8). Callbacks use the platform + default calling convention. +- Native libraries: the Windows DLLs stay in `EDSDK/`. `libEDSDK.so` (from `EDSDK/Library/x86_64` of the 13.20.21 + Linux SDK) is added as `EDSDK/linux-x64/libEDSDK.so`. +- The title returned when the SDK cannot be loaded (`CameraExceptionHandler`) covers both platforms: `EDSDK.dll` or + `libEDSDK.so` missing, wrong architecture, or `libusb-1.0` missing on Linux. +- `PlatformTarget` stays x64. + +### 2. Canon.API: two target frameworks + +- `TargetFrameworks`: `net10.0-windows;net10.0`. +- Conditioned on the Windows target: the `Autoupdater.NET.Official` reference, `RuntimeIdentifier` `win-x64`, + `SelfContained`, `PublishSingleFile`, `IncludeNativeLibrariesForSelfExtract`, `EnableCompressionInSingleFile`. + The `net10.0` target is framework-dependent and has no runtime identifier in step 1. +- `Program.cs`: the AutoUpdater `using` and `ConfigureAutoUpdater` are compiled for Windows only (`#if WINDOWS`). + On the other targets, an Information log says that automatic updates are not available on this platform. +- Native libraries are copied by Canon.API according to its target (moved from `Canon.Core.csproj`, the API being + the only executable): `EDSDK.dll` and `EdsImage.dll` for `net10.0-windows`, `libEDSDK.so` for `net10.0`. Copied to + the output and publish folders, excluded from the single file, as today. +- Commands: `dotnet run --project Canon.API -f net10.0` on Linux, `-f net10.0-windows` on Windows. The publish + commands (`.github/workflows/build.yml`, `Dockerfile`, `build.bat`) add `-f net10.0-windows`. The opt-in OpenAPI + generation command uses `-f net10.0`, which runs on Windows and Linux, so the document is written once. +- Running on Linux: + - The user needs read/write access to the camera USB device (desktop sessions get it through `uaccess`). + - Nothing else may hold the camera: no GNOME (gvfs) gphoto2 mount, no VMware USB passthrough. + - Logs: console and `Canon.API/bin/Debug/net10.0/logs/canon-api.log`. + - Use the `http` launch profile (`http://localhost:5159`). + +### 3. Diagnostics + +- Debug level, category `Canon.Core.CanonCamera`, enabled with `Logging:LogLevel:Canon.Core` = `Debug`. Off by + default (`Default` is `Information`). +- Logged: + - every event received from the SDK: state events (name and parameter), object events (name), property events + (property id and parameter); + - each step of a capture, with its EDSDK result code and its duration: UI lock, + flash target, flash firing, UI unlock, shutter press (each attempt), shutter release (each attempt), transfer + request, download, then completion, failure or timeout. +- The UI unlock result was ignored until now: it is logged, as a warning when it fails, and stays non-fatal. +- Event names come from a pure helper in `EdsdkHelper`, so they can be unit tested. +- Logging only: no behavior change. + +### 4. Testing + +Unit tests (run on Linux and Windows): + +- Every `DllImport` of the `EDSDK` class uses the `"EDSDK"` library name. +- Event names: known state and object events map to their names, unknown codes to hexadecimal. +- Existing tests unchanged and passing. + +Build: the Windows CI builds the solution, so both targets of Canon.API. + +Manual test with the R100 on Linux, one variable at a time: + +1. Start the API with `Canon.Core` at Debug. Set the camera as in the photo booth (shooting mode, flash on the + shoe, no memory card if possible). +2. `GET /status`, open `/videostream` in a browser, then `POST /takepicture` with the default parameters. +3. If the capture fails, the log shows the last step that succeeded. Then retry with `Canon:ForceFlashFiring` = + `false`, then with `useAutoFocus=false`. + +The result decides the fix. + +### 5. Documentation + +- README: "Running on Linux (development)" section: prerequisites (.NET 10 SDK, `libusb-1.0`), command, logs, + camera access. +- CLAUDE.md: target frameworks, `-f` in the commands, Linux library. +- CHANGELOG (Unreleased): Linux x64 support without automatic update, Debug logs of EDSDK events and capture steps; + `dotnet run` and `dotnet publish` need `-f`. + +## Risks + +- The Linux EDSDK may behave differently from the Windows one (e.g. hot plug through libusb and udev). If the camera + cannot be detected or its session cannot be opened on Linux, report the EDSDK error and stop: no workaround in + step 1. +- If the R100 does not reproduce the photo booth failure on Linux, the photo booth log is still needed. diff --git a/specs/2026-10-04-linux-edsdk-plan.md b/specs/2026-10-04-linux-edsdk-plan.md new file mode 100644 index 0000000..8472cc5 --- /dev/null +++ b/specs/2026-10-04-linux-edsdk-plan.md @@ -0,0 +1,847 @@ +# Linux Support, Step 1 - Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Run CanonWebAPI on Linux x64 against a real camera, with Debug logs of every EDSDK event and capture step, then use it to find why the photo booth R100 froze on capture. + +**Architecture:** Canon.Core becomes a plain `net10.0` library whose P/Invokes load `"EDSDK"` (`EDSDK.dll` on Windows, `libEDSDK.so` on Linux). Canon.API targets `net10.0-windows` (unchanged Windows release with AutoUpdater.NET) and `net10.0` (Linux, no automatic update); each target copies its own native library. A `TraceCall` helper in `CanonCamera` logs the result and duration of the SDK calls of a capture. + +**Tech Stack:** .NET 10 (SDK 10.0.112 on the dev machine), ASP.NET Core, Serilog, xUnit, Canon EDSDK 13.20.21 (Windows DLLs, Linux x86_64 `libEDSDK.so`). + +**Spec:** `specs/2026-10-04-linux-edsdk-design.md` + +## Global Constraints + +- Branch `feature/linux-edsdk`. Every commit message ends with `Co-Authored-By: Claude Opus 5.5 `. +- Target frameworks: `net10.0` (Canon.Core, Canon.Core.Tests, Canon.API.Tests); `net10.0-windows;net10.0` (Canon.API). `PlatformTarget` stays `x64`. +- The Windows release is unchanged: self-contained single-file `win-x64` executable, `EDSDK.dll` and `EdsImage.dll` next to it, AutoUpdater.NET. +- No automatic update on Linux. +- Linux library: `EDSDK/linux-x64/libEDSDK.so`, taken from `EDSDK/Library/x86_64/libEDSDK.so` of `~/Downloads/EDSDK_v13.20.21_Linux.zip`. +- Code comments in English, senior altitude (only what the code cannot say). ASCII punctuation in code, comments and docs. +- Every change is recorded in the `Unreleased` section of `CHANGELOG.md`. +- Build and tests on the dev machine (Linux): `dotnet build CanonSDK.sln`, `dotnet test CanonSDK.sln`. Building the Windows target on Linux works thanks to `EnableWindowsTargeting` in `Directory.Build.props`. + +## File Structure + +| File | Change | +|------|--------| +| `Canon.Core/EDSDK.cs` | Library name constant, 55 `DllImport`s use it | +| `Canon.Core/Canon.Core.csproj` | `net10.0`; native library items move to Canon.API | +| `Canon.Core/EdsdkHelper.cs` | `DescribeEvent`, `DescribeResult` | +| `Canon.Core/CanonCamera.cs` | `TraceCall` helper, Debug logs of events and capture steps, UI unlock warning | +| `Canon.Core.Tests/Canon.Core.Tests.csproj`, `Canon.API.Tests/Canon.API.Tests.csproj` | `net10.0` | +| `Canon.Core.Tests/EdsdkInteropTests.cs` | Test: every import uses `"EDSDK"` | +| `Canon.Core.Tests/EdsdkHelperTests.cs` | Tests of the event and result names | +| `Canon.API/Canon.API.csproj` | Two targets, Windows-only properties and package, native libraries per target | +| `Canon.API/Program.cs` | AutoUpdater under `#if WINDOWS` | +| `Canon.API/Infrastructure/CameraExceptionHandler.cs` | Platform-neutral "EDSDK could not be loaded" title | +| `EDSDK/linux-x64/libEDSDK.so` | New | +| `.github/workflows/build.yml`, `Dockerfile`, `build.bat` | Publish with `-f net10.0-windows` | +| `Directory.Build.props` | Comment | +| `README.md`, `CLAUDE.md`, `CHANGELOG.md` | Linux, commands, logs | + +--- + +### Task 1: Canon.Core loads the EDSDK by a cross-platform name and targets net10.0 + +**Files:** +- Modify: `Canon.Core/EDSDK.cs:9-12` and every `[DllImport("EDSDK.dll")]` +- Modify: `Canon.Core/Canon.Core.csproj`, `Canon.Core.Tests/Canon.Core.Tests.csproj`, `Canon.API.Tests/Canon.API.Tests.csproj` +- Test: `Canon.Core.Tests/EdsdkInteropTests.cs` + +**Interfaces:** +- Produces: `EDSDK.Library` (`private const string`, value `"EDSDK"`), used by every `DllImport` of `EDSDK`. + +- [ ] **Step 1: Write the failing test** + +In `Canon.Core.Tests/EdsdkInteropTests.cs`, add `using System.Reflection;` to the usings, and this test to the `EdsdkInteropTests` class: + +```csharp + [Fact] + public void Every_import_uses_the_cross_platform_library_name() + { + var imports = typeof(EDSDK).GetMethods(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Static) + .Select(method => method.GetCustomAttribute()) + .OfType() + .ToList(); + + // Guards against a reflection change returning nothing, which would make the test pass vacuously. + Assert.True(imports.Count > 50, $"{imports.Count} imports found"); + Assert.All(imports, import => Assert.Equal("EDSDK", import.Value)); + } +``` + +- [ ] **Step 2: Run it to verify it fails** + +Run: `dotnet test Canon.Core.Tests --filter "FullyQualifiedName~Every_import_uses_the_cross_platform_library_name"` +Expected: FAIL, `Assert.Equal() Failure` with expected `"EDSDK"` and actual `"EDSDK.dll"`. + +- [ ] **Step 3: Use the library name constant** + +In `Canon.Core/EDSDK.cs`, replace: + +```csharp +internal class EDSDK +{ + [DllImport("EDSDK.dll")] + public static extern uint EdsGetEvent(); +``` + +with: + +```csharp +internal class EDSDK +{ + // Resolved to EDSDK.dll on Windows and libEDSDK.so on Linux. + private const string Library = "EDSDK"; + + [DllImport(Library)] + public static extern uint EdsGetEvent(); +``` + +Then replace the other imports: + +```bash +sed -i 's/\[DllImport("EDSDK.dll")\]/[DllImport(Library)]/' Canon.Core/EDSDK.cs +grep -c 'DllImport(Library)' Canon.Core/EDSDK.cs # 55 +grep -c 'EDSDK.dll' Canon.Core/EDSDK.cs # 0 +``` + +- [ ] **Step 4: Target net10.0** + +In `Canon.Core/Canon.Core.csproj`, `Canon.Core.Tests/Canon.Core.Tests.csproj` and `Canon.API.Tests/Canon.API.Tests.csproj`, replace: + +```xml + net10.0-windows +``` + +with: + +```xml + net10.0 +``` + +Keep the native library items of `Canon.Core.csproj` for now: Task 2 moves them. + +- [ ] **Step 5: Run the tests** + +Run: `dotnet test CanonSDK.sln` +Expected: PASS for both test projects (`Failed: 0`), build with 0 warnings. The new test passes. + +- [ ] **Step 6: Commit** + +```bash +git add Canon.Core/EDSDK.cs Canon.Core/Canon.Core.csproj Canon.Core.Tests/Canon.Core.Tests.csproj Canon.API.Tests/Canon.API.Tests.csproj Canon.Core.Tests/EdsdkInteropTests.cs +git commit -m "Load the EDSDK by a cross-platform library name and target net10.0 in Canon.Core + +Co-Authored-By: Claude Opus 5.5 " +``` + +--- + +### Task 2: Canon.API runs on Linux (net10.0 target) + +**Files:** +- Create: `EDSDK/linux-x64/libEDSDK.so` +- Modify: `Canon.API/Canon.API.csproj`, `Canon.Core/Canon.Core.csproj`, `Canon.API/Program.cs`, `Canon.API/Infrastructure/CameraExceptionHandler.cs:118` +- Modify: `Canon.API.Tests/Canon.API.Tests.csproj` (comment), `Directory.Build.props` (comment) +- Modify: `.github/workflows/build.yml:110`, `Dockerfile:49`, `build.bat` +- Modify: `README.md`, `CLAUDE.md`, `CHANGELOG.md` + +**Interfaces:** +- Consumes: Task 1 (`Canon.Core` targets `net10.0`, imports load `"EDSDK"`). +- Produces: `dotnet run --project Canon.API -f net10.0 --launch-profile http` runs the API on Linux at `http://localhost:5159`, with `libEDSDK.so` in `Canon.API/bin/Debug/net10.0/`. + +- [ ] **Step 1: Verify the Linux target does not exist yet** + +Run: `dotnet build Canon.API -f net10.0` +Expected: FAIL (restore or build error: the project has no `net10.0` target yet). + +- [ ] **Step 2: Add the Linux library** + +```bash +unzip -j -o ~/Downloads/EDSDK_v13.20.21_Linux.zip 'EDSDK/Library/x86_64/libEDSDK.so' -d EDSDK/linux-x64 +file EDSDK/linux-x64/libEDSDK.so # ELF 64-bit LSB shared object, x86-64 +``` + +- [ ] **Step 3: Move the native library items to Canon.API** + +In `Canon.Core/Canon.Core.csproj`, delete this item group: + +```xml + + + PreserveNewest + PreserveNewest + true + + + PreserveNewest + PreserveNewest + true + + +``` + +- [ ] **Step 4: Give Canon.API two targets** + +Replace the whole `Canon.API/Canon.API.csproj` with: + +```xml + + + + + net10.0-windows;net10.0 + enable + enable + x64 + + + 1.4.0.0 + 1.4.0.0 + 1.4.0.0 + + + false + $(MSBuildProjectDirectory)/../docs + --file-name openapi + + + + + true + true + win-x64 + true + true + + + + + + + + + + + + + + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + PreserveNewest + PreserveNewest + true + + + PreserveNewest + PreserveNewest + true + + + + + + PreserveNewest + PreserveNewest + true + + + + + + + + +``` + +- [ ] **Step 5: Compile AutoUpdater for Windows only** + +In `Canon.API/Program.cs`, replace: + +```csharp +using Canon.API.Infrastructure; +using Serilog; +using AutoUpdaterDotNET; +``` + +with: + +```csharp +using Canon.API.Infrastructure; +using Serilog; +#if WINDOWS +using AutoUpdaterDotNET; +#endif +``` + +Replace: + +```csharp + if (app.Configuration.GetValue("AutoUpdate:Enabled", true)) + ConfigureAutoUpdater(app); + else + Log.Information("Automatic updates are disabled"); +``` + +with: + +```csharp +#if WINDOWS + if (app.Configuration.GetValue("AutoUpdate:Enabled", true)) + ConfigureAutoUpdater(app); + else + Log.Information("Automatic updates are disabled"); +#else + Log.Information("Automatic updates are not available on this platform"); +#endif +``` + +Put `#if WINDOWS` on the line before `static void ConfigureAutoUpdater(WebApplication app)` and `#endif` after its closing brace (end of file). + +- [ ] **Step 6: Platform-neutral load error and comments** + +In `Canon.API/Infrastructure/CameraExceptionHandler.cs`, replace: + +```csharp + DllNotFoundException or BadImageFormatException => (StatusCodes.Status500InternalServerError, "Canon EDSDK could not be loaded (EDSDK.dll missing or wrong architecture)"), +``` + +with: + +```csharp + DllNotFoundException or BadImageFormatException => (StatusCodes.Status500InternalServerError, "Canon EDSDK could not be loaded (EDSDK.dll or libEDSDK.so missing, wrong architecture, or libusb-1.0 missing on Linux)"), +``` + +In `Canon.API.Tests/Canon.API.Tests.csproj`, replace: + +```xml + +``` + +with: + +```xml + +``` + +In `Directory.Build.props`, replace: + +```xml + +``` + +with: + +```xml + +``` + +- [ ] **Step 7: Publish the Windows target explicitly** + +- `.github/workflows/build.yml`, step "Publish Canon.API as standalone executable": + `run: dotnet publish Canon.API/Canon.API.csproj --configuration Release --framework net10.0-windows --runtime win-x64 --self-contained true -p:PublishSingleFile=true --output ./publish` +- `Dockerfile`, publish stage: + `RUN dotnet publish Canon.API/Canon.API.csproj -c Release -f net10.0-windows -r win-x64 --self-contained true `` ` `` + (the next line, `-p:PublishSingleFile=true --no-restore -o C:\publish`, is unchanged) +- `build.bat`: + `dotnet publish Canon.API/Canon.API.csproj --configuration Release --framework net10.0-windows --runtime win-x64 --self-contained true -p:PublishSingleFile=true --output ./publish --verbosity minimal` + +- [ ] **Step 8: Build and check the outputs** + +```bash +dotnet build CanonSDK.sln +ls Canon.API/bin/Debug/net10.0/libEDSDK.so Canon.API/bin/Debug/net10.0-windows/win-x64/EDSDK.dll Canon.API/bin/Debug/net10.0-windows/win-x64/EdsImage.dll +ls Canon.API/bin/Debug/net10.0/ | grep -c -i 'EDSDK.dll\|EdsImage.dll' # 0 +ls Canon.API/bin/Debug/net10.0-windows/win-x64/ | grep -c 'libEDSDK.so' # 0 +dotnet test CanonSDK.sln +``` + +Expected: build with 0 warnings and 0 errors; the three files listed; both counts 0; tests `Failed: 0`. + +- [ ] **Step 9: Check the Windows release publish from Linux** + +```bash +P=$(mktemp -d) +dotnet publish Canon.API/Canon.API.csproj --configuration Release --framework net10.0-windows --runtime win-x64 --self-contained true -p:PublishSingleFile=true --output "$P" +ls "$P" # Canon.API.exe, EDSDK.dll, EdsImage.dll, appsettings*.json (no appsettings.Local.json, no libEDSDK.so) +rm -rf "$P" +``` + +- [ ] **Step 10: Start the API on Linux** + +Run in the background: `dotnet run --project Canon.API -f net10.0 --launch-profile http` +Then: `curl -s http://localhost:5159/status` and `curl -s -o /dev/null -w '%{http_code}\n' http://localhost:5159/openapi/v1.json` +Expected: the console log contains `Automatic updates are not available on this platform`; `/status` returns JSON (`connected` true when the camera is plugged in and free, otherwise false with an `error` that is not a `DllNotFoundException`); `/openapi/v1.json` returns 200. If the camera is plugged in and free but `connected` is false, stop and report the EDSDK error: no workaround in step 1 (spec, Risks). Stop the API. + +- [ ] **Step 11: Regenerate the OpenAPI document with the Linux target** + +Run: `dotnet build Canon.API -f net10.0 -t:Build -t:GenerateOpenApiDocuments && git diff --stat docs/openapi.json` +Expected: no difference (no endpoint changed). If the file differs, stop and report the difference. + +Erratum: the `-p:OpenApiGenerateDocumentsOnBuild=true` form is a no-op in a multi-targeted project; the generated document also differs cosmetically from the committed one (schema order, `default: null`, final newline), so it is not regenerated in this plan. + +- [ ] **Step 12: Documentation** + +`README.md`: +- Project Structure, `EDSDK` line: `* \`EDSDK\`: Canon EDSDK 13.20.21 64-bit libraries: \`EDSDK.dll\` and \`EdsImage.dll\` (Windows), \`linux-x64/libEDSDK.so\` (Linux).` +- Prerequisites, replace the Windows line with: `* **Windows** or **Linux** (x64). On Linux, see [Running on Linux](#running-on-linux).` +- Installation step 2: `Ensure the \`EDSDK\` folder is present in the project's root directory: \`EDSDK.dll\` and \`EdsImage.dll\` on Windows, \`linux-x64/libEDSDK.so\` on Linux. These files are essential for the \`Canon.Core\` library to communicate with the camera.` +- "Building from Source" and "Running the API": `dotnet run --project Canon.API -f net10.0-windows` with the comment `# on Linux: -f net10.0`. +- After "Running the API", add: + +````markdown +### Running on Linux + +The `net10.0` target of `Canon.API` runs on Linux x64 with the Linux version of the EDSDK (`EDSDK/linux-x64/libEDSDK.so`). +There is no automatic update and no release package for Linux yet: run it from the sources. + +Prerequisites: .NET 10 SDK and `libusb-1.0` (Ubuntu: `sudo apt install dotnet-sdk-10.0 libusb-1.0-0`). + +```bash +dotnet run --project Canon.API -f net10.0 --launch-profile http +``` + +* The user running the API needs read/write access to the camera USB device; desktop sessions get it automatically. +* Nothing else may use the camera: if the desktop mounted it, unmount it (GNOME: `gio mount -l`, then `gio mount -u `), and do not pass it to a virtual machine. +* Logs: console and `Canon.API/bin/Debug/net10.0/logs/`. +```` + +- "OpenAPI Document": replace "To regenerate `docs/openapi.json` at build time (on Windows):" and its command with "To regenerate `docs/openapi.json` at build time (Windows or Linux):" and `dotnet build Canon.API -f net10.0 -t:Build -t:GenerateOpenApiDocuments`. +- System Requirements, OS line: `* **OS**: Windows 10/11 (x64); Linux x64 from the sources (see [Running on Linux](#running-on-linux))`. + +`CLAUDE.md`: +- Architecture, Canon.API line: `- **Canon.API**: ASP.NET Core Web API (main entry point); targets \`net10.0-windows\` (Windows release, AutoUpdater.NET) and \`net10.0\` (Linux, no automatic update)` +- Dependencies: after the EDSDK DLL line add `- Linux: \`EDSDK/linux-x64/libEDSDK.so\` (from \`EDSDK/Library/x86_64\` of the 13.20.21 Linux SDK), needs \`libusb-1.0\``; replace `- Platform target: x64 (Windows only)` with `- Platform target: x64 (Windows; Linux through the \`net10.0\` target of Canon.API)`. +- "Run API Server": `dotnet run --project Canon.API -f net10.0-windows # Linux: -f net10.0 --launch-profile http`. +- "Build Specific Project": `dotnet build Canon.API` builds both targets (no change needed). +- Important Notes: replace `- All projects target .NET 10 (\`net10.0-windows\`) with Windows-specific dependencies` with `- Canon.Core and the tests target \`net10.0\`; Canon.API targets \`net10.0-windows\` (release, AutoUpdater.NET) and \`net10.0\` (Linux). Publish the release with \`-f net10.0-windows\``. + +`CHANGELOG.md`, under `## [Unreleased]`: + +```markdown +### Added +- Linux x64 support: the `net10.0` target of `Canon.API` runs on Linux with the Linux version of the EDSDK + (`EDSDK/linux-x64/libEDSDK.so`, needs `libusb-1.0`). No automatic update and no release package on Linux yet. + +### Changed +- `Canon.API` has two target frameworks: `dotnet run` and `dotnet publish` need `-f net10.0-windows` (Windows) or + `-f net10.0` (Linux). `Canon.Core` and the unit tests target `net10.0`. +``` + +- [ ] **Step 13: Commit** + +```bash +git add EDSDK/linux-x64/libEDSDK.so Canon.API/Canon.API.csproj Canon.Core/Canon.Core.csproj Canon.API/Program.cs Canon.API/Infrastructure/CameraExceptionHandler.cs Canon.API.Tests/Canon.API.Tests.csproj Directory.Build.props .github/workflows/build.yml Dockerfile build.bat README.md CLAUDE.md CHANGELOG.md +git commit -m "Run the API on Linux with a net10.0 target and the Linux EDSDK + +Co-Authored-By: Claude Opus 5.5 " +``` + +--- + +### Task 3: Names of EDSDK events and results + +**Files:** +- Modify: `Canon.Core/EdsdkHelper.cs` +- Test: `Canon.Core.Tests/EdsdkHelperTests.cs` + +**Interfaces:** +- Produces: + - `public static string EdsdkHelper.DescribeEvent(uint eventId)`: the name of the `EDSDK` constant (`"StateEvent_Shutdown"`, `"ObjectEvent_DirItemRequestTransfer"`, `"PropertyEvent_PropertyChanged"`...), or `"0x..."` when unknown. + - `public static string EdsdkHelper.DescribeResult(uint errorCode)`: `"OK"`, `" (0x)"` for a known error, `"0x"` otherwise. + +- [ ] **Step 1: Write the failing tests** + +Add to the `EdsdkHelperTests` class in `Canon.Core.Tests/EdsdkHelperTests.cs`: + +```csharp + [Theory] + [InlineData(EDSDK.StateEvent_Shutdown, "StateEvent_Shutdown")] + [InlineData(EDSDK.StateEvent_CaptureError, "StateEvent_CaptureError")] + [InlineData(EDSDK.ObjectEvent_DirItemRequestTransfer, "ObjectEvent_DirItemRequestTransfer")] + [InlineData(EDSDK.PropertyEvent_PropertyChanged, "PropertyEvent_PropertyChanged")] + [InlineData(0xABCDu, "0xABCD")] + public void Events_are_named_after_their_constant(uint eventId, string name) + { + Assert.Equal(name, EdsdkHelper.DescribeEvent(eventId)); + } + + [Fact] + public void Results_are_described_with_their_code() + { + Assert.Equal("OK", EdsdkHelper.DescribeResult(EDSDK.EDS_ERR_OK)); + Assert.Equal("Device is busy (0x2019)", EdsdkHelper.DescribeResult(EDSDK.EDS_ERR_PTP_DEVICE_BUSY)); + Assert.Equal("0x12345678", EdsdkHelper.DescribeResult(0x12345678)); + } +``` + +- [ ] **Step 2: Run them to verify they fail** + +Run: `dotnet test Canon.Core.Tests --filter "FullyQualifiedName~Events_are_named_after_their_constant|FullyQualifiedName~Results_are_described_with_their_code"` +Expected: FAIL to compile, `EdsdkHelper` has no `DescribeEvent` / `DescribeResult`. + +- [ ] **Step 3: Implement the helpers** + +At the top of `Canon.Core/EdsdkHelper.cs` (before `namespace Canon.Core;`), add `using System.Reflection;`. After `GetErrorMessage`, add: + +```csharp + /// + /// "OK", the error message followed by its code, or the code alone when unknown. + /// + public static string DescribeResult(uint errorCode) => + errorCode == EDSDK.EDS_ERR_OK ? "OK" + : ErrorMessages.TryGetValue(errorCode, out var message) ? $"{message} ({FormatRawValue(errorCode)})" + : FormatRawValue(errorCode); + + // Property, object and state event constants of EDSDK, by value ("*_All" are subscription masks, not events). + private static readonly Dictionary EventNames = typeof(EDSDK) + .GetFields(BindingFlags.Public | BindingFlags.Static) + .Where(field => field.IsLiteral && field.FieldType == typeof(uint) && !field.Name.EndsWith("_All") + && (field.Name.StartsWith("PropertyEvent_") || field.Name.StartsWith("ObjectEvent_") || field.Name.StartsWith("StateEvent_"))) + .GroupBy(field => (uint)field.GetRawConstantValue()!) + .ToDictionary(group => group.Key, group => group.First().Name); + + /// + /// Name of an EDSDK event (e.g. "StateEvent_Shutdown"), or its code when unknown. + /// + public static string DescribeEvent(uint eventId) => + EventNames.TryGetValue(eventId, out var name) ? name : FormatRawValue(eventId); +``` + +(`FormatRawValue` already exists in `EdsdkHelper`: `$"0x{value:X}"`.) + +- [ ] **Step 4: Run the tests** + +Run: `dotnet test CanonSDK.sln` +Expected: `Failed: 0`, the 6 new test cases pass. + +- [ ] **Step 5: Commit** + +```bash +git add Canon.Core/EdsdkHelper.cs Canon.Core.Tests/EdsdkHelperTests.cs +git commit -m "Name EDSDK events and results for the logs + +Co-Authored-By: Claude Opus 5.5 " +``` + +--- + +### Task 4: Debug logs of EDSDK events and capture steps + +**Files:** +- Modify: `Canon.Core/CanonCamera.cs` +- Modify: `README.md`, `CHANGELOG.md` + +**Interfaces:** +- Consumes: `EdsdkHelper.DescribeEvent(uint)`, `EdsdkHelper.DescribeResult(uint)` (Task 3). +- Produces: `private uint TraceCall(string step, Func call)` in `CanonCamera`; Debug logs under the `Canon.Core.CanonCamera` category, enabled with `--Logging:LogLevel:Canon.Core=Debug`. + +These calls need the native SDK and a camera: they are checked by running the API (Step 9) and in Task 5, not by unit tests. Logging only: no behavior change, apart from a warning when the UI unlock fails. + +- [ ] **Step 1: The TraceCall helper** + +In `Canon.Core/CanonCamera.cs`, add `using System.Diagnostics;` as the first using. Before the `#endregion` that closes the `Connection` region (after the `RunWithBusyRetryAsync(Action ...)` overload), add: + +```csharp + /// + /// Runs an SDK call and logs its result and duration at Debug level. + /// + private uint TraceCall(string step, Func call) + { + if (_logger is not { } logger || !logger.IsEnabled(LogLevel.Debug)) + return call(); + + var start = Stopwatch.GetTimestamp(); + var result = call(); + logger.LogDebug("{Step}: {Result} ({Duration:0} ms)", step, EdsdkHelper.DescribeResult(result), Stopwatch.GetElapsedTime(start).TotalMilliseconds); + return result; + } +``` + +- [ ] **Step 2: Log every SDK event** + +In `OnCameraPropertyChanged`, before `if (inEvent != EDSDK.PropertyEvent_PropertyChanged || PropertyChanged == null)`, add: + +```csharp + _logger?.LogDebug("SDK event {Event}: property 0x{Property:X}, parameter 0x{Parameter:X}", EdsdkHelper.DescribeEvent(inEvent), inPropertyId, inParam); + +``` + +In `OnCameraStateChanged`, before `switch (inEvent)`, add: + +```csharp + _logger?.LogDebug("SDK event {Event}: parameter 0x{Parameter:X}", EdsdkHelper.DescribeEvent(inEvent), inParameter); + +``` + +In `OnCameraObject`, before `try`, add: + +```csharp + _logger?.LogDebug("SDK event {Event}", EdsdkHelper.DescribeEvent(inEvent)); + +``` + +- [ ] **Step 3: Trace the flash settings** + +In `SetFlashFiringCore`, replace: + +```csharp + EDSDK.EdsSendStatusCommand(camera, EDSDK.CameraState_UILock, 1).ThrowIfEdSdkError("Could not lock the camera UI"); + + try + { + EDSDK.EdsSetPropertyData(_flashRef, EDSDK.PropID_Flash_Target, 0, sizeof(uint), target) + .ThrowIfEdSdkError("Could not set the flash target"); + EDSDK.EdsSetPropertyData(_flashRef, EDSDK.PropID_Flash_Firing, 0, sizeof(uint), firing ? 1u : 0u) + .ThrowIfEdSdkError("Could not set flash firing (the camera must be in P, Tv, Av or M)"); + } + finally + { + EDSDK.EdsSendStatusCommand(camera, EDSDK.CameraState_UIUnLock, 0); + } +``` + +with: + +```csharp + TraceCall("UI lock", () => EDSDK.EdsSendStatusCommand(camera, EDSDK.CameraState_UILock, 1)).ThrowIfEdSdkError("Could not lock the camera UI"); + + try + { + TraceCall("Flash target", () => EDSDK.EdsSetPropertyData(_flashRef, EDSDK.PropID_Flash_Target, 0, sizeof(uint), target)) + .ThrowIfEdSdkError("Could not set the flash target"); + TraceCall("Flash firing", () => EDSDK.EdsSetPropertyData(_flashRef, EDSDK.PropID_Flash_Firing, 0, sizeof(uint), firing ? 1u : 0u)) + .ThrowIfEdSdkError("Could not set flash firing (the camera must be in P, Tv, Av or M)"); + } + finally + { + var unlock = TraceCall("UI unlock", () => EDSDK.EdsSendStatusCommand(camera, EDSDK.CameraState_UIUnLock, 0)); + if (unlock != EDSDK.EDS_ERR_OK) + _logger?.LogWarning("Could not unlock the camera UI: {Result}", EdsdkHelper.DescribeResult(unlock)); + } +``` + +- [ ] **Step 4: Trace the shutter button** + +In `TakePictureAsync`, after the `acceptedTypes` lines (`if (acceptedTypes.Count == 0) acceptedTypes = _defaultFileTypes;`), add: + +```csharp + var clock = Stopwatch.StartNew(); + _logger?.LogDebug("Capture requested (auto focus: {AutoFocus}, file types: {FileTypes})", useAutoFocus, string.Join(", ", acceptedTypes)); +``` + +Replace the press: + +```csharp + await RunWithBusyRetryAsync(camera => + EDSDK.EdsSendCommand(camera, EDSDK.CameraCommand_PressShutterButton, (int)button) + .ThrowIfEdSdkError("Could not press the shutter button"), cancellationToken); +``` + +with: + +```csharp + await RunWithBusyRetryAsync(camera => + TraceCall($"Press shutter button ({button})", () => EDSDK.EdsSendCommand(camera, EDSDK.CameraCommand_PressShutterButton, (int)button)) + .ThrowIfEdSdkError("Could not press the shutter button"), cancellationToken); +``` + +Replace: + +```csharp + return await pending.Completion.Task.WaitAsync(timeout, cancellationToken); +``` + +with: + +```csharp + _logger?.LogDebug("Waiting up to {Timeout} s for the file", timeout.TotalSeconds); + var image = await pending.Completion.Task.WaitAsync(timeout, cancellationToken); + _logger?.LogDebug("Capture completed in {Duration} ms: {File}", clock.ElapsedMilliseconds, image.FileName); + return image; +``` + +In `ReleaseShutterButtonAsync`, replace: + +```csharp + await RunAsync(camera => + EDSDK.EdsSendCommand(camera, EDSDK.CameraCommand_PressShutterButton, (int)EDSDK.EdsShutterButton.CameraCommand_ShutterButton_OFF) + .ThrowIfEdSdkError("Could not release the shutter button")); +``` + +with: + +```csharp + await RunAsync(camera => + TraceCall("Release shutter button", () => EDSDK.EdsSendCommand(camera, EDSDK.CameraCommand_PressShutterButton, (int)EDSDK.EdsShutterButton.CameraCommand_ShutterButton_OFF)) + .ThrowIfEdSdkError("Could not release the shutter button")); +``` + +In `AutoFocus`, replace: + +```csharp + await RunWithBusyRetryAsync(camera => + EDSDK.EdsSendCommand(camera, EDSDK.CameraCommand_PressShutterButton, (int)EDSDK.EdsShutterButton.CameraCommand_ShutterButton_Halfway) + .ThrowIfEdSdkError("Could not send AF command")); +``` + +with: + +```csharp + await RunWithBusyRetryAsync(camera => + TraceCall("Press shutter button halfway", () => EDSDK.EdsSendCommand(camera, EDSDK.CameraCommand_PressShutterButton, (int)EDSDK.EdsShutterButton.CameraCommand_ShutterButton_Halfway)) + .ThrowIfEdSdkError("Could not send AF command")); +``` + +- [ ] **Step 5: Trace the transfer** + +In `TransferFile`, after the `EdsGetDirectoryItemInfo` error block, add: + +```csharp + _logger?.LogDebug("Transfer requested: {File} ({Size} bytes), pending capture: {Pending}", info.szFileName, info.Size, pending != null); + +``` + +and in the "file type not requested" block, replace `EDSDK.EdsDownloadCancel(dirItem);` with: + +```csharp + TraceCall("Download cancel", () => EDSDK.EdsDownloadCancel(dirItem)); +``` + +In `DownloadToMemory`, replace: + +```csharp + err = EDSDK.EdsDownload(dirItem, info.Size, stream); +``` + +with: + +```csharp + err = TraceCall("Download", () => EDSDK.EdsDownload(dirItem, info.Size, stream)); +``` + +and replace: + +```csharp + EDSDK.EdsDownloadComplete(dirItem).ThrowIfEdSdkError("Failed to complete download"); +``` + +with: + +```csharp + TraceCall("Download complete", () => EDSDK.EdsDownloadComplete(dirItem)).ThrowIfEdSdkError("Failed to complete download"); +``` + +- [ ] **Step 6: Build and run the tests** + +Run: `dotnet build CanonSDK.sln && dotnet test CanonSDK.sln` +Expected: 0 warnings, 0 errors, `Failed: 0`. + +- [ ] **Step 7: Documentation** + +`README.md`: +- In "Running on Linux", after the `dotnet run` command line, add inside the same code block: + +```bash +# Every EDSDK event and each step of a capture (result and duration) in the logs: +dotnet run --project Canon.API -f net10.0 --launch-profile http -- --Logging:LogLevel:Canon.Core=Debug +``` + +- Configuration table, `Logging:LogLevel` row description: append ` \`Canon.Core\`: \`Debug\` logs every EDSDK event and each step of a capture (UI lock, flash settings, shutter button, transfer) with its result and duration.` + +`CHANGELOG.md`, under `## [Unreleased]`: +- In `### Added`: `- Debug logs (\`Logging:LogLevel:Canon.Core\` = \`Debug\`): every EDSDK event, and each step of a capture (UI lock, flash settings, shutter button, transfer) with its result and duration.` +- In `### Changed`: `- A failed UI unlock after setting the flash is logged as a warning; it was ignored.` + +- [ ] **Step 8: Commit** + +```bash +git add Canon.Core/CanonCamera.cs README.md CHANGELOG.md +git commit -m "Log EDSDK events and capture steps at Debug level + +Co-Authored-By: Claude Opus 5.5 " +``` + +- [ ] **Step 9: Smoke check with the camera** + +Run in the background: `dotnet run --project Canon.API -f net10.0 --launch-profile http -- --Logging:LogLevel:Canon.Core=Debug` +The camera connects at startup. Expected in the log: `Connected to camera Canon EOS R100`, `SDK event ...` lines and, when the camera supports the flash settings, `UI lock: OK (... ms)`, `Flash target: ...`, `Flash firing: ...`, `UI unlock: ...`. Stop the API. + +--- + +### Task 5: Reproduce the photo booth capture on Linux (manual, with the user) + +**Files:** none (findings go to the `r100-capture-disconnect` work state and to the user). + +**Interfaces:** +- Consumes: the Linux API with Debug logs (Tasks 2 and 4). + +- [ ] **Step 1: Prepare the camera like the photo booth** + +Ask the user to set the R100 as in the photo booth (shooting mode, flash on the shoe, no memory card if possible, same power supply if available), to plug it into this computer, and to watch the camera screen during the test. + +- [ ] **Step 2: Make sure nothing else holds the camera** + +```bash +lsusb -d 04a9: # Canon device present +lsusb -t | grep -i imaging # Driver=[none]: no other program holds it +gio mount -l | grep -i -A2 canon # nothing: not mounted by the desktop +``` + +If the desktop mounted it: `gio mount -u `. + +- [ ] **Step 3: Run the photo booth sequence** + +```bash +S=$(mktemp -d) +dotnet run --project Canon.API -f net10.0 --launch-profile http -- --Logging:LogLevel:Canon.Core=Debug # background +curl -s http://localhost:5159/status +curl -s -N -o /dev/null --max-time 120 http://localhost:5159/videostream & # the photo booth live view +sleep 5 +curl -s -X POST 'http://localhost:5159/takepicture' -o "$S/capture-1.jpg" -D - -w '%{http_code} %{time_total}s\n' +``` + +Expected when it works: `200`, `X-File-Name` header, a JPEG in `$S/capture-1.jpg` (`file` says JPEG). + +- [ ] **Step 4: Read the log** + +```bash +grep -n -E 'SDK event|UI lock|UI unlock|Flash|shutter|Transfer|Download|Capture|disconnected|error|Warn' Canon.API/bin/Debug/net10.0/logs/canon-api*.log | tail -80 +``` + +Write down the last step that succeeded, the first failure (result code), the events around it and what the user saw on the camera. + +- [ ] **Step 5: Change one variable at a time** + +If the capture failed, restart the API and the stream before each run: +1. `-- --Logging:LogLevel:Canon.Core=Debug --Canon:ForceFlashFiring=false` (no flash setting before the capture). +2. Flash setting back, `POST /takepicture?useAutoFocus=false` (no autofocus). +3. Without `/videostream` (no live view). + +If the capture works, repeat Step 3 ten times to check it does not fail intermittently. + +- [ ] **Step 6: Report** + +Record the results in the `r100-capture-disconnect` work state and give the user the root cause with the log lines that prove it. The fix follows superpowers:systematic-debugging Phase 4 (failing test when possible, one change), in a separate commit. diff --git a/tools/Test-Camera.cmd b/tools/Test-Camera.cmd new file mode 100644 index 0000000..029d689 --- /dev/null +++ b/tools/Test-Camera.cmd @@ -0,0 +1,4 @@ +@echo off +rem Runs Test-Camera.ps1 whatever the PowerShell execution policy; arguments are passed to it. +powershell.exe -NoProfile -ExecutionPolicy Bypass -File "%~dp0Test-Camera.ps1" %* +pause diff --git a/tools/Test-Camera.ps1 b/tools/Test-Camera.ps1 new file mode 100644 index 0000000..72e45c6 --- /dev/null +++ b/tools/Test-Camera.ps1 @@ -0,0 +1,244 @@ +<# +.SYNOPSIS + Runs the photo booth camera checks against CanonWebAPI: live view, captures, flash changes during the live view + and a camera power cycle. + +.DESCRIPTION + Copy this script next to Canon.API.exe. When the API does not answer, it is started from that folder with detailed + camera logs and without the update check. Pictures, the result of every step and a copy of the API log are saved + in test-results\ next to the script. Needs curl.exe (included in Windows 10 1803 and later). + +.PARAMETER BaseUrl + Address of the API. The release listens on http://localhost:5000. + +.PARAMETER Captures + Number of pictures taken with the live view open. + +.PARAMETER CameraTimeoutSeconds + How long to wait for the camera to be connected, at the start and after the power cycle. + +.PARAMETER SkipPowerCycle + Skips the step that asks to turn the camera off and on. + +.PARAMETER NoBrowser + Does not open the live view in the browser (a hidden client keeps it running anyway). + +.EXAMPLE + .\Test-Camera.ps1 + +.EXAMPLE + .\Test-Camera.ps1 -Captures 6 -SkipPowerCycle +#> +[CmdletBinding()] +param( + [string]$BaseUrl = 'http://localhost:5000', + [ValidateRange(1, 50)][int]$Captures = 4, + [ValidateRange(5, 600)][int]$CameraTimeoutSeconds = 120, + [switch]$SkipPowerCycle, + [switch]$NoBrowser +) + +# Compatible with Windows PowerShell 5.1: no PowerShell 7 only syntax. +$ErrorActionPreference = 'Stop' + +$onWindows = [System.Environment]::OSVersion.Platform -eq 'Win32NT' +$curl = if ($onWindows) { 'curl.exe' } else { 'curl' } +$nullDevice = if ($onWindows) { 'NUL' } else { '/dev/null' } +$apiExe = Join-Path $PSScriptRoot $(if ($onWindows) { 'Canon.API.exe' } else { 'Canon.API' }) +$results = Join-Path (Join-Path $PSScriptRoot 'test-results') (Get-Date -Format 'yyyyMMdd-HHmmss') +$steps = New-Object System.Collections.Generic.List[object] +$script:captureNumber = 0 + +function Add-Step([string]$Name, [bool]$Passed, [string]$Detail) { + $result = if ($Passed) { 'PASS' } else { 'FAIL' } + $steps.Add([pscustomobject]@{ Step = $Name; Result = $result; Detail = $Detail }) + $color = if ($Passed) { 'Green' } else { 'Red' } + Write-Host ('{0} {1}: {2}' -f $result, $Name, $Detail) -ForegroundColor $color +} + +# Calls the API with curl; returns the HTTP status (0 when unreachable), the duration and the X-File-Name header. +function Invoke-Api([string]$Method, [string]$Path, [string]$JsonBody, [string]$OutFile) { + $headerFile = "$OutFile.headers" + $curlArgs = @('-s', '-X', $Method, "$BaseUrl$Path", '-o', $OutFile, '-D', $headerFile, + '-w', '%{http_code} %{time_total}', '--max-time', '90') + + if ($JsonBody) { + # Through a file: Windows PowerShell 5.1 strips the double quotes of native command arguments. + $bodyFile = Join-Path $results 'request-body.json' + [System.IO.File]::WriteAllText($bodyFile, $JsonBody) + $curlArgs += @('-H', 'Content-Type: application/json', '--data-binary', "@$bodyFile") + } + + $out = & $curl @curlArgs + $parts = "$out".Trim().Split(' ') + $fileName = $null + + if (Test-Path $headerFile) { + foreach ($line in Get-Content $headerFile) { + if ($line -match '^X-File-Name:\s*(.+)$') { $fileName = $Matches[1].Trim() } + } + Remove-Item $headerFile + } + + [pscustomobject]@{ + Status = [int]$parts[0] + Seconds = [double]::Parse($parts[1], [System.Globalization.CultureInfo]::InvariantCulture) + FileName = $fileName + } +} + +function Get-ProblemDetail([string]$File) { + try { + $problem = Get-Content $File -Raw | ConvertFrom-Json + return "$($problem.title) - $($problem.detail)" + } + catch { + return 'no details' + } +} + +function Get-Status { + $file = Join-Path $results 'status.json' + $response = Invoke-Api 'GET' '/status' $null $file + if ($response.Status -ne 200) { return $null } + return Get-Content $file -Raw | ConvertFrom-Json +} + +# Polls the condition once a second; true as soon as it holds, false after the timeout. +function Wait-Until([int]$TimeoutSeconds, [scriptblock]$Condition) { + $deadline = (Get-Date).AddSeconds($TimeoutSeconds) + while ((Get-Date) -lt $deadline) { + if (& $Condition) { return $true } + Start-Sleep -Seconds 1 + } + return $false +} + +function Test-LiveView { + $status = Get-Status + return [bool]($status -and $status.connected -and $status.liveViewActive) +} + +function Invoke-Capture([string]$Name) { + $script:captureNumber++ + $file = Join-Path $results ('capture-{0:00}.jpg' -f $script:captureNumber) + $response = Invoke-Api 'POST' '/takepicture' $null $file + + if ($response.Status -eq 200) { + $detail = '{0} in {1:0.0} s' -f $response.FileName, $response.Seconds + # The photo booth takes about 1.5 s; a busy camera retries for 5 s per attempt. + if ($response.Seconds -gt 5) { $detail += ' (slow: the camera answered busy, see the log)' } + Add-Step $Name $true $detail + } + else { + $problemFile = [System.IO.Path]::ChangeExtension($file, '.json') + Move-Item $file $problemFile -Force + Add-Step $Name $false ('HTTP {0} after {1:0.0} s: {2}' -f $response.Status, $response.Seconds, (Get-ProblemDetail $problemFile)) + } +} + +function Invoke-FlashChange([string]$Value) { + $file = Join-Path $results ('flash-{0}-{1:00}.json' -f $Value, ($script:captureNumber + 1)) + $response = Invoke-Api 'POST' '/flash' ('"' + $Value + '"') $file + + if ($response.Status -ne 200) { + Add-Step "Flash $Value" $false ('HTTP {0}: {1}' -f $response.Status, (Get-ProblemDetail $file)) + return + } + + # The live view is paused while the setting is written, then restarted. + $resumed = Wait-Until 10 { Test-LiveView } + $detail = if ($resumed) { 'set, live view resumed' } else { 'set, but the live view did not resume within 10 s' } + Add-Step "Flash $Value" $resumed $detail + Invoke-Capture "Capture after flash $Value" +} + +function Invoke-Tests { + if (-not (Get-Status)) { + if (-not (Test-Path $apiExe)) { + throw "The API does not answer at $BaseUrl and $apiExe was not found: copy this script next to it." + } + + Write-Host "Starting $apiExe with detailed camera logs..." + Start-Process $apiExe -WorkingDirectory $PSScriptRoot ` + -ArgumentList @('--Logging:LogLevel:Canon.Core=Debug', '--AutoUpdate:Enabled=false', "--urls=$BaseUrl") + + if (-not (Wait-Until 60 { Get-Status })) { + throw "The API did not answer at $BaseUrl within 60 s." + } + } + + Write-Host 'Waiting for the camera (turn it on if needed)...' + if (-not (Wait-Until $CameraTimeoutSeconds { $s = Get-Status; [bool]($s -and $s.connected) })) { + Add-Step 'Camera connected' $false ("no camera within {0} s: {1}" -f $CameraTimeoutSeconds, (Get-Status).error) + return + } + + $status = Get-Status + Add-Step 'Camera connected' $true ('{0}, {1}, flash firing: {2}' -f $status.cameraName, $status.mode.aeMode, $status.flash.firing) + + # Hidden client, so the live view runs as in the photo booth even without the browser. + $streamArgs = @('-s', '-N', '-o', $nullDevice, "$BaseUrl/videostream") + if ($onWindows) { + $script:stream = Start-Process $curl -ArgumentList $streamArgs -WindowStyle Hidden -PassThru + } + else { + $script:stream = Start-Process $curl -ArgumentList $streamArgs -PassThru + } + + if (-not $NoBrowser) { Start-Process "$BaseUrl/videostream" } + + $liveView = Wait-Until 20 { Test-LiveView } + Add-Step 'Live view started' $liveView $(if ($liveView) { 'running' } else { 'not running after 20 s' }) + + for ($i = 1; $i -le $Captures; $i++) { + Invoke-Capture "Capture $i with live view" + Start-Sleep -Seconds 2 + } + + foreach ($value in @('fire', 'off', 'fire')) { + Invoke-FlashChange $value + Start-Sleep -Seconds 2 + } + + if (-not $SkipPowerCycle) { + Write-Host '' + Write-Host 'Turn the camera OFF, wait 10 s, turn it back ON, then press Enter.' -ForegroundColor Yellow + Write-Host '(In a virtual machine, connect the camera to it again if needed.)' + [void](Read-Host) + + $back = Wait-Until $CameraTimeoutSeconds { Test-LiveView } + Add-Step 'Reconnected after power cycle' $back $(if ($back) { 'camera and live view back' } else { "not back within $CameraTimeoutSeconds s" }) + if ($back) { Invoke-Capture 'Capture after power cycle' } + } +} + +New-Item -ItemType Directory -Path $results -Force | Out-Null + +if (-not (Get-Command $curl -ErrorAction SilentlyContinue)) { + throw "$curl was not found: it is included in Windows 10 1803 and later." +} + +$script:stream = $null + +try { + Invoke-Tests +} +finally { + if ($script:stream -and -not $script:stream.HasExited) { Stop-Process -Id $script:stream.Id -Force } + + $logs = Join-Path $PSScriptRoot 'logs' + $log = Get-ChildItem $logs -Filter 'canon-api*.log' -ErrorAction SilentlyContinue | + Sort-Object LastWriteTime -Descending | Select-Object -First 1 + if ($log) { Copy-Item $log.FullName $results } + + $table = $steps | Format-Table -AutoSize -Wrap | Out-String -Width 200 + Set-Content (Join-Path $results 'results.txt') $table + Write-Host $table + + $failed = @($steps | Where-Object { $_.Result -eq 'FAIL' }).Count + $color = if ($failed -eq 0) { 'Green' } else { 'Red' } + Write-Host ('{0} step(s), {1} failed. Pictures, results and API log: {2}' -f $steps.Count, $failed, $results) -ForegroundColor $color +} + +if (@($steps | Where-Object { $_.Result -eq 'FAIL' }).Count -gt 0) { exit 1 }