Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -416,3 +416,6 @@ FodyWeavers.xsd
*.msix
*.msm
*.msp

# Operator settings of a local install (see README, Configuration)
appsettings.Local.json
45 changes: 44 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,48 @@ 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.
- `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
Expand Down Expand Up @@ -173,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
Expand Down
14 changes: 9 additions & 5 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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

Expand All @@ -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
Expand All @@ -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)
107 changes: 107 additions & 0 deletions Canon.API.Tests/CameraExceptionHandlerTests.cs
Original file line number Diff line number Diff line change
@@ -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<Exception, int> 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<CameraExceptionHandler>.Instance, services.GetRequiredService<IProblemDetailsService>());
}

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);
}
}
37 changes: 37 additions & 0 deletions Canon.API.Tests/CameraSettingsRequestTests.cs
Original file line number Diff line number Diff line change
@@ -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<CameraSettingsRequest>(
"""{ "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<CameraSettingsRequest>("""{ "iso": "Auto" }""", Web)!.ToDictionary();

Assert.Equal([CameraProperty.ISOSpeed], settings.Keys);
}

[Fact]
public void Unknown_field_is_refused()
{
Assert.Throws<JsonException>(() => JsonSerializer.Deserialize<CameraSettingsRequest>("""{ "exposure": "+1/3" }""", Web));
}
}
48 changes: 48 additions & 0 deletions Canon.API.Tests/Canon.API.Tests.csproj
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<TargetFramework>net10.0-windows</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<PlatformTarget>x64</PlatformTarget>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
</PropertyGroup>

<!--
Canon.API needs the Windows Desktop runtime (AutoUpdater.NET): referencing it would make these tests Windows only.
The parts under test do not depend on it, so their sources are compiled here instead.
-->
<ItemGroup>
<Compile Include="..\Canon.API\Infrastructure\CameraExceptionHandler.cs" Link="Linked\CameraExceptionHandler.cs" />
<Compile Include="..\Canon.API\Infrastructure\LogLevels.cs" Link="Linked\LogLevels.cs" />
<Compile Include="..\Canon.API\Models\ApiModels.cs" Link="Linked\ApiModels.cs" />
</ItemGroup>

<ItemGroup>
<FrameworkReference Include="Microsoft.AspNetCore.App" />
</ItemGroup>

<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.10.1" />
<PackageReference Include="Serilog.AspNetCore" Version="10.0.0" />
<PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.5">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
</ItemGroup>

<ItemGroup>
<!-- Implicit usings of the Web SDK used by the linked sources. -->
<Using Include="Microsoft.AspNetCore.Http" />
<Using Include="Microsoft.Extensions.Configuration" />
<Using Include="Microsoft.Extensions.Logging" />
<Using Include="Xunit" />
</ItemGroup>

<ItemGroup>
<ProjectReference Include="..\Canon.Core\Canon.Core.csproj" />
</ItemGroup>

</Project>
47 changes: 47 additions & 0 deletions Canon.API.Tests/LogLevelsTests.cs
Original file line number Diff line number Diff line change
@@ -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<string, string?> 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));
}
}
Loading
Loading