From c00e12bedbdd6768219b0bc1e52ba1433f16153f Mon Sep 17 00:00:00 2001 From: Gilbert Paquin Date: Wed, 30 Sep 2026 15:27:07 -0400 Subject: [PATCH 1/2] Fix the issues found in the review of the 2026 changes Review of v1.1.0.0 to v1.3.0.0 against the EDSDK 13.20.21 headers, API reference and samples. Camera and settings: - Exposure compensation is settable in M when ISO is Auto. - Aperture and shutter speed labels shared by a 1/2 and a 1/3 stop value resolve to the value the camera accepts; f/4.5 labels follow the other pairs. - GET/POST /settings include exposureCompensation; POST refuses unknown fields. - POST /flash waits for a capture in progress. - A capture cancelled by the client marks its late file as stale. API and hosting: - Operator settings go in appsettings.Local.json, never shipped, so automatic updates no longer overwrite them. - Canon:CaptureFileTypes defaults to empty: the binder appended the configured types to ["jpg"]. - /videostream ends when the application stops instead of delaying the shutdown by 30 s. - /liveview no longer serves stale frames. - Content root and log file next to the executable; Logging:LogLevel applied to Serilog; Retry-After exposed through CORS; OpenAPI response metadata fixed; update exit handler simplified (the check runs before the server starts). Wrapper and tests: - Unused EDSDK declarations aligned with the headers (EdsWrite, EdsCreateFileStreamEx, EdsSetFramePoint, FocusShiftSetting, EdsManualWBData). - New Canon.API.Tests project (linked sources, runs on Linux), run by CI, release workflow and Dockerfile; ineffective or timing-dependent tests reworked. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/build.yml | 2 +- .github/workflows/ci.yml | 2 +- .gitignore | 3 + CHANGELOG.md | 40 +++++++ CLAUDE.md | 14 ++- .../CameraExceptionHandlerTests.cs | 107 ++++++++++++++++++ Canon.API.Tests/CameraSettingsRequestTests.cs | 37 ++++++ Canon.API.Tests/Canon.API.Tests.csproj | 48 ++++++++ Canon.API.Tests/LogLevelsTests.cs | 47 ++++++++ Canon.API/Canon.API.csproj | 5 + Canon.API/Controllers/CanonController.cs | 28 +++-- Canon.API/Infrastructure/LogLevels.cs | 40 +++++++ .../NeverUnavailableAttribute.cs | 8 ++ .../ServiceCollectionExtensions.cs | 6 + Canon.API/Models/ApiModels.cs | 14 ++- Canon.API/Program.cs | 62 +++++----- Canon.Core.Tests/CameraModelsTests.cs | 17 ++- Canon.Core.Tests/CameraSettingRulesTests.cs | 40 ++++++- Canon.Core.Tests/Canon.Core.Tests.csproj | 5 + Canon.Core.Tests/CanonThreadTests.cs | 21 +++- Canon.Core.Tests/EdsdkHelperTests.cs | 22 ++++ Canon.Core.Tests/EdsdkInteropTests.cs | 61 ++++++++++ Canon.Core.Tests/LiveViewBroadcasterTests.cs | 54 +++++++-- Canon.Core/CameraSettings.cs | 33 ++++-- Canon.Core/Canon.Core.csproj | 1 + Canon.Core/CanonCamera.cs | 45 +++++++- Canon.Core/CanonCameraOptions.cs | 10 +- Canon.Core/EDSDK.cs | 65 ++++++----- Canon.Core/EdsdkHelper.cs | 24 +++- Canon.Core/LiveViewBroadcaster.cs | 4 +- CanonSDK.sln | 14 +++ Dockerfile | 10 +- README.md | 44 +++++-- docs/openapi.json | 80 +++++++++---- 34 files changed, 859 insertions(+), 154 deletions(-) create mode 100644 Canon.API.Tests/CameraExceptionHandlerTests.cs create mode 100644 Canon.API.Tests/CameraSettingsRequestTests.cs create mode 100644 Canon.API.Tests/Canon.API.Tests.csproj create mode 100644 Canon.API.Tests/LogLevelsTests.cs create mode 100644 Canon.API/Infrastructure/LogLevels.cs create mode 100644 Canon.API/Infrastructure/NeverUnavailableAttribute.cs create mode 100644 Canon.Core.Tests/EdsdkInteropTests.cs diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index bcfd7e8..8fc7a85 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -104,7 +104,7 @@ jobs: run: dotnet build CanonSDK.sln --configuration Release --no-restore - name: Run unit tests - run: dotnet test Canon.Core.Tests/Canon.Core.Tests.csproj --configuration Release --no-build + 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 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f9f0751..89e64ab 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -33,7 +33,7 @@ jobs: run: dotnet build CanonSDK.sln --configuration Release --no-restore - name: Run unit tests - run: dotnet test Canon.Core.Tests/Canon.Core.Tests.csproj --configuration Release --no-build --logger trx --results-directory TestResults + run: dotnet test CanonSDK.sln --configuration Release --no-build --logger trx --results-directory TestResults - name: Upload test results if: always() diff --git a/.gitignore b/.gitignore index ce89292..7ef69f3 100644 --- a/.gitignore +++ b/.gitignore @@ -416,3 +416,6 @@ FodyWeavers.xsd *.msix *.msm *.msp + +# Operator settings of a local install (see README, Configuration) +appsettings.Local.json diff --git a/CHANGELOG.md b/CHANGELOG.md index 1121866..e8cd21c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,46 @@ Versions match the Git tags (`v1.0.0.11`) that trigger the release workflow. ## [Unreleased] +### Added +- `appsettings.Local.json` (next to `appsettings.json`) for the settings of a photo booth. It is not part of the release + package, so automatic updates no longer reset them: move any setting changed in `appsettings.json` to this file. +- `exposureCompensation` in `GET /settings` and `POST /settings`. +- `Canon.API.Tests`: unit tests of the error mapping, the request models and the log levels, run by the CI and release + workflows and by the Dockerfile. + +### Changed +- Exposure compensation can be set in manual exposure mode (M) when ISO is Auto (also when `POST /settings` sets ISO + to Auto in the same call), as the camera allows. It was always refused in M. +- An aperture or shutter speed label shared by a 1/2 and a 1/3 stop value (e.g. `"2.5"` and `"2.5 (1/3)"`) is set to the + one the camera accepts with its current exposure step, instead of being refused. +- f/4.5 values are labelled like the other pairs: 0x2B (1/3 stop) is now `"4.5 (1/3)"` and 0x2C (1/2 stop) `"4.5"`. + Sending `"4.5"` still works with both exposure steps. +- `POST /flash` waits for a capture or an autofocus in progress to finish. +- `POST /settings` refuses an unknown field (400) instead of ignoring it, e.g. a misspelled setting. +- Settings files and logs (`logs/canon-api.log`) are next to the executable whatever the working directory (shortcut, + scheduled task...); `appsettings.json` was only read from the working directory. +- `Logging:LogLevel` applies to the logs: it was ignored, and every request was logged by ASP.NET Core. +- `Retry-After` can be read by browser clients (CORS). +- OpenAPI document: HEIF and CR3 content types for `/takepicture` and `/latestpicture`, 409 for `/autofocus` and + `POST /flash`, no 503 for `/status` and `/latestpicture`. +- The update check no longer tries to stop the web server before updating: it runs before the server starts. + +### Fixed +- Automatic updates overwrote `appsettings.json`, resetting the settings of the photo booth (see `appsettings.Local.json`). +- `Canon:CaptureFileTypes` always included `jpg`: the configured types were added to the default one instead of replacing + it (e.g. `["cr3"]` also accepted JPEG files). The documentation no longer claims an order of preference: the first + matching file the camera sends is returned. +- An open `/videostream` delayed the application shutdown by 30 s; closing the window during that time left the camera + session open. Streams now end as soon as the application stops. +- The file of a capture whose request was cancelled by the client could answer the next capture. +- `/liveview` could return a frame up to a few seconds old after the last `/videostream` client left, or a frame of a + previous live view. +- EDSDK wrapper declarations not used by the application, aligned with the 13.20.21 headers: `EdsWrite` (64-bit written + size), `EdsCreateFileStreamEx` (declared under a name the DLL does not export), `EdsSetFramePoint`, `FocusShiftSetting` + (8 fields) and `EdsManualWBData` (header layout and serialization helpers). +- Unit tests that could not detect the regression they were written for (live view errors, shared live view producer) + or depended on timing (SDK thread disposal). + ## [1.3.0.0] - 2026-09-30 ### Added diff --git a/CLAUDE.md b/CLAUDE.md index f186b28..578d13a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -8,11 +8,12 @@ CanonWebAPI is a .NET 10 web API for remotely controlling Canon DSLR and mirrorl ## Architecture -The solution consists of 3 projects: +The solution consists of 4 projects: - **Canon.API**: ASP.NET Core Web API (main entry point) - **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) ### Key Components @@ -47,7 +48,7 @@ dotnet run --project Canon.API ### Run Unit Tests ```bash -dotnet test Canon.Core.Tests +dotnet test CanonSDK.sln ``` ### Docker (Windows containers) @@ -88,7 +89,9 @@ Errors are problem details: 400 invalid value, 409 capture refused or setting lo - 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 -- Uses structured logging with Serilog (logs to console and `logs/canon-api.log`) +- 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` - Target deployment: photo booth with a Canon EOS R100, dummy battery, no memory card (pictures are saved to the PC only) - `docs/openapi.json` must be regenerated when endpoints change @@ -98,7 +101,7 @@ The application includes AutoUpdater.NET with these configurations: - Automatic version checking on startup - No admin privileges required (`RunUpdateAsAdmin = false`) - Custom download path to avoid temp folder permission issues -- Graceful web server shutdown during updates +- The update check runs synchronously before the web server starts: nothing to stop when the updater exits the application - Updates sourced from GitHub releases via `docs/autoupdate.xml` ## Release Process @@ -125,5 +128,6 @@ The project uses GitHub Actions for automated releases (record every change in t ## Testing -- **Canon.Core.Tests**: xUnit tests of the logic that does not need a camera (value tables, timeouts, file types, CanonThread, LiveViewBroadcaster) +- **Canon.Core.Tests**: xUnit tests of the logic that does not need a camera (value tables, settings rules, timeouts, file types, CanonThread, LiveViewBroadcaster, EDSDK structure layouts) +- **Canon.API.Tests**: xUnit tests of the error mapping (CameraExceptionHandler), the request models and the log levels - End-to-end testing requires a physical Canon camera connected via USB (not possible in Windows containers) \ No newline at end of file diff --git a/Canon.API.Tests/CameraExceptionHandlerTests.cs b/Canon.API.Tests/CameraExceptionHandlerTests.cs new file mode 100644 index 0000000..64c4d28 --- /dev/null +++ b/Canon.API.Tests/CameraExceptionHandlerTests.cs @@ -0,0 +1,107 @@ +using System.Text.Json; +using Canon.API.Infrastructure; +using Canon.Core; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging.Abstractions; + +namespace Canon.API.Tests; + +public class CameraExceptionHandlerTests +{ + private static readonly CameraMode Av = new(0x02, "Aperture Priority AE", true, false); + + public static TheoryData StatusCodesByException => new() + { + { new CameraNotConnectedException("x"), 503 }, + { new EdsException(EDSDK.EDS_ERR_DEVICE_BUSY, "x"), 503 }, + { new EdsException(EDSDK.EDS_ERR_COMM_DISCONNECTED, "x"), 503 }, + { new TimeoutException(), 504 }, + { new CaptureFailedException(1), 409 }, + { new EdsException(EDSDK.EDS_ERR_TAKE_PICTURE_AF_NG, "x"), 409 }, + { new EdsException(EDSDK.EDS_ERR_OPERATION_REFUSED, "x"), 409 }, + { new EdsException(EDSDK.EDS_ERR_INVALID_DEVICEPROP_VALUE, "x"), 400 }, + { new EdsException(EDSDK.EDS_ERR_INTERNAL_ERROR, "x"), 500 }, + { new ArgumentException("x"), 400 }, + { Refused(SettingErrorReason.NotSettableInMode), 409 }, + { Refused(SettingErrorReason.ValueNotAccepted), 400 }, + { Refused(SettingErrorReason.InvalidValue), 400 }, + { NotApplied(new EdsException(EDSDK.EDS_ERR_DEVICE_BUSY, "x")), 503 }, + { NotApplied(new EdsException(EDSDK.EDS_ERR_INVALID_DEVICEPROP_VALUE, "x")), 400 }, + { new InvalidOperationException(), 500 }, + }; + + private static CameraSettingsException Refused(SettingErrorReason reason) => + new(Av, [new SettingError(CameraProperty.ShutterSpeed, "1/125", reason, ["1/60"], "refused")]); + + private static CameraSettingsApplyException NotApplied(Exception inner) => + new([CameraProperty.ISOSpeed], CameraProperty.Aperture, "5.6", inner); + + [Theory] + [MemberData(nameof(StatusCodesByException))] + public void Exceptions_map_to_the_documented_status(Exception exception, int status) + { + Assert.Equal(status, CameraExceptionHandler.Map(exception).Status); + } + + [Fact] + public async Task Busy_camera_answers_503_with_retry_after() + { + var (context, body) = await Handle(new EdsException(EDSDK.EDS_ERR_DEVICE_BUSY, "Could not set ISO")); + + Assert.Equal(503, context.Response.StatusCode); + Assert.Equal("1", context.Response.Headers.RetryAfter.ToString()); + Assert.Equal("0x00000081", body.GetProperty("errorCode").GetString()); + } + + [Fact] + public async Task Busy_camera_while_applying_settings_answers_503_with_retry_after_and_the_applied_settings() + { + var (context, body) = await Handle(NotApplied(new EdsException(EDSDK.EDS_ERR_DEVICE_BUSY, "x"))); + + Assert.Equal(503, context.Response.StatusCode); + Assert.Equal("1", context.Response.Headers.RetryAfter.ToString()); + Assert.Equal(["iso"], body.GetProperty("applied").EnumerateArray().Select(e => e.GetString())); + Assert.Equal("aperture", body.GetProperty("failed").GetProperty("property").GetString()); + } + + [Fact] + public async Task Refused_settings_are_machine_readable() + { + var (context, body) = await Handle(Refused(SettingErrorReason.NotSettableInMode)); + + Assert.Equal(409, context.Response.StatusCode); + Assert.Equal("not-settable-in-mode", body.GetProperty("reason").GetString()); + Assert.Equal("shutterSpeed", body.GetProperty("property").GetString()); + Assert.Equal("Aperture Priority AE", body.GetProperty("aeMode").GetString()); + Assert.Equal(1, body.GetProperty("errors").GetArrayLength()); + } + + [Fact] + public async Task Nothing_is_written_when_the_client_went_away() + { + var context = new DefaultHttpContext { RequestAborted = new CancellationToken(true) }; + var handler = CreateHandler(out _); + + Assert.True(await handler.TryHandleAsync(context, new OperationCanceledException(), CancellationToken.None)); + Assert.Equal(200, context.Response.StatusCode); + } + + private static CameraExceptionHandler CreateHandler(out IServiceProvider services) + { + services = new ServiceCollection().AddLogging().AddProblemDetails().BuildServiceProvider(); + return new CameraExceptionHandler(NullLogger.Instance, services.GetRequiredService()); + } + + private static async Task<(HttpContext Context, JsonElement Body)> Handle(Exception exception) + { + var handler = CreateHandler(out var services); + var context = new DefaultHttpContext { RequestServices = services }; + context.Request.Path = "/test"; + context.Response.Body = new MemoryStream(); + + Assert.True(await handler.TryHandleAsync(context, exception, CancellationToken.None)); + + context.Response.Body.Position = 0; + return (context, (await JsonDocument.ParseAsync(context.Response.Body)).RootElement); + } +} diff --git a/Canon.API.Tests/CameraSettingsRequestTests.cs b/Canon.API.Tests/CameraSettingsRequestTests.cs new file mode 100644 index 0000000..786aef6 --- /dev/null +++ b/Canon.API.Tests/CameraSettingsRequestTests.cs @@ -0,0 +1,37 @@ +using System.Text.Json; +using Canon.API.Models; +using Canon.Core; + +namespace Canon.API.Tests; + +public class CameraSettingsRequestTests +{ + // Options used by ASP.NET Core for request bodies. + private static readonly JsonSerializerOptions Web = new(JsonSerializerDefaults.Web); + + [Fact] + public void Every_setting_is_mapped_in_write_order() + { + var request = JsonSerializer.Deserialize( + """{ "whiteBalance": "Daylight", "exposureCompensation": "+1/3", "shutterSpeed": "1/125", "aperture": "5.6", "iso": "400" }""", Web); + + var settings = request!.ToDictionary(); + + Assert.Equal(CameraSettingRules.WriteOrder, CameraSettingRules.WriteOrder.Where(settings.ContainsKey)); + Assert.Equal("+1/3", settings[CameraProperty.ExposureCompensation]); + } + + [Fact] + public void Omitted_settings_are_left_unchanged() + { + var settings = JsonSerializer.Deserialize("""{ "iso": "Auto" }""", Web)!.ToDictionary(); + + Assert.Equal([CameraProperty.ISOSpeed], settings.Keys); + } + + [Fact] + public void Unknown_field_is_refused() + { + Assert.Throws(() => JsonSerializer.Deserialize("""{ "exposure": "+1/3" }""", Web)); + } +} diff --git a/Canon.API.Tests/Canon.API.Tests.csproj b/Canon.API.Tests/Canon.API.Tests.csproj new file mode 100644 index 0000000..b54fd69 --- /dev/null +++ b/Canon.API.Tests/Canon.API.Tests.csproj @@ -0,0 +1,48 @@ + + + + net10.0-windows + enable + enable + x64 + false + true + + + + + + + + + + + + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + + + + + + diff --git a/Canon.API.Tests/LogLevelsTests.cs b/Canon.API.Tests/LogLevelsTests.cs new file mode 100644 index 0000000..6d2f1b5 --- /dev/null +++ b/Canon.API.Tests/LogLevelsTests.cs @@ -0,0 +1,47 @@ +using Canon.API.Infrastructure; +using Serilog; +using Serilog.Core; +using Serilog.Events; + +namespace Canon.API.Tests; + +public class LogLevelsTests +{ + private static Logger Create(Dictionary levels) => + new LoggerConfiguration() + .ApplyLogLevels(new ConfigurationBuilder() + .AddInMemoryCollection(levels.ToDictionary(l => $"Logging:LogLevel:{l.Key}", l => l.Value)) + .Build()) + .CreateLogger(); + + private static bool IsEnabled(Logger logger, string category, LogEventLevel level) => + logger.ForContext(Constants.SourceContextPropertyName, category).IsEnabled(level); + + [Fact] + public void Category_levels_of_appsettings_apply() + { + using var logger = Create(new() { ["Default"] = "Information", ["Microsoft.AspNetCore"] = "Warning" }); + + Assert.True(IsEnabled(logger, "Canon.Core.CanonCamera", LogEventLevel.Information)); + Assert.False(IsEnabled(logger, "Canon.Core.CanonCamera", LogEventLevel.Debug)); + Assert.False(IsEnabled(logger, "Microsoft.AspNetCore.Routing.EndpointMiddleware", LogEventLevel.Information)); + Assert.True(IsEnabled(logger, "Microsoft.AspNetCore.Routing.EndpointMiddleware", LogEventLevel.Warning)); + } + + [Fact] + public void Default_level_can_be_lowered_and_invalid_levels_are_ignored() + { + using var logger = Create(new() { ["Default"] = "Debug", ["Canon"] = "loud" }); + + Assert.True(IsEnabled(logger, "Canon.Core.CanonCamera", LogEventLevel.Debug)); + } + + [Fact] + public void None_keeps_only_fatal_events() + { + using var logger = Create(new() { ["Default"] = "None" }); + + Assert.False(logger.IsEnabled(LogEventLevel.Error)); + Assert.True(logger.IsEnabled(LogEventLevel.Fatal)); + } +} diff --git a/Canon.API/Canon.API.csproj b/Canon.API/Canon.API.csproj index b2dbbce..f489390 100644 --- a/Canon.API/Canon.API.csproj +++ b/Canon.API/Canon.API.csproj @@ -31,6 +31,11 @@ + + + + + diff --git a/Canon.API/Controllers/CanonController.cs b/Canon.API/Controllers/CanonController.cs index 7c70086..1417e0a 100644 --- a/Canon.API/Controllers/CanonController.cs +++ b/Canon.API/Controllers/CanonController.cs @@ -1,6 +1,7 @@ using System.ComponentModel; using System.ComponentModel.DataAnnotations; using System.Text; +using Canon.API.Infrastructure; using Canon.API.Models; using Canon.Core; using Microsoft.AspNetCore.Http.Features; @@ -16,7 +17,7 @@ namespace Canon.API.Controllers; [ApiController] [ProducesResponseType(StatusCodes.Status503ServiceUnavailable, "application/problem+json")] [ProducesResponseType(StatusCodes.Status500InternalServerError, "application/problem+json")] -public class CanonController(ILogger logger, CanonCamera camera, LiveViewBroadcaster liveView) : ControllerBase +public class CanonController(ILogger logger, CanonCamera camera, LiveViewBroadcaster liveView, IHostApplicationLifetime lifetime) : ControllerBase { private static readonly byte[] FrameBoundary = "--frame\r\nContent-Type: image/jpeg\r\nContent-Length: "u8.ToArray(); private static readonly byte[] HeaderEnd = "\r\n\r\n"u8.ToArray(); @@ -28,6 +29,7 @@ public class CanonController(ILogger logger, CanonCamera camera public async Task GetCameraName() => Ok(await camera.GetCameraName()); [HttpGet("status")] + [NeverUnavailable] [EndpointSummary("Camera connection, shooting mode, temperature and live view state")] [EndpointDescription("Never fails because of the camera: when it is not connected, 'connected' is false and 'error' explains why.")] [ProducesResponseType(StatusCodes.Status200OK, "application/json")] @@ -76,13 +78,14 @@ public async Task GetStatus() [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.")] [ProducesResponseType(StatusCodes.Status200OK, "application/json")] [ProducesResponseType(StatusCodes.Status400BadRequest, "application/problem+json")] - public async Task SetFlash([Required][FromBody] string value) + [ProducesResponseType(StatusCodes.Status409Conflict, "application/problem+json")] + public async Task SetFlash([Required][FromBody] string value, CancellationToken cancellationToken) { if (!FlashStatus.TryParseFiring(value, out var firing)) throw new ArgumentException($"Invalid flash value '{value}'. Use \"fire\" or \"off\".", nameof(value)); logger.LogInformation("Setting flash firing to {Firing}", firing ? "Fire" : "Off"); - await camera.SetFlashFiringAsync(firing); + await camera.SetFlashFiringAsync(firing, cancellationToken); return Ok(await camera.GetFlashStatus()); } @@ -105,7 +108,7 @@ private async Task SetValue(CameraProperty property, string value "The problem details carry reason, property, acceptedValues, aeMode and aeModeCode."; [HttpGet("settings")] - [EndpointSummary("Shooting mode, ISO, aperture, shutter speed and white balance in one call")] + [EndpointSummary("Shooting mode, ISO, aperture, shutter speed, exposure compensation and white balance in one call")] [EndpointDescription("For each setting: current value, values the camera accepts now, and whether it can be changed now (settable: allowed by the shooting mode and at least one value accepted by the camera).")] [ProducesResponseType(StatusCodes.Status200OK, "application/json")] public async Task GetSettings() => Ok(await camera.GetSettingsAsync()); @@ -116,7 +119,7 @@ private async Task SetValue(CameraProperty property, string value "Every value is checked before anything is written; when one is refused nothing is written and the problem details list " + "every refused setting in 'errors' (property, value, reason, acceptedValues, detail). 409 when one of them cannot be changed " + "in the current shooting mode, 400 otherwise. Values are then written in a fixed order (iso, aperture, shutterSpeed, " + - "whiteBalance), never during a capture. If a write fails, the problem details list the settings already written in " + + "exposureCompensation, whiteBalance), never during a capture. An unknown field is refused (400). If a write fails, the problem details list the settings already written in " + "'applied' and the one that failed in 'failed'. On success, returns the settings read back from the camera, as GET /settings.")] [ProducesResponseType(StatusCodes.Status200OK, "application/json")] [ProducesResponseType(StatusCodes.Status400BadRequest, "application/problem+json")] @@ -176,7 +179,7 @@ public async Task SetSettings([Required][FromBody] CameraSettings [HttpPost("exposure")] [EndpointSummary("Set the exposure compensation (e.g. \"0\", \"+1/3\", \"-1 2/3\")")] - [EndpointDescription("Not available in manual exposure mode, nor in the basic zone modes. " + SetterErrors)] + [EndpointDescription("In manual exposure mode, only with ISO Auto; not available in the basic zone modes. " + SetterErrors)] [ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status400BadRequest, "application/problem+json")] [ProducesResponseType(StatusCodes.Status409Conflict, "application/problem+json")] @@ -204,7 +207,7 @@ public async Task SetSettings([Required][FromBody] CameraSettings [HttpPost("takepicture")] [EndpointSummary("Take a picture and return the captured file")] [EndpointDescription("The timeout adapts to the shutter speed. The file name is returned in the X-File-Name header.")] - [ProducesResponseType(StatusCodes.Status200OK, "image/jpeg", "application/octet-stream")] + [ProducesResponseType(StatusCodes.Status200OK, "image/jpeg", "image/heif", "image/x-canon-cr3", "application/octet-stream")] [ProducesResponseType(StatusCodes.Status409Conflict, "application/problem+json")] [ProducesResponseType(StatusCodes.Status504GatewayTimeout, "application/problem+json")] public async Task TakePicture( @@ -222,8 +225,9 @@ public async Task TakePicture( } [HttpGet("latestpicture")] + [NeverUnavailable] [EndpointSummary("Last picture received from the camera")] - [ProducesResponseType(StatusCodes.Status200OK, "image/jpeg", "application/octet-stream")] + [ProducesResponseType(StatusCodes.Status200OK, "image/jpeg", "image/heif", "image/x-canon-cr3", "application/octet-stream")] [ProducesResponseType(StatusCodes.Status404NotFound, "application/problem+json")] public IActionResult GetLatestPicture() { @@ -239,6 +243,7 @@ public IActionResult GetLatestPicture() [HttpPost("autofocus")] [EndpointSummary("Focus (half-press of the shutter button)")] [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status409Conflict, "application/problem+json")] public async Task AutoFocus() { logger.LogInformation("Starting auto focus"); @@ -283,7 +288,10 @@ public async Task GetLiveViewFrame() [ProducesResponseType(StatusCodes.Status200OK, "multipart/x-mixed-replace")] public async Task GetVideoStream() { - var cancellationToken = HttpContext.RequestAborted; + // Kestrel waits for open requests before stopping: end the stream when the application stops, + // so the live view is stopped and the camera session closed without waiting for the shutdown timeout. + using var stopping = CancellationTokenSource.CreateLinkedTokenSource(HttpContext.RequestAborted, lifetime.ApplicationStopping); + var cancellationToken = stopping.Token; // Fails with a problem details response when no camera is connected, before the stream starts. await camera.ConnectAsync(); @@ -308,7 +316,7 @@ public async Task GetVideoStream() } catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { - // Normal client disconnect. + // Client disconnected or application stopping. } finally { diff --git a/Canon.API/Infrastructure/LogLevels.cs b/Canon.API/Infrastructure/LogLevels.cs new file mode 100644 index 0000000..6fe09dc --- /dev/null +++ b/Canon.API/Infrastructure/LogLevels.cs @@ -0,0 +1,40 @@ +using Serilog; +using Serilog.Events; + +namespace Canon.API.Infrastructure; + +public static class LogLevels +{ + /// + /// Applies the "Logging:LogLevel" section (Microsoft format: "Default" and category prefixes) to Serilog, which + /// replaces the Microsoft logging pipeline and ignores that section otherwise. + /// + public static LoggerConfiguration ApplyLogLevels(this LoggerConfiguration logger, IConfiguration configuration) + { + foreach (var entry in configuration.GetSection("Logging:LogLevel").GetChildren()) + { + if (!Enum.TryParse(entry.Value, ignoreCase: true, out var level)) + continue; + + var serilogLevel = ToSerilog(level); + + if (string.Equals(entry.Key, "Default", StringComparison.OrdinalIgnoreCase)) + logger.MinimumLevel.Is(serilogLevel); + else + logger.MinimumLevel.Override(entry.Key, serilogLevel); + } + + return logger; + } + + // Serilog has no level above Fatal: "None" keeps only fatal events. + private static LogEventLevel ToSerilog(LogLevel level) => level switch + { + LogLevel.Trace => LogEventLevel.Verbose, + LogLevel.Debug => LogEventLevel.Debug, + LogLevel.Information => LogEventLevel.Information, + LogLevel.Warning => LogEventLevel.Warning, + LogLevel.Error => LogEventLevel.Error, + _ => LogEventLevel.Fatal + }; +} diff --git a/Canon.API/Infrastructure/NeverUnavailableAttribute.cs b/Canon.API/Infrastructure/NeverUnavailableAttribute.cs new file mode 100644 index 0000000..f7a246e --- /dev/null +++ b/Canon.API/Infrastructure/NeverUnavailableAttribute.cs @@ -0,0 +1,8 @@ +namespace Canon.API.Infrastructure; + +/// +/// Marks an endpoint that answers even when the camera is not connected or busy: the 503 response declared for the +/// whole controller is removed from its OpenAPI description. +/// +[AttributeUsage(AttributeTargets.Method)] +public sealed class NeverUnavailableAttribute : Attribute; diff --git a/Canon.API/Infrastructure/ServiceCollectionExtensions.cs b/Canon.API/Infrastructure/ServiceCollectionExtensions.cs index 9b9903b..50a9029 100644 --- a/Canon.API/Infrastructure/ServiceCollectionExtensions.cs +++ b/Canon.API/Infrastructure/ServiceCollectionExtensions.cs @@ -22,6 +22,12 @@ public static IServiceCollection AddCanonApi(this IServiceCollection services, I "503 camera not connected or busy, 504 capture timeout."; return Task.CompletedTask; }); + options.AddOperationTransformer((operation, context, _) => + { + if (context.Description.ActionDescriptor.EndpointMetadata.OfType().Any()) + operation.Responses?.Remove(StatusCodes.Status503ServiceUnavailable.ToString()); + return Task.CompletedTask; + }); }); services.AddProblemDetails(); services.AddExceptionHandler(); diff --git a/Canon.API/Models/ApiModels.cs b/Canon.API/Models/ApiModels.cs index c06dfdf..c3d9283 100644 --- a/Canon.API/Models/ApiModels.cs +++ b/Canon.API/Models/ApiModels.cs @@ -1,3 +1,4 @@ +using System.Text.Json.Serialization; using Canon.Core; namespace Canon.API.Models; @@ -29,13 +30,21 @@ public sealed record CameraStatusResponse( string? Error); /// -/// Settings to apply with POST /settings. Omitted (or null) settings are left unchanged. +/// Settings to apply with POST /settings. Omitted (or null) settings are left unchanged; an unknown field is refused, +/// so a misspelled setting is not silently ignored. /// /// ISO speed (e.g. "Auto", "400"). /// Aperture (e.g. "5.6"). /// Shutter speed (e.g. "1/125"). +/// Exposure compensation (e.g. "0", "+1/3", "-1 2/3"). /// White balance (e.g. "Auto", "Daylight"). -public sealed record CameraSettingsRequest(string? Iso = null, string? Aperture = null, string? ShutterSpeed = null, string? WhiteBalance = null) +[JsonUnmappedMemberHandling(JsonUnmappedMemberHandling.Disallow)] +public sealed record CameraSettingsRequest( + string? Iso = null, + string? Aperture = null, + string? ShutterSpeed = null, + string? ExposureCompensation = null, + string? WhiteBalance = null) { public Dictionary ToDictionary() { @@ -43,6 +52,7 @@ public Dictionary ToDictionary() if (Iso != null) settings[CameraProperty.ISOSpeed] = Iso; if (Aperture != null) settings[CameraProperty.Aperture] = Aperture; if (ShutterSpeed != null) settings[CameraProperty.ShutterSpeed] = ShutterSpeed; + if (ExposureCompensation != null) settings[CameraProperty.ExposureCompensation] = ExposureCompensation; if (WhiteBalance != null) settings[CameraProperty.WhiteBalance] = WhiteBalance; return settings; } diff --git a/Canon.API/Program.cs b/Canon.API/Program.cs index 343efab..5a0e1ea 100644 --- a/Canon.API/Program.cs +++ b/Canon.API/Program.cs @@ -2,10 +2,14 @@ using Serilog; using AutoUpdaterDotNET; +// Next to the executable, whatever the working directory (shortcut, scheduled task...). +var logFile = Path.Combine(AppContext.BaseDirectory, "logs", "canon-api.log"); + +// Startup logger, replaced by the configured one once the host is built. Log.Logger = new LoggerConfiguration() .WriteTo.Console() - .WriteTo.File("logs/canon-api.log", rollingInterval: RollingInterval.Day) - .CreateLogger(); + .WriteTo.File(logFile, rollingInterval: RollingInterval.Day) + .CreateBootstrapLogger(); // Display application version var version = System.Reflection.Assembly.GetExecutingAssembly().GetName().Version; @@ -13,9 +17,21 @@ try { - var builder = WebApplication.CreateBuilder(args); - - builder.Services.AddSerilog(); + // Settings files are read next to the executable, whatever the working directory. + var builder = WebApplication.CreateBuilder(new WebApplicationOptions { Args = args, ContentRootPath = AppContext.BaseDirectory }); + + // Operator settings. The release package ships appsettings.json, which every automatic update overwrites; + // this file is never shipped. Environment variables and the command line keep precedence over it. + builder.Configuration + .AddJsonFile("appsettings.Local.json", optional: true, reloadOnChange: false) + .AddEnvironmentVariables() + .AddCommandLine(args); + + // This overload reloads the startup logger, which closes its log file before the configured logger opens it. + builder.Services.AddSerilog((_, logger) => logger + .ApplyLogLevels(builder.Configuration) + .WriteTo.Console() + .WriteTo.File(logFile, rollingInterval: RollingInterval.Day)); builder.Services.AddCanonApi(builder.Configuration, version); // The API only listens on the local machine, so any origin is allowed. @@ -26,7 +42,7 @@ policy.AllowAnyOrigin() .AllowAnyMethod() .AllowAnyHeader() - .WithExposedHeaders("X-File-Name"); + .WithExposedHeaders("X-File-Name", "Retry-After"); }); }); @@ -72,39 +88,13 @@ static void ConfigureAutoUpdater(WebApplication app) AutoUpdater.DownloadPath = appDirectory; AutoUpdater.InstallationPath = appDirectory; - // Handle application exit for updates - properly shutdown web server and release the camera + // The check is synchronous and runs before app.Run(): the web server is not started and the camera not opened yet, + // so there is nothing to stop before the updater replaces the files. AutoUpdater.ApplicationExitEvent += () => { Log.Information("AutoUpdater requesting application exit for update..."); - - var shutdownTask = Task.Run(async () => - { - try - { - // Stop accepting new requests, then dispose services: stops the live view, - // closes the camera session and terminates the SDK. - using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(8)); - await app.StopAsync(timeout.Token); - await app.DisposeAsync(); - Log.Information("Web server stopped gracefully"); - } - catch (Exception ex) - { - Log.Warning(ex, "Error during graceful shutdown, forcing exit"); - } - finally - { - Log.CloseAndFlush(); - Environment.Exit(0); - } - }); - - // Wait maximum 10 seconds for graceful shutdown - if (!shutdownTask.Wait(10000)) - { - Log.Warning("Graceful shutdown timed out, forcing exit"); - Environment.Exit(0); - } + Log.CloseAndFlush(); + Environment.Exit(0); }; AutoUpdater.Start(app.Configuration["AutoUpdate:Url"] ?? "https://dansleboby.github.io/CanonWebAPI/autoupdate.xml"); diff --git a/Canon.Core.Tests/CameraModelsTests.cs b/Canon.Core.Tests/CameraModelsTests.cs index 9717706..e99c51e 100644 --- a/Canon.Core.Tests/CameraModelsTests.cs +++ b/Canon.Core.Tests/CameraModelsTests.cs @@ -1,4 +1,5 @@ using Canon.Core; +using Microsoft.Extensions.Configuration; namespace Canon.Core.Tests; @@ -93,10 +94,24 @@ public void Default_options_capture_jpeg_only() { var options = new CanonCameraOptions(); - Assert.Equal(["jpg"], options.CaptureFileTypes); + // Empty: CanonCamera falls back to JPEG. + Assert.Empty(options.CaptureFileTypes); Assert.Equal(10, options.CaptureTimeoutSeconds); } + [Fact] + public void Configured_capture_file_types_replace_the_default() + { + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary { ["Canon:CaptureFileTypes:0"] = "cr3" }) + .Build(); + + var options = configuration.GetSection(CanonCameraOptions.SectionName).Get(); + + Assert.NotNull(options); + Assert.Equal(["cr3"], options.CaptureFileTypes); + } + [Fact] public void Eds_exception_classifies_errors() { diff --git a/Canon.Core.Tests/CameraSettingRulesTests.cs b/Canon.Core.Tests/CameraSettingRulesTests.cs index ac6fed6..72ffe26 100644 --- a/Canon.Core.Tests/CameraSettingRulesTests.cs +++ b/Canon.Core.Tests/CameraSettingRulesTests.cs @@ -150,13 +150,49 @@ public void Everything_but_exposure_compensation_can_be_changed_in_M(CameraPrope } [Fact] - public void Exposure_compensation_cannot_be_changed_in_M() + public void Exposure_compensation_can_be_changed_in_M_only_with_ISO_Auto() { - Assert.Equal(SettingErrorReason.NotSettableInMode, Validate(CameraProperty.ExposureCompensation, "+1/3", Manual)?.Reason); + var error = Validate(CameraProperty.ExposureCompensation, "+1/3", Manual); + + Assert.Equal(SettingErrorReason.NotSettableInMode, error?.Reason); + Assert.Contains("unless ISO is Auto", error!.Message); + Assert.Null(CameraSettingRules.Validate(CameraProperty.ExposureCompensation, "+1/3", Manual, null, out _, isoAuto: true)); + Assert.True(CameraSettingRules.IsSettable(CameraProperty.ExposureCompensation, Manual, null, isoAuto: true)); + Assert.False(CameraSettingRules.IsSettable(CameraProperty.ExposureCompensation, Manual, [], isoAuto: true)); Assert.Null(Validate(CameraProperty.ExposureCompensation, "+1/3", Av)); Assert.Null(Validate(CameraProperty.ExposureCompensation, "+1/3", ProgramAE)); } + [Fact] + public void ISO_Auto_does_not_unlock_other_modes() + { + Assert.False(CameraSettingRules.IsSettableInMode(CameraProperty.ExposureCompensation, SceneIntelligentAuto, isoAuto: true)); + Assert.False(CameraSettingRules.IsSettableInMode(CameraProperty.ShutterSpeed, Av, isoAuto: true)); + } + + [Theory] + [InlineData("2.5", 0x1Du, 0x1Cu)] + [InlineData("4.5", 0x2Bu, 0x2Cu)] + public void Label_shared_by_both_exposure_steps_takes_the_value_the_camera_accepts(string label, uint oneThirdStop, uint halfStop) + { + // Apertures listed by the camera with 1/3 and 1/2 stop exposure steps (2 to 5.6). + uint[] oneThirdStops = [0x18, 0x1B, 0x1D, 0x20, 0x23, 0x25, 0x28, 0x2B, 0x2D, 0x30]; + uint[] halfStops = [0x18, 0x1C, 0x20, 0x24, 0x28, 0x2C, 0x30]; + + Assert.Null(CameraSettingRules.Validate(CameraProperty.Aperture, label, Manual, oneThirdStops, out var value)); + Assert.Equal(oneThirdStop, value); + Assert.Null(CameraSettingRules.Validate(CameraProperty.Aperture, label, Manual, halfStops, out value)); + Assert.Equal(halfStop, value); + } + + [Fact] + public void Raw_value_is_never_swapped_for_the_other_exposure_step() + { + uint[] oneThirdStops = [0x18, 0x1B, 0x1D, 0x20]; + + Assert.Equal(SettingErrorReason.ValueNotAccepted, Validate(CameraProperty.Aperture, "0x1C", Manual, oneThirdStops)?.Reason); + } + [Fact] public void Primary_error_is_the_mode_error() { diff --git a/Canon.Core.Tests/Canon.Core.Tests.csproj b/Canon.Core.Tests/Canon.Core.Tests.csproj index 95942aa..f5b5286 100644 --- a/Canon.Core.Tests/Canon.Core.Tests.csproj +++ b/Canon.Core.Tests/Canon.Core.Tests.csproj @@ -18,6 +18,11 @@ + + + + + diff --git a/Canon.Core.Tests/CanonThreadTests.cs b/Canon.Core.Tests/CanonThreadTests.cs index 1f32573..4d56074 100644 --- a/Canon.Core.Tests/CanonThreadTests.cs +++ b/Canon.Core.Tests/CanonThreadTests.cs @@ -89,7 +89,12 @@ public async Task Dispose_fails_pending_tasks_instead_of_hanging() // The first task is running and the second one is still queued when Dispose is called. Assert.True(started.Wait(TimeSpan.FromSeconds(5))); var dispose = Task.Run(thread.Dispose); - await Task.Delay(100); + using (var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5))) + { + // Dispose refuses new work first: the running task is released only once Dispose has started. + while (!IsDisposed(thread)) + await Task.Delay(5, timeout.Token); + } blocker.Set(); await dispose.WaitAsync(TimeSpan.FromSeconds(5)); @@ -108,6 +113,20 @@ public async Task Dispose_fails_pending_tasks_instead_of_hanging() Assert.NotNull(error); } + private static bool IsDisposed(CanonThread thread) + { + try + { + // Queued behind the running task: failed like the other pending tasks once disposed. + _ = thread.InvokeAsync(() => 0); + return false; + } + catch (ObjectDisposedException) + { + return true; + } + } + [Fact] public async Task Dispose_does_not_deadlock_under_load() { diff --git a/Canon.Core.Tests/EdsdkHelperTests.cs b/Canon.Core.Tests/EdsdkHelperTests.cs index 46cc11d..57241f6 100644 --- a/Canon.Core.Tests/EdsdkHelperTests.cs +++ b/Canon.Core.Tests/EdsdkHelperTests.cs @@ -40,6 +40,28 @@ public void Exposure_compensation_table_matches_the_documentation(string label, Assert.Equal(label, ((uint)CameraProperty.ExposureCompensation).DescribeValue(value)); } + [Theory] + [InlineData(0x2Bu, "4.5 (1/3)")] + [InlineData(0x2Cu, "4.5")] + [InlineData(0x1Du, "2.5 (1/3)")] + [InlineData(0x1Cu, "2.5")] + public void Aperture_pairs_mark_the_one_third_stop_value(uint value, string label) + { + Assert.Equal(label, ((uint)CameraProperty.Aperture).DescribeValue(value)); + } + + [Fact] + public void Same_label_values_are_the_other_exposure_step() + { + var aperture = (uint)CameraProperty.Aperture; + + Assert.Equal([0x2Bu], aperture.GetSameLabelValues("4.5")); + Assert.Equal([0x2Cu], aperture.GetSameLabelValues("4.5 (1/3)")); + Assert.Equal([0x1Du], ((uint)CameraProperty.ShutterSpeed).GetSameLabelValues("10\"")); + Assert.Empty(aperture.GetSameLabelValues("5.6")); + Assert.Empty(aperture.GetSameLabelValues("0x1C")); + } + [Fact] public void Exposure_compensation_has_every_documented_step() { diff --git a/Canon.Core.Tests/EdsdkInteropTests.cs b/Canon.Core.Tests/EdsdkInteropTests.cs new file mode 100644 index 0000000..f06c031 --- /dev/null +++ b/Canon.Core.Tests/EdsdkInteropTests.cs @@ -0,0 +1,61 @@ +using System.Buffers.Binary; +using System.Runtime.InteropServices; +using System.Text; +using Canon.Core; + +namespace Canon.Core.Tests; + +/// +/// Layouts checked against EDSDKTypes.h (13.20.21), x64. +/// +public class EdsdkInteropTests +{ + [Fact] + public void Structures_have_the_size_of_the_headers() + { + Assert.Equal(32, Marshal.SizeOf()); + Assert.Equal(48, Marshal.SizeOf()); + Assert.Equal(524, Marshal.SizeOf()); + Assert.Equal(288, Marshal.SizeOf()); + } + + [Fact] + public void Manual_white_balance_data_is_serialized_with_its_whole_payload() + { + var data = new EDSDK.EdsManualWBData { Valid = 1, dataSize = 20, szCaption = "Studio", data = Enumerable.Range(1, 20).Select(i => (byte)i).ToArray() }; + + var buffer = EDSDK.ConvertMWB(data); + + Assert.Equal(40 + 12 + 20, buffer.Length); + Assert.Equal(1u, BinaryPrimitives.ReadUInt32LittleEndian(buffer)); + Assert.Equal(20u + 12, BinaryPrimitives.ReadUInt32LittleEndian(buffer.AsSpan(4))); + Assert.Equal("Studio", Encoding.ASCII.GetString(buffer, 8, 6)); + Assert.All(buffer[40..52], b => Assert.Equal(0, b)); + Assert.Equal(data.data, buffer[52..]); + } + + [Fact] + public void Manual_white_balance_data_is_read_with_its_whole_payload() + { + var buffer = new byte[40 + 20]; + BinaryPrimitives.WriteUInt32LittleEndian(buffer, 1); + BinaryPrimitives.WriteUInt32LittleEndian(buffer.AsSpan(4), 20); + Encoding.ASCII.GetBytes("Studio").CopyTo(buffer, 8); + for (var i = 0; i < 20; i++) + buffer[40 + i] = (byte)(i + 1); + + var handle = GCHandle.Alloc(buffer, GCHandleType.Pinned); + try + { + var data = EDSDK.MarshalPtrToManualWBData(handle.AddrOfPinnedObject()); + + Assert.Equal(1u, data.Valid); + Assert.Equal("Studio", data.szCaption); + Assert.Equal(buffer[40..], data.data); + } + finally + { + handle.Free(); + } + } +} diff --git a/Canon.Core.Tests/LiveViewBroadcasterTests.cs b/Canon.Core.Tests/LiveViewBroadcasterTests.cs index 06fea49..bcadd8b 100644 --- a/Canon.Core.Tests/LiveViewBroadcasterTests.cs +++ b/Canon.Core.Tests/LiveViewBroadcasterTests.cs @@ -66,14 +66,32 @@ public async Task Two_clients_share_a_single_producer() await using var broadcaster = Create(camera); using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(10)); - var first = Read(broadcaster, 20, timeout.Token); - var second = Read(broadcaster, 20, timeout.Token); - await Task.WhenAll(first, second); + var clients = await Task.WhenAll(Read(broadcaster, 20, timeout.Token), Read(broadcaster, 20, timeout.Token)); Assert.Equal(1, camera.Starts); - // Each frame is downloaded once and shared: far fewer downloads than frames delivered. - Assert.True(camera.FramesRequested < 40 + 10, $"{camera.FramesRequested} frames requested"); - Assert.NotNull(broadcaster.LatestFrame); + // Every download returns a new frame number: clients downloading their own frames would share none. + Assert.NotEmpty(clients[0].Select(f => f[0]).Intersect(clients[1].Select(f => f[0]))); + } + + [Fact] + public async Task Latest_frame_is_only_kept_while_a_client_streams() + { + var camera = new FakeCamera(); + await using var broadcaster = Create(camera, idleStopDelay: 5000); + using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + + await foreach (var _ in broadcaster.ReadFramesAsync(timeout.Token)) + { + Assert.NotNull(broadcaster.LatestFrame); + break; + } + + // The live view keeps running during the idle delay, but no frame is downloaded anymore. + while (broadcaster.LatestFrame != null && !timeout.IsCancellationRequested) + await Task.Delay(5); + + Assert.Null(broadcaster.LatestFrame); + Assert.True(broadcaster.IsRunning); } [Fact] @@ -127,9 +145,31 @@ public async Task Live_view_is_stopped_even_when_the_last_frames_failed() var camera = new FakeCamera(); await using var broadcaster = Create(camera); using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + using var client = CancellationTokenSource.CreateLinkedTokenSource(timeout.Token); - await Read(broadcaster, 2, timeout.Token); + var reader = Task.Run(async () => + { + try + { + await foreach (var _ in broadcaster.ReadFramesAsync(client.Token)) + { + } + } + catch (OperationCanceledException) + { + } + }); + + while (broadcaster.LatestFrame is null) + await Task.Delay(5, timeout.Token); + + // The downloads fail while the client is still connected, then the client leaves. camera.FailuresToSimulate = int.MaxValue; + var requested = camera.FramesRequested; + while (camera.FramesRequested < requested + 2) + await Task.Delay(5, timeout.Token); + await client.CancelAsync(); + await reader; while (broadcaster.IsRunning && !timeout.IsCancellationRequested) await Task.Delay(10); diff --git a/Canon.Core/CameraSettings.cs b/Canon.Core/CameraSettings.cs index c666bde..d74870f 100644 --- a/Canon.Core/CameraSettings.cs +++ b/Canon.Core/CameraSettings.cs @@ -46,6 +46,7 @@ public sealed record CameraSettings( CameraSettingState Iso, CameraSettingState Aperture, CameraSettingState ShutterSpeed, + CameraSettingState ExposureCompensation, CameraSettingState WhiteBalance); /// @@ -77,12 +78,15 @@ public static class CameraSettingRules // kEdsPropID_Tv value of Bulb: it can never be set from a computer (EDSDK API reference, kEdsPropID_Tv). private const uint TvBulb = 0x0C; + /// kEdsPropID_ISOSpeed value of ISO Auto. + public const uint IsoAuto = 0x00; + /// /// Settings that can be changed in each creative zone mode. In the other (basic zone) modes, the camera sets /// capture-related properties by itself and none can be changed (EDSDK API reference, kEdsPropID_AEModeSelect). /// EdsGetPropertyDesc does not say whether a setting is locked by the mode (its "form" and "access" fields are - /// reserved, always 0, EDSDK 13.20 API reference 3.1.20) nor what it lists in that case, hence this table. - /// Exposure compensation is not available in manual exposure mode (EDSDK API reference 5.2.28). + /// reserved, always 0, EDSDK 13.20 API reference 3.1.20), hence this table. + /// Exposure compensation in manual exposure mode: see . /// private static readonly Dictionary SettableByMode = new() { @@ -99,8 +103,13 @@ public static class CameraSettingRules /// /// True when can be changed in the AE mode (kEdsPropID_AEMode value). /// - public static bool IsSettableInMode(CameraProperty property, uint aeMode) => - SettableByMode.TryGetValue(aeMode, out var properties) && properties.Contains(property); + /// + /// ISO is Auto. In manual exposure mode, exposure compensation only applies with ISO Auto (EOS R user guides); + /// the EDSDK API reference (5.2.28) predates it and says it is never available in that mode. + /// + public static bool IsSettableInMode(CameraProperty property, uint aeMode, bool isoAuto = false) => + (SettableByMode.TryGetValue(aeMode, out var properties) && properties.Contains(property)) + || (property == CameraProperty.ExposureCompensation && aeMode == Manual && isoAuto); /// /// True when can be changed now: allowed by the AE mode, and the camera lists at least @@ -108,8 +117,8 @@ public static bool IsSettableInMode(CameraProperty property, uint aeMode) => /// changed in the current state (movie mode, no lens...): the Canon samples disable the setting in that case, /// and the EDSDK API reference asks to set only values from that list. Null means the camera does not list the values. /// - public static bool IsSettable(CameraProperty property, uint aeMode, IReadOnlyCollection? settableValues) => - IsSettableInMode(property, aeMode) && settableValues is not { Count: 0 }; + public static bool IsSettable(CameraProperty property, uint aeMode, IReadOnlyCollection? settableValues, bool isoAuto = false) => + IsSettableInMode(property, aeMode, isoAuto) && settableValues is not { Count: 0 }; /// /// Checks a requested value before it is written to the camera. @@ -122,8 +131,9 @@ public static bool IsSettable(CameraProperty property, uint aeMode, IReadOnlyCol /// the mode are checked. Empty: the setting cannot be changed now (see ). /// /// The raw value to write, when valid. + /// ISO is Auto once the settings are applied (see ). /// Null when the value can be written, otherwise why it cannot. - public static SettingError? Validate(CameraProperty property, string? requested, uint aeMode, IReadOnlyCollection? settableValues, out uint value) + public static SettingError? Validate(CameraProperty property, string? requested, uint aeMode, IReadOnlyCollection? settableValues, out uint value, bool isoAuto = false) { var propId = (uint)property; var accepted = (IReadOnlyList)(settableValues ?? []).Select(v => propId.DescribeValue(v)).ToList(); @@ -135,10 +145,11 @@ public static bool IsSettable(CameraProperty property, uint aeMode, IReadOnlyCol $"Invalid value '{requested}' for {property.ToSettingName()}."); } - if (!IsSettableInMode(property, aeMode)) + if (!IsSettableInMode(property, aeMode, isoAuto)) { return new SettingError(property, requested, SettingErrorReason.NotSettableInMode, [], - $"{property.ToSettingName()} cannot be changed in the current shooting mode ({DescribeAEMode(aeMode)})."); + $"{property.ToSettingName()} cannot be changed in the current shooting mode ({DescribeAEMode(aeMode)})" + + (property == CameraProperty.ExposureCompensation && aeMode == Manual ? " unless ISO is Auto." : ".")); } if (settableValues is { Count: 0 }) @@ -148,6 +159,10 @@ public static bool IsSettable(CameraProperty property, uint aeMode, IReadOnlyCol $"({DescribeAEMode(aeMode)}; movie mode, lens...)."); } + // A label shared by a 1/2 and a 1/3 stop value (e.g. aperture "2.5"): take the one allowed by the current exposure step. + if (settableValues != null && !settableValues.Contains(value)) + value = propId.GetSameLabelValues(requested).FirstOrDefault(settableValues.Contains, value); + if ((settableValues != null && !settableValues.Contains(value)) || (property == CameraProperty.ShutterSpeed && value == TvBulb)) { return new SettingError(property, requested, SettingErrorReason.ValueNotAccepted, accepted, diff --git a/Canon.Core/Canon.Core.csproj b/Canon.Core/Canon.Core.csproj index 178938b..52604da 100644 --- a/Canon.Core/Canon.Core.csproj +++ b/Canon.Core/Canon.Core.csproj @@ -26,6 +26,7 @@ + diff --git a/Canon.Core/CanonCamera.cs b/Canon.Core/CanonCamera.cs index 7ca9a78..7ecc617 100644 --- a/Canon.Core/CanonCamera.cs +++ b/Canon.Core/CanonCamera.cs @@ -618,21 +618,23 @@ public async Task SetValue(CameraProperty property, string description) } /// - /// Reads the shooting mode and the ISO, aperture, shutter speed and white balance settings in one call. + /// Reads the shooting mode and the ISO, aperture, shutter speed, exposure compensation and white balance settings in one call. /// public Task GetSettingsAsync() => RunWithBusyRetryAsync(camera => { var mode = ReadMode(camera); + var isoAuto = IsIsoAuto(camera); CameraSettingState Read(CameraProperty property) { EDSDK.EdsGetPropertyData(camera, (uint)property, 0, out uint value).ThrowIfEdSdkError($"Could not get {property}"); var settable = GetSettableValues(camera, property); var supported = (settable ?? []).Select(v => ((uint)property).DescribeValue(v)).ToList(); - return new CameraSettingState(((uint)property).DescribeValue(value), supported, CameraSettingRules.IsSettable(property, mode.AEModeCode, settable)); + return new CameraSettingState(((uint)property).DescribeValue(value), supported, CameraSettingRules.IsSettable(property, mode.AEModeCode, settable, isoAuto)); } - return new CameraSettings(mode, Read(CameraProperty.ISOSpeed), Read(CameraProperty.Aperture), Read(CameraProperty.ShutterSpeed), Read(CameraProperty.WhiteBalance)); + return new CameraSettings(mode, Read(CameraProperty.ISOSpeed), Read(CameraProperty.Aperture), Read(CameraProperty.ShutterSpeed), + Read(CameraProperty.ExposureCompensation), Read(CameraProperty.WhiteBalance)); }); /// @@ -658,12 +660,13 @@ public async Task ApplySettingsAsync(IReadOnlyDictionary var values = await RunWithBusyRetryAsync(camera => { var mode = ReadMode(camera); + var isoAuto = IsIsoAutoAfter(camera, settings); var errors = new List(); var valid = new List<(CameraProperty Property, uint Value)>(); foreach (var property in ordered) { - var error = CameraSettingRules.Validate(property, settings[property], mode.AEModeCode, GetSettableValues(camera, property), out var value); + var error = CameraSettingRules.Validate(property, settings[property], mode.AEModeCode, GetSettableValues(camera, property), out var value, isoAuto); if (error != null) errors.Add(error); else @@ -700,6 +703,17 @@ await RunWithBusyRetryAsync(camera => } } + /// + /// Whether ISO is Auto once are applied: the requested ISO when readable, the current one otherwise. + /// + private static bool IsIsoAutoAfter(nint camera, IReadOnlyDictionary settings) => + settings.TryGetValue(CameraProperty.ISOSpeed, out var requested) && ((uint)CameraProperty.ISOSpeed).TryParseValue(requested, out var requestedIso) + ? requestedIso == CameraSettingRules.IsoAuto + : IsIsoAuto(camera); + + private static bool IsIsoAuto(nint camera) => + EDSDK.EdsGetPropertyData(camera, (uint)CameraProperty.ISOSpeed, 0, out uint iso) == EDSDK.EDS_ERR_OK && iso == CameraSettingRules.IsoAuto; + /// /// Gets the shooting mode of the camera (mode dial position and still/movie mode). /// @@ -834,6 +848,7 @@ public async Task TakePictureAsync(bool useAutoFocus = true, IEnu var pending = new PendingCapture(acceptedTypes); _pendingCapture = pending; _capturing = true; + var pressed = false; try { @@ -849,6 +864,7 @@ public async Task TakePictureAsync(bool useAutoFocus = true, IEnu await RunWithBusyRetryAsync(camera => EDSDK.EdsSendCommand(camera, EDSDK.CameraCommand_PressShutterButton, (int)button) .ThrowIfEdSdkError("Could not press the shutter button"), cancellationToken); + pressed = true; } finally { @@ -864,6 +880,12 @@ await RunWithBusyRetryAsync(camera => $"The camera did not deliver a picture within {timeout.TotalSeconds:0.#} s. " + $"Check the focus and that the image quality set on the camera produces one of these file types: {string.Join(", ", acceptedTypes)}."); } + catch (OperationCanceledException) when (pressed) + { + // The shot was taken: its file may still arrive and must not answer the next capture. + _staleTransfers = true; + throw; + } finally { _pendingCapture = null; @@ -1007,8 +1029,21 @@ public Task GetFlashStatus() => RunAsync(_ => /// /// Sets the "flash firing" camera setting (Fire or Off). /// With , it is set back to Fire before the next capture. + /// Waits for a capture or an autofocus in progress to finish. /// - public Task SetFlashFiringAsync(bool firing) => RunWithBusyRetryAsync(camera => SetFlashFiringCore(camera, firing)); + public async Task SetFlashFiringAsync(bool firing, CancellationToken cancellationToken = default) + { + await _shutterLock.WaitAsync(cancellationToken); + + try + { + await RunWithBusyRetryAsync(camera => SetFlashFiringCore(camera, firing), cancellationToken); + } + finally + { + _shutterLock.Release(); + } + } #endregion diff --git a/Canon.Core/CanonCameraOptions.cs b/Canon.Core/CanonCameraOptions.cs index bc8fc88..a4e08cd 100644 --- a/Canon.Core/CanonCameraOptions.cs +++ b/Canon.Core/CanonCameraOptions.cs @@ -8,11 +8,15 @@ public sealed class CanonCameraOptions public const string SectionName = "Canon"; /// - /// File types downloaded after a capture, by extension without the dot, in order of preference. - /// Default: JPEG only. Other files (RAW, HEIF...) are cancelled so the camera releases them. + /// File types downloaded after a capture, by extension without the dot. The first matching file the camera sends + /// answers the capture. Empty (default): JPEG only. Other files (RAW, HEIF...) are cancelled so the camera releases them. /// "jpg" also matches ".jpeg", "heif" also matches ".hif", "*" accepts every file. /// - public string[] CaptureFileTypes { get; set; } = ["jpg"]; + /// + /// Empty rather than ["jpg"] by default: the configuration binder appends to an existing array, so a configured + /// ["cr3"] would bind as ["jpg", "cr3"]. + /// + public string[] CaptureFileTypes { get; set; } = []; /// /// Time allowed for the camera to deliver a capture, in seconds, on top of the exposure time. diff --git a/Canon.Core/EDSDK.cs b/Canon.Core/EDSDK.cs index b73a9c2..093f840 100644 --- a/Canon.Core/EDSDK.cs +++ b/Canon.Core/EDSDK.cs @@ -1362,29 +1362,29 @@ public static uint EdsGetPropertyData(IntPtr inRef, uint inPropertyID, int inPar public static byte[] ConvertMWB(EdsManualWBData pcwb) { - var headerSize = 40; - var MWBHEADERSIZE = sizeof(uint) * 3; - - var datasize = (int) pcwb.dataSize; - // Since the pointer is copied by the following StructureToPtr, - // if the data size is less than the pointer size, the buffer is enlarged. - if (datasize < IntPtr.Size) - datasize = IntPtr.Size; - - var size = (int) pcwb.dataSize + MWBHEADERSIZE + headerSize; - var ptr = Marshal.AllocHGlobal(datasize + headerSize); - pcwb.dataSize += (uint) MWBHEADERSIZE; - Marshal.StructureToPtr(pcwb, ptr, true); - pcwb.dataSize -= (uint) MWBHEADERSIZE; - var buff = new byte[size]; - Marshal.Copy(ptr, buff, 0, headerSize); - var i = 0; - for (i = 0; i < MWBHEADERSIZE; i++) - buff[headerSize + i] = 0; - - for (var j = 0; j < pcwb.dataSize; j++) buff[headerSize + i + j] = pcwb.data[j]; - Marshal.FreeHGlobal(ptr); - return buff; + const int headerSize = 40; + const int mwbHeaderSize = sizeof(uint) * 3; + + // Only the header goes through the marshaller, with a placeholder of the declared size for data. + var header = pcwb; + header.dataSize += mwbHeaderSize; + header.data = new byte[8]; + + var ptr = Marshal.AllocHGlobal(Marshal.SizeOf()); + try + { + Marshal.StructureToPtr(header, ptr, false); + + // Header, then mwbHeaderSize zero bytes, then the payload. + var buff = new byte[headerSize + mwbHeaderSize + pcwb.dataSize]; + Marshal.Copy(ptr, buff, 0, headerSize); + pcwb.data.AsSpan(0, (int) pcwb.dataSize).CopyTo(buff.AsSpan(headerSize + mwbHeaderSize)); + return buff; + } + finally + { + Marshal.FreeHGlobal(ptr); + } } public static EdsManualWBData MarshalPtrToManualWBData(IntPtr ptr) @@ -1826,10 +1826,10 @@ public static extern uint EdsCreateFileStream(string inFileName, EdsFileCreateDi /*----------------------------------------------------------------------------- // - // Function: EdsCreateStreamEx + // Function: EdsCreateFileStreamEx // // Description: - // An extended version of EdsCreateStreamFromFile. + // An extended version of EdsCreateFileStream. // Use this function when working with Unicode file names. // // Parameters: @@ -1843,8 +1843,8 @@ public static extern uint EdsCreateFileStream(string inFileName, EdsFileCreateDi // Returns: Any of the sdk errors. -----------------------------------------------------------------------------*/ [DllImport("EDSDK.dll")] - public static extern uint EdsCreateStreamEx( - string inFileName, + public static extern uint EdsCreateFileStreamEx( + [MarshalAs(UnmanagedType.LPWStr)] string inFileName, EdsFileCreateDisposition inCreateDisposition, EdsAccess inDesiredAccess, out IntPtr outStream @@ -1934,7 +1934,7 @@ public static extern uint EdsRead(IntPtr inStreamRef, ulong inReadSize, IntPtr o -----------------------------------------------------------------------------*/ [DllImport("EDSDK.dll")] public static extern uint EdsWrite(IntPtr inStreamRef, ulong inWriteSize, IntPtr inBuffer, - out uint outWrittenSize); + out ulong outWrittenSize); /*----------------------------------------------------------------------------- // @@ -2267,7 +2267,7 @@ public static extern uint EdsSetCameraStateEventHandler(IntPtr inCameraRef, uint // Returns: Any of the sdk errors. ------------------------------------------------------------------------------*/ [DllImport("EDSDK.dll")] - public static extern uint EdsSetFramePoint(IntPtr inCameraRef, EdsSize inFramePoint, bool inLockAfFrame); + public static extern uint EdsSetFramePoint(IntPtr inCameraRef, EdsPoint inFramePoint, [MarshalAs(UnmanagedType.U1)] bool inLockAfFrame); /*----------------------------------------------------------------------------- // @@ -2594,6 +2594,9 @@ public struct FocusShiftSetting public uint ShootingNumber; public uint StepWidth; public uint ExposureSmoothing; + public uint FocusStackingFunction; + public uint FocusStackingTrimming; + public uint FlashInterval; } /*----------------------------------------------------------------------------- @@ -2608,7 +2611,9 @@ public struct EdsManualWBData [MarshalAs(UnmanagedType.ByValTStr, SizeConst = 32)] public string szCaption; - [MarshalAs(UnmanagedType.ByValArray)] public byte[] data; + // The native header declares data[8]; the actual payload is dataSize bytes, copied by ConvertMWB and + // MarshalPtrToManualWBData outside of the marshaller. + [MarshalAs(UnmanagedType.ByValArray, SizeConst = 8)] public byte[] data; } #endregion diff --git a/Canon.Core/EdsdkHelper.cs b/Canon.Core/EdsdkHelper.cs index 168cc57..cafc124 100644 --- a/Canon.Core/EdsdkHelper.cs +++ b/Canon.Core/EdsdkHelper.cs @@ -195,8 +195,8 @@ public static string GetErrorMessage(uint errorCode) => { 0x24, "3.5" }, { 0x25, "3.5 (1/3)" }, { 0x28, "4" }, - { 0x2B, "4.5" }, - { 0x2C, "4.5 (1/3)" }, + { 0x2B, "4.5 (1/3)" }, + { 0x2C, "4.5" }, { 0x2D, "5.0" }, { 0x30, "5.6" }, { 0x33, "6.3" }, @@ -587,6 +587,24 @@ public static bool TryParseValue(this uint propId, string text, out uint value) return false; } + private const string OneThirdStopSuffix = " (1/3)"; + + /// + /// Values of the other exposure step whose label reads the same as , e.g. aperture "2.5" + /// gives 0x1D ("2.5 (1/3)") and "2.5 (1/3)" gives 0x1C ("2.5"). Empty for raw values and labels without a pair. + /// + public static IEnumerable GetSameLabelValues(this uint propId, string label) + { + label = label.Trim(); + var pair = label.EndsWith(OneThirdStopSuffix, StringComparison.OrdinalIgnoreCase) + ? label[..^OneThirdStopSuffix.Length] + : label + OneThirdStopSuffix; + + return DescriptionsByProperty.TryGetValue(propId, out var descriptions) && descriptions.TryGetValue(pair, out var value) + ? [value] + : []; + } + /// /// Gets the exposure duration in seconds of a kEdsPropID_Tv value. Returns false for Bulb and unknown values. /// @@ -597,7 +615,7 @@ public static bool TryGetExposureSeconds(uint tvValue, out double seconds) if (!TvValues.TryGetValue(tvValue, out var label) || tvValue == 0x0C) return false; - label = label.Replace(" (1/3)", string.Empty); + label = label.Replace(OneThirdStopSuffix, string.Empty); var culture = System.Globalization.CultureInfo.InvariantCulture; if (label.EndsWith('"')) diff --git a/Canon.Core/LiveViewBroadcaster.cs b/Canon.Core/LiveViewBroadcaster.cs index 92c9f4d..c2958ce 100644 --- a/Canon.Core/LiveViewBroadcaster.cs +++ b/Canon.Core/LiveViewBroadcaster.cs @@ -86,7 +86,8 @@ public bool IsRunning } /// - /// The last frame downloaded, if any. + /// The last frame downloaded, while at least one client streams. Null when no client streams: no frame is + /// downloaded then, so the last one would be stale. /// public byte[]? LatestFrame => _latestFrame; @@ -155,6 +156,7 @@ private async Task ProduceAsync() if (subscribers.Length == 0) { idleSince ??= DateTime.UtcNow; + _latestFrame = null; if (DateTime.UtcNow - idleSince >= idleDelay || token.IsCancellationRequested) { diff --git a/CanonSDK.sln b/CanonSDK.sln index 5d28f8e..a70f9e5 100644 --- a/CanonSDK.sln +++ b/CanonSDK.sln @@ -9,6 +9,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Canon.API", "Canon.API\Cano EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Canon.Core.Tests", "Canon.Core.Tests\Canon.Core.Tests.csproj", "{F7599AF1-B3CE-4DFD-ACC5-A15919A9AF14}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Canon.API.Tests", "Canon.API.Tests\Canon.API.Tests.csproj", "{0D2C03A3-C98D-48CD-8620-D67277D4B679}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -55,6 +57,18 @@ Global {F7599AF1-B3CE-4DFD-ACC5-A15919A9AF14}.Release|x64.Build.0 = Release|Any CPU {F7599AF1-B3CE-4DFD-ACC5-A15919A9AF14}.Release|x86.ActiveCfg = Release|Any CPU {F7599AF1-B3CE-4DFD-ACC5-A15919A9AF14}.Release|x86.Build.0 = Release|Any CPU + {0D2C03A3-C98D-48CD-8620-D67277D4B679}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {0D2C03A3-C98D-48CD-8620-D67277D4B679}.Debug|Any CPU.Build.0 = Debug|Any CPU + {0D2C03A3-C98D-48CD-8620-D67277D4B679}.Debug|x64.ActiveCfg = Debug|Any CPU + {0D2C03A3-C98D-48CD-8620-D67277D4B679}.Debug|x64.Build.0 = Debug|Any CPU + {0D2C03A3-C98D-48CD-8620-D67277D4B679}.Debug|x86.ActiveCfg = Debug|Any CPU + {0D2C03A3-C98D-48CD-8620-D67277D4B679}.Debug|x86.Build.0 = Debug|Any CPU + {0D2C03A3-C98D-48CD-8620-D67277D4B679}.Release|Any CPU.ActiveCfg = Release|Any CPU + {0D2C03A3-C98D-48CD-8620-D67277D4B679}.Release|Any CPU.Build.0 = Release|Any CPU + {0D2C03A3-C98D-48CD-8620-D67277D4B679}.Release|x64.ActiveCfg = Release|Any CPU + {0D2C03A3-C98D-48CD-8620-D67277D4B679}.Release|x64.Build.0 = Release|Any CPU + {0D2C03A3-C98D-48CD-8620-D67277D4B679}.Release|x86.ActiveCfg = Release|Any CPU + {0D2C03A3-C98D-48CD-8620-D67277D4B679}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/Dockerfile b/Dockerfile index 7ab8ad3..cb4e5ca 100644 --- a/Dockerfile +++ b/Dockerfile @@ -21,20 +21,26 @@ WORKDIR C:\src COPY Directory.Build.props ./ COPY Canon.Core/Canon.Core.csproj Canon.Core/ COPY Canon.Core.Tests/Canon.Core.Tests.csproj Canon.Core.Tests/ +COPY Canon.API.Tests/Canon.API.Tests.csproj Canon.API.Tests/ COPY Canon.API/Canon.API.csproj Canon.API/ RUN dotnet restore Canon.Core.Tests/Canon.Core.Tests.csproj && ` + dotnet restore Canon.API.Tests/Canon.API.Tests.csproj && ` dotnet restore Canon.API/Canon.API.csproj -r win-x64 COPY EDSDK/ EDSDK/ COPY Canon.Core/ Canon.Core/ COPY Canon.Core.Tests/ Canon.Core.Tests/ +COPY Canon.API.Tests/ Canon.API.Tests/ COPY Canon.API/ Canon.API/ -RUN dotnet build Canon.Core.Tests/Canon.Core.Tests.csproj -c Release --no-restore +RUN dotnet build Canon.Core.Tests/Canon.Core.Tests.csproj -c Release --no-restore && ` + dotnet build Canon.API.Tests/Canon.API.Tests.csproj -c Release --no-restore FROM build AS test RUN dotnet test Canon.Core.Tests/Canon.Core.Tests.csproj -c Release --no-build ` - --logger "trx;LogFileName=results.trx" --results-directory C:\testresults + --logger "trx;LogFileName=core.trx" --results-directory C:\testresults && ` + dotnet test Canon.API.Tests/Canon.API.Tests.csproj -c Release --no-build ` + --logger "trx;LogFileName=api.trx" --results-directory C:\testresults # --------------------------------------------------------------------------- # Publish: same self-contained single-file executable as the GitHub release diff --git a/README.md b/README.md index 0f5e05e..b787cbd 100644 --- a/README.md +++ b/README.md @@ -36,6 +36,7 @@ The solution is divided into the following projects: * `Canon.API`: An ASP.NET Core web application that exposes the camera controls as a RESTful API. * `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`). ## Getting Started @@ -53,7 +54,8 @@ The solution is divided into the following projects: 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. 3. Build the solution using Visual Studio or the `dotnet build` command. -4. Run the `Canon.API` project. This will start the web server. +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. ## API Endpoints @@ -70,8 +72,8 @@ Setting values are sent as a JSON string, e.g. `"100"` with `Content-Type: appli | 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"` | -| GET | `/settings` | Shooting mode, plus ISO, aperture, shutter speed 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, whiteBalance), never during a capture. Returns the settings read back from the camera, as `GET /settings`. Omitted fields are left unchanged. | `{ "iso": "400", "aperture": "5.6", "shutterSpeed": "1/125", "whiteBalance": "Daylight" }` | +| 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 | | POST | `/iso` | Sets the ISO speed. | `"100"` | | GET | `/aperture` | Gets the current aperture and a list of supported values. | N/A | @@ -127,7 +129,7 @@ it (the `form`/`access` fields of `EdsGetPropertyDesc` are reserved), hence this | P, A-DEP | ✓ | | | ✓ | ✓ | | Tv | ✓ | | ✓ | ✓ | ✓ | | Av | ✓ | ✓ | | ✓ | ✓ | -| M | ✓ | ✓ | ✓ | | ✓ | +| M | ✓ | ✓ | ✓ | ✓ with ISO Auto | ✓ | | Bulb | ✓ | ✓ | | | ✓ | | Fv | ✓ | ✓ | ✓ | ✓ | ✓ | @@ -137,11 +139,26 @@ If the camera refuses a value while `POST /settings` writes it, the problem deta ## Configuration -Settings are read from `appsettings.json` (next to the executable) or environment variables (e.g. `Canon__CaptureFileTypes__0=jpg`). +Settings are read, in increasing order of precedence, from `appsettings.json`, `appsettings.Local.json` (both next to the +executable, whatever the working directory), environment variables (e.g. `Canon__CaptureFileTypes__0=jpg`) and the command line. +Logs are written to the console and to `logs/canon-api.log` next to the executable. + +Put the settings of a photo booth in `appsettings.Local.json`, never in `appsettings.json`: `appsettings.json` is part of the +release package and every automatic update overwrites it, while `appsettings.Local.json` is never shipped. It only needs the +settings that differ from the defaults, e.g.: + +```json +{ + "Canon": { + "FlashTarget": "External", + "CaptureFileTypes": [ "jpg", "cr3" ] + } +} +``` | Setting | Default | Description | |---------|---------|-------------| -| `Canon:CaptureFileTypes` | `["jpg"]` | File types downloaded after a capture, in order of preference (`jpg`, `heif`, `cr3`, `*`...). Other files are cancelled on the camera. Can be overridden per request with `fileTypes`. | +| `Canon:CaptureFileTypes` | `["jpg"]` | File types downloaded after a capture (`jpg`, `heif`, `cr3`, `*`...); the first matching file the camera sends is returned. Other files are cancelled on the camera. Can be overridden per request with `fileTypes`. | | `Canon:CaptureTimeoutSeconds` | `10` | Time allowed to receive a capture, added to the exposure time of the current shutter speed. | | `Canon:AutoFocusHoldMilliseconds` | `800` | How long `/autofocus` holds the shutter button halfway. | | `Canon:LiveViewSmallImage` | `false` | Uses the smaller live view image (less bandwidth, lower resolution; not supported by every camera). | @@ -152,16 +169,19 @@ Settings are read from `appsettings.json` (next to the executable) or environmen | `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`). | ## Automatic Updates This application includes automatic update functionality powered by AutoUpdater.NET: -* **Automatic version checking** on startup +* **Automatic version checking** on startup, before the web server starts and the camera is opened * **Seamless updates** without requiring admin privileges -* **Graceful shutdown** handling for web server during updates * 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` ## Development & Building @@ -189,7 +209,7 @@ This project uses automated GitHub Actions for releases: 1. **Changelog**: Move the changes of the `Unreleased` section of [CHANGELOG.md](CHANGELOG.md) under the new version and date, and update the version in `Canon.API/Canon.API.csproj` 2. **Tag-based releases**: Push a tag like `v1.3.0.0` on the latest commit of `main` to trigger automated build and release (the workflow refuses a tag on another commit) 3. **Version synchronization**: The workflow automatically updates project versions to match the tag -4. **Unit tests**: The workflow runs `Canon.Core.Tests` before packaging +4. **Unit tests**: The workflow runs `Canon.Core.Tests` and `Canon.API.Tests` before packaging 5. **Automatic packaging**: Creates release packages and updates the AutoUpdater XML 6. **GitHub releases**: Automatically creates GitHub releases with generated notes @@ -204,12 +224,14 @@ dotnet run --project Canon.API ### Unit Tests ```bash -dotnet test Canon.Core.Tests +dotnet test CanonSDK.sln ``` The `CI` GitHub Actions workflow builds the solution and runs these tests on every pull request and push to `main`. -The tests cover the value tables, the capture timeout, the file type filter, the SDK thread and the live view broadcaster. They do not need a camera and also run on Linux/macOS. +The tests cover the value tables, the settings rules, the capture timeout, the file type filter, the SDK thread, the live view +broadcaster, the structure layouts of the EDSDK wrapper, the error mapping of the API, the request models and the log levels. +They do not need a camera and also run on Linux/macOS. ### Docker (Windows containers) diff --git a/docs/openapi.json b/docs/openapi.json index 7d92c91..e1adf0f 100644 --- a/docs/openapi.json +++ b/docs/openapi.json @@ -54,16 +54,6 @@ "summary": "Camera connection, shooting mode, temperature and live view state", "description": "Never fails because of the camera: when it is not connected, 'connected' is false and 'error' explains why.", "responses": { - "503": { - "description": "Service Unavailable", - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - } - }, "500": { "description": "Internal Server Error", "content": { @@ -275,6 +265,16 @@ } } } + }, + "409": { + "description": "Conflict", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } } } } @@ -284,7 +284,7 @@ "tags": [ "Canon" ], - "summary": "Shooting mode, ISO, aperture, shutter speed and white balance in one call", + "summary": "Shooting mode, ISO, aperture, shutter speed, exposure compensation and white balance in one call", "description": "For each setting: current value, values the camera accepts now, and whether it can be changed now (settable: allowed by the shooting mode and at least one value accepted by the camera).", "responses": { "503": { @@ -324,7 +324,7 @@ "Canon" ], "summary": "Apply several settings at once (e.g. a day / night preset)", - "description": "Every value is checked before anything is written; when one is refused nothing is written and the problem details list every refused setting in 'errors' (property, value, reason, acceptedValues, detail). 409 when one of them cannot be changed in the current shooting mode, 400 otherwise. Values are then written in a fixed order (iso, aperture, shutterSpeed, whiteBalance), never during a capture. If a write fails, the problem details list the settings already written in 'applied' and the one that failed in 'failed'. On success, returns the settings read back from the camera, as GET /settings.", + "description": "Every value is checked before anything is written; when one is refused nothing is written and the problem details list every refused setting in 'errors' (property, value, reason, acceptedValues, detail). 409 when one of them cannot be changed in the current shooting mode, 400 otherwise. Values are then written in a fixed order (iso, aperture, shutterSpeed, exposureCompensation, whiteBalance), never during a capture. An unknown field is refused (400). If a write fails, the problem details list the settings already written in 'applied' and the one that failed in 'failed'. On success, returns the settings read back from the camera, as GET /settings.", "requestBody": { "content": { "application/json": { @@ -779,7 +779,7 @@ "Canon" ], "summary": "Set the exposure compensation (e.g. \"0\", \"+1/3\", \"-1 2/3\")", - "description": "Not available in manual exposure mode, nor in the basic zone modes. Errors: 400 when the label cannot be read (reason \"invalid-value\") or the camera does not accept the value now (\"value-not-accepted\"), 409 when the setting cannot be changed in the current shooting mode or the camera accepts no value now (\"not-settable-in-mode\"). The problem details carry reason, property, acceptedValues, aeMode and aeModeCode.", + "description": "In manual exposure mode, only with ISO Auto; not available in the basic zone modes. Errors: 400 when the label cannot be read (reason \"invalid-value\") or the camera does not accept the value now (\"value-not-accepted\"), 409 when the setting cannot be changed in the current shooting mode or the camera accepts no value now (\"not-settable-in-mode\"). The problem details carry reason, property, acceptedValues, aeMode and aeModeCode.", "requestBody": { "content": { "application/json": { @@ -1014,6 +1014,16 @@ "$ref": "#/components/schemas/Stream" } }, + "image/heif": { + "schema": { + "$ref": "#/components/schemas/Stream" + } + }, + "image/x-canon-cr3": { + "schema": { + "$ref": "#/components/schemas/Stream" + } + }, "application/octet-stream": { "schema": { "$ref": "#/components/schemas/Stream" @@ -1051,16 +1061,6 @@ ], "summary": "Last picture received from the camera", "responses": { - "503": { - "description": "Service Unavailable", - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - } - }, "500": { "description": "Internal Server Error", "content": { @@ -1079,6 +1079,16 @@ "$ref": "#/components/schemas/Stream" } }, + "image/heif": { + "schema": { + "$ref": "#/components/schemas/Stream" + } + }, + "image/x-canon-cr3": { + "schema": { + "$ref": "#/components/schemas/Stream" + } + }, "application/octet-stream": { "schema": { "$ref": "#/components/schemas/Stream" @@ -1128,6 +1138,16 @@ }, "200": { "description": "OK" + }, + "409": { + "description": "Conflict", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } } } } @@ -1285,6 +1305,7 @@ "iso", "aperture", "shutterSpeed", + "exposureCompensation", "whiteBalance" ], "type": "object", @@ -1301,6 +1322,9 @@ "shutterSpeed": { "$ref": "#/components/schemas/CameraSettingState" }, + "exposureCompensation": { + "$ref": "#/components/schemas/CameraSettingState" + }, "whiteBalance": { "$ref": "#/components/schemas/CameraSettingState" } @@ -1330,6 +1354,13 @@ ], "default": null }, + "exposureCompensation": { + "type": [ + "null", + "string" + ], + "default": null + }, "whiteBalance": { "type": [ "null", @@ -1337,7 +1368,8 @@ ], "default": null } - } + }, + "additionalProperties": false }, "CameraStatusResponse": { "required": [ From 6738ea073a1ae2c70779dd038d8d28a852a4064a Mon Sep 17 00:00:00 2001 From: Gilbert Paquin Date: Wed, 30 Sep 2026 15:52:48 -0400 Subject: [PATCH 2/2] Bump version to 1.4.0.0 and date the 1.4.0.0 release Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 5 ++++- Canon.API/Canon.API.csproj | 6 +++--- README.md | 6 ++++-- docs/openapi.json | 2 +- 4 files changed, 12 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e8cd21c..2e10c7c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,8 @@ Versions match the Git tags (`v1.0.0.11`) that trigger the release workflow. ## [Unreleased] +## [1.4.0.0] - 2026-09-30 + ### Added - `appsettings.Local.json` (next to `appsettings.json`) for the settings of a photo booth. It is not part of the release package, so automatic updates no longer reset them: move any setting changed in `appsettings.json` to this file. @@ -213,7 +215,8 @@ First release. - Automatic updates with AutoUpdater.NET, from GitHub releases. - CI/CD workflow building the release package. -[Unreleased]: https://github.com/dansleboby/CanonWebAPI/compare/v1.3.0.0...HEAD +[Unreleased]: https://github.com/dansleboby/CanonWebAPI/compare/v1.4.0.0...HEAD +[1.4.0.0]: https://github.com/dansleboby/CanonWebAPI/compare/v1.3.0.0...v1.4.0.0 [1.3.0.0]: https://github.com/dansleboby/CanonWebAPI/compare/v1.2.0.0...v1.3.0.0 [1.2.0.0]: https://github.com/dansleboby/CanonWebAPI/compare/v1.1.0.0...v1.2.0.0 [1.1.0.0]: https://github.com/dansleboby/CanonWebAPI/compare/v1.0.0.11...v1.1.0.0 diff --git a/Canon.API/Canon.API.csproj b/Canon.API/Canon.API.csproj index f489390..371a095 100644 --- a/Canon.API/Canon.API.csproj +++ b/Canon.API/Canon.API.csproj @@ -7,9 +7,9 @@ x64 - 1.3.0.0 - 1.3.0.0 - 1.3.0.0 + 1.4.0.0 + 1.4.0.0 + 1.4.0.0 true diff --git a/README.md b/README.md index b787cbd..c1d71ad 100644 --- a/README.md +++ b/README.md @@ -4,12 +4,14 @@ A web API for remotely controlling Canon DSLR and mirrorless cameras. This project utilizes the Canon EDSDK to communicate with the camera and includes automatic update capabilities. -**Current version: 1.3.0.0** (.NET 10, Canon EDSDK 13.20.21). See the [changelog](CHANGELOG.md) for the changes of each version. +**Current version: 1.4.0.0** (.NET 10, Canon EDSDK 13.20.21). See the [changelog](CHANGELOG.md) for the changes of each version. > **Upgrading from 1.0.x**: errors are now returned as problem details with new status codes (e.g. 503 when the camera is not connected, 504 instead of 408 on capture timeout), and values unknown to the value tables are returned in hexadecimal (`"0x99"`). See the [changelog](CHANGELOG.md#1100---2026-09-30). > **Upgrading from 1.2.x**: the setters (`/iso`, `/aperture`, `/shutterspeed`, `/exposure`, `/whitebalance`) now answer 409 `not-settable-in-mode` when the shooting mode locks the setting, and setting errors carry machine readable fields (`reason`, `acceptedValues`...). See [Setting errors](#setting-errors) and the [changelog](CHANGELOG.md#1300---2026-09-30). +> **Upgrading from 1.3.x**: settings changed in `appsettings.json` are reset by this update: put them in `appsettings.Local.json`, which updates never overwrite (see [Configuration](#configuration)). `POST /settings` refuses unknown fields (400), and f/4.5 in 1/3 stop steps now reads `"4.5 (1/3)"` (sending `"4.5"` still works). See the [changelog](CHANGELOG.md#1400---2026-09-30). + ## Features * Get camera information (e.g., camera name, shooting mode, temperature restrictions). @@ -207,7 +209,7 @@ dotnet run --project Canon.API This project uses automated GitHub Actions for releases: 1. **Changelog**: Move the changes of the `Unreleased` section of [CHANGELOG.md](CHANGELOG.md) under the new version and date, and update the version in `Canon.API/Canon.API.csproj` -2. **Tag-based releases**: Push a tag like `v1.3.0.0` on the latest commit of `main` to trigger automated build and release (the workflow refuses a tag on another commit) +2. **Tag-based releases**: Push a tag like `v1.4.0.0` on the latest commit of `main` to trigger automated build and release (the workflow refuses a tag on another commit) 3. **Version synchronization**: The workflow automatically updates project versions to match the tag 4. **Unit tests**: The workflow runs `Canon.Core.Tests` and `Canon.API.Tests` before packaging 5. **Automatic packaging**: Creates release packages and updates the AutoUpdater XML diff --git a/docs/openapi.json b/docs/openapi.json index e1adf0f..449e040 100644 --- a/docs/openapi.json +++ b/docs/openapi.json @@ -3,7 +3,7 @@ "info": { "title": "Canon Web API", "description": "Remote control of a Canon camera through the Canon EDSDK: settings, capture and MJPEG live view. Errors are returned as problem details: 400 invalid value, 409 capture refused by the camera or setting not changeable in the current shooting mode, 503 camera not connected or busy, 504 capture timeout.", - "version": "1.3.0" + "version": "1.4.0" }, "paths": { "/cameraname": {