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
25 changes: 24 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,28 @@ Versions match the Git tags (`v1.0.0.11`) that trigger the release workflow.

## [Unreleased]

## [1.3.0.0] - 2026-09-30

### Added
- `GET /settings`: shooting mode, plus ISO, aperture, shutter speed and white balance in one call, each with its value,
the values the camera accepts now and whether it can be changed in the current shooting mode (`settable`).
- `POST /settings`: applies several settings at once (e.g. a day / night preset). Every value is checked before anything
is written; they are then written in a fixed order, never during a capture, and the settings read back from the camera
are returned. A write failure lists the settings already written (`applied`) and the one that failed (`failed`).

### Changed
- **Setters (`/iso`, `/aperture`, `/shutterspeed`, `/exposure`, `/whitebalance`) check that the setting can be changed in the
current shooting mode**: 409 with reason `not-settable-in-mode` (e.g. shutter speed in Av, anything in a scene mode,
exposure compensation in M). The camera used to be asked anyway, answering 400, 409 or 500 depending on the SDK error.
- Setting errors are machine readable: `reason` (`invalid-value`, `value-not-accepted`, `not-settable-in-mode`), `property`,
`acceptedValues`, `aeMode`, `aeModeCode` and `errors` in the problem details (the accepted values used to be only in the message).
- Setting a value waits for a capture or an autofocus in progress to finish.
- A setting for which the camera lists no settable value (movie mode, no lens...) is refused with 409 `not-settable-in-mode`,
as the Canon samples disable it; the EDSDK 13.20 API reference asks to set only values from that list.
- ISO, aperture, shutter speed and exposure compensation read `"Not valid"` instead of `"0xFFFFFFFF"` when the setting is not
valid in the current state (e.g. exposure compensation in M).
- `GET /whitebalance` returns an empty `supportedValues` instead of failing when the camera does not list the white balance values.

## [1.2.0.0] - 2026-09-30

### Changed
Expand Down Expand Up @@ -151,7 +173,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.2.0.0...HEAD
[Unreleased]: https://github.com/dansleboby/CanonWebAPI/compare/v1.3.0.0...HEAD
[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
[1.0.0.11]: https://github.com/dansleboby/CanonWebAPI/compare/v1.0.0.10...v1.0.0.11
Expand Down
4 changes: 3 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ dotnet clean CanonSDK.sln

The Canon.API project exposes these REST endpoints (OpenAPI at `/openapi/v1.json`, Swagger UI at `/swagger`, copy in `docs/openapi.json`):
- GET/POST `/iso`, `/aperture`, `/shutterspeed`, `/exposure`, `/whitebalance` - Camera settings
- GET/POST `/settings` - Mode + iso/aperture/shutterSpeed/whiteBalance in one call; POST validates everything before writing
- POST `/takepicture` - Capture image (`useAutoFocus`, `fileTypes`; JPEG only by default)
- GET `/videostream` - MJPEG live view stream (shared by all clients)
- GET `/liveview` - Single live view frame
Expand All @@ -79,7 +80,7 @@ The Canon.API project exposes these REST endpoints (OpenAPI at `/openapi/v1.json
- GET `/cameraname`, `/mode`, `/temperature`, `/status` - Camera information
- GET/POST `/flash` - "Flash firing" setting (forced to Fire before each capture by default)

Errors are problem details: 400 invalid value, 409 capture refused, 503 not connected/busy, 504 capture timeout.
Errors are problem details: 400 invalid value, 409 capture refused or setting locked by the shooting mode, 503 not connected/busy, 504 capture timeout.

## Important Notes

Expand Down Expand Up @@ -119,6 +120,7 @@ The project uses GitHub Actions for automated releases (record every change in t
- Live view is started with `Evf_OutputDevice` and stopped when no client uses it
- "Device busy" answers are retried after ~500 ms, as in the Canon samples; only the shutter press is retried, the release is always sent
- Flash (`EdsCreateFlashSettingRef`, `Flash_Target`, `Flash_Firing`): the UI must be locked while setting it; the SDK only reports values set remotely, so the setting is forced before each capture (studio flash on the shoe in the photo booth)
- Settings: `CameraSettingRules` (pure, tested) decides what is settable per AE mode; EdsGetPropertyDesc gives the accepted values (its `form`/`access` fields are reserved, always 0); an empty list means not settable now (as in the Canon samples). Set only values from that list (API reference 3.1.20)
- Memory management critical due to unmanaged EDSDK resources: release every ref (`EdsRelease`)

## Testing
Expand Down
6 changes: 3 additions & 3 deletions Canon.API/Canon.API.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,9 @@
<PlatformTarget>x64</PlatformTarget>

<!-- Assembly Version Configuration -->
<AssemblyVersion>1.2.0.0</AssemblyVersion>
<FileVersion>1.2.0.0</FileVersion>
<Version>1.2.0.0</Version>
<AssemblyVersion>1.3.0.0</AssemblyVersion>
<FileVersion>1.3.0.0</FileVersion>
<Version>1.3.0.0</Version>

<!-- Self-Contained Single-File Deployment -->
<PublishSingleFile>true</PublishSingleFile>
Expand Down
45 changes: 43 additions & 2 deletions Canon.API/Controllers/CanonController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,8 @@ namespace Canon.API.Controllers;

/// <summary>
/// Camera control endpoints. Errors are returned as problem details (see <see cref="Infrastructure.CameraExceptionHandler"/>):
/// 400 invalid value, 409 capture refused by the camera, 503 camera not connected or busy, 504 capture timeout.
/// 400 invalid value, 409 capture refused by the camera or setting locked by the shooting mode, 503 camera not connected or busy,
/// 504 capture timeout.
/// </summary>
[ApiController]
[ProducesResponseType<ProblemDetails>(StatusCodes.Status503ServiceUnavailable, "application/problem+json")]
Expand Down Expand Up @@ -98,15 +99,48 @@ private async Task<IActionResult> SetValue(CameraProperty property, string value
return Ok();
}

private const string SetterErrors =
"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.";

[HttpGet("settings")]
[EndpointSummary("Shooting mode, ISO, aperture, shutter speed 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<CameraSettings>(StatusCodes.Status200OK, "application/json")]
public async Task<IActionResult> GetSettings() => Ok(await camera.GetSettingsAsync());

[HttpPost("settings")]
[EndpointSummary("Apply several settings at once (e.g. a day / night preset)")]
[EndpointDescription(
"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.")]
[ProducesResponseType<CameraSettings>(StatusCodes.Status200OK, "application/json")]
[ProducesResponseType<ProblemDetails>(StatusCodes.Status400BadRequest, "application/problem+json")]
[ProducesResponseType<ProblemDetails>(StatusCodes.Status409Conflict, "application/problem+json")]
public async Task<IActionResult> SetSettings([Required][FromBody] CameraSettingsRequest request, CancellationToken cancellationToken)
{
var settings = request.ToDictionary();
logger.LogInformation("Applying settings {Settings}", string.Join(", ", settings.Select(s => $"{s.Key.ToSettingName()}={s.Value}")));

await camera.ApplySettingsAsync(settings, cancellationToken);
return Ok(await camera.GetSettingsAsync());
}

[HttpGet("iso")]
[EndpointSummary("Current ISO speed and supported values")]
[ProducesResponseType<PropertyValueResponse>(StatusCodes.Status200OK, "application/json")]
public Task<IActionResult> GetIso() => GetValue(CameraProperty.ISOSpeed);

[HttpPost("iso")]
[EndpointSummary("Set the ISO speed (e.g. \"Auto\", \"400\")")]
[EndpointDescription(SetterErrors)]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType<ProblemDetails>(StatusCodes.Status400BadRequest, "application/problem+json")]
[ProducesResponseType<ProblemDetails>(StatusCodes.Status409Conflict, "application/problem+json")]
public Task<IActionResult> SetIso([Required][FromBody] string value) => SetValue(CameraProperty.ISOSpeed, value);

[HttpGet("aperture")]
Expand All @@ -116,8 +150,10 @@ private async Task<IActionResult> SetValue(CameraProperty property, string value

[HttpPost("aperture")]
[EndpointSummary("Set the aperture (e.g. \"5.6\")")]
[EndpointDescription(SetterErrors)]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType<ProblemDetails>(StatusCodes.Status400BadRequest, "application/problem+json")]
[ProducesResponseType<ProblemDetails>(StatusCodes.Status409Conflict, "application/problem+json")]
public Task<IActionResult> SetAperture([Required][FromBody] string value) => SetValue(CameraProperty.Aperture, value);

[HttpGet("shutterspeed")]
Expand All @@ -127,8 +163,10 @@ private async Task<IActionResult> SetValue(CameraProperty property, string value

[HttpPost("shutterspeed")]
[EndpointSummary("Set the shutter speed (e.g. \"1/125\", \"2\\\"\")")]
[EndpointDescription(SetterErrors)]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType<ProblemDetails>(StatusCodes.Status400BadRequest, "application/problem+json")]
[ProducesResponseType<ProblemDetails>(StatusCodes.Status409Conflict, "application/problem+json")]
public Task<IActionResult> SetShutterSpeed([Required][FromBody] string value) => SetValue(CameraProperty.ShutterSpeed, value);

[HttpGet("exposure")]
Expand All @@ -138,9 +176,10 @@ private async Task<IActionResult> SetValue(CameraProperty property, string value

[HttpPost("exposure")]
[EndpointSummary("Set the exposure compensation (e.g. \"0\", \"+1/3\", \"-1 2/3\")")]
[EndpointDescription("Not available in manual exposure mode.")]
[EndpointDescription("Not available in manual exposure mode, nor in the basic zone modes. " + SetterErrors)]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType<ProblemDetails>(StatusCodes.Status400BadRequest, "application/problem+json")]
[ProducesResponseType<ProblemDetails>(StatusCodes.Status409Conflict, "application/problem+json")]
public Task<IActionResult> SetExposureCompensation([Required][FromBody] string value) => SetValue(CameraProperty.ExposureCompensation, value);

[HttpGet("whitebalance")]
Expand All @@ -150,8 +189,10 @@ private async Task<IActionResult> SetValue(CameraProperty property, string value

[HttpPost("whitebalance")]
[EndpointSummary("Set the white balance (e.g. \"Auto\", \"Daylight\")")]
[EndpointDescription(SetterErrors)]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType<ProblemDetails>(StatusCodes.Status400BadRequest, "application/problem+json")]
[ProducesResponseType<ProblemDetails>(StatusCodes.Status409Conflict, "application/problem+json")]
public Task<IActionResult> SetWhiteBalance([Required][FromBody] string value) => SetValue(CameraProperty.WhiteBalance, value);

/// <param name="useAutoFocus">Focus before shooting (default true).</param>
Expand Down
44 changes: 43 additions & 1 deletion Canon.API/Infrastructure/CameraExceptionHandler.cs
Original file line number Diff line number Diff line change
Expand Up @@ -43,9 +43,24 @@ public async ValueTask<bool> TryHandleAsync(HttpContext httpContext, Exception e
case CaptureFailedException capture:
problem.Extensions["captureErrorCode"] = capture.CaptureErrorCode;
break;
case CameraSettingsException settings:
AddSettingsExtensions(problem, settings);
break;
case CameraSettingsApplyException apply:
problem.Extensions["applied"] = apply.Applied.Select(p => p.ToSettingName()).ToArray();
problem.Extensions["failed"] = new
{
property = apply.Failed.ToSettingName(),
value = apply.FailedValue,
detail = apply.InnerException?.Message,
errorCode = apply.InnerException is EdsException inner ? $"0x{inner.ErrorCode:X8}" : null
};
if (apply.InnerException is EdsException innerEds)
problem.Extensions["errorCode"] = $"0x{innerEds.ErrorCode:X8}";
break;
}

if (exception is EdsException { IsBusy: true })
if (exception is EdsException { IsBusy: true } or CameraSettingsApplyException { InnerException: EdsException { IsBusy: true } })
httpContext.Response.Headers.RetryAfter = "1";

httpContext.Response.StatusCode = status;
Expand All @@ -58,11 +73,38 @@ public async ValueTask<bool> TryHandleAsync(HttpContext httpContext, Exception e
});
}

/// <summary>
/// Machine readable description of refused settings: the decisive error at the top level (reason, property,
/// acceptedValues), the shooting mode, and every refused setting in "errors".
/// </summary>
private static void AddSettingsExtensions(ProblemDetails problem, CameraSettingsException exception)
{
var primary = exception.PrimaryError;

problem.Extensions["reason"] = primary.Reason.ToCode();
problem.Extensions["property"] = primary.Property.ToSettingName();
problem.Extensions["acceptedValues"] = primary.AcceptedValues;
problem.Extensions["aeMode"] = exception.Mode.AEMode;
problem.Extensions["aeModeCode"] = exception.Mode.AEModeCode;
problem.Extensions["errors"] = exception.Errors.Select(e => new
{
property = e.Property.ToSettingName(),
value = e.RequestedValue,
reason = e.Reason.ToCode(),
acceptedValues = e.AcceptedValues,
detail = e.Message
}).ToArray();
}

/// <summary>
/// HTTP status code and title for an exception.
/// </summary>
public static (int Status, string Title) Map(Exception exception) => exception switch
{
CameraSettingsException { PrimaryError.Reason: SettingErrorReason.NotSettableInMode } => (StatusCodes.Status409Conflict, "Setting cannot be changed in the current shooting mode"),
CameraSettingsException { PrimaryError.Reason: SettingErrorReason.ValueNotAccepted } => (StatusCodes.Status400BadRequest, "Value not accepted by the camera"),
CameraSettingsException => (StatusCodes.Status400BadRequest, "Invalid value"),
CameraSettingsApplyException { InnerException: { } inner } => (Map(inner).Status, "Settings not fully applied"),
ArgumentException => (StatusCodes.Status400BadRequest, "Invalid value"),
CaptureFailedException => (StatusCodes.Status409Conflict, "Capture failed"),
TimeoutException => (StatusCodes.Status504GatewayTimeout, "Camera did not respond in time"),
Expand Down
2 changes: 1 addition & 1 deletion Canon.API/Infrastructure/ServiceCollectionExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ public static IServiceCollection AddCanonApi(this IServiceCollection services, I
document.Info.Title = "Canon Web API";
document.Info.Version = version?.ToString(3) ?? "1.0.0";
document.Info.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, " +
"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.";
return Task.CompletedTask;
});
Expand Down
20 changes: 20 additions & 0 deletions Canon.API/Models/ApiModels.cs
Original file line number Diff line number Diff line change
Expand Up @@ -27,3 +27,23 @@ public sealed record CameraStatusResponse(
TemperatureStatus? Temperature,
FlashStatus? Flash,
string? Error);

/// <summary>
/// Settings to apply with POST /settings. Omitted (or null) settings are left unchanged.
/// </summary>
/// <param name="Iso">ISO speed (e.g. "Auto", "400").</param>
/// <param name="Aperture">Aperture (e.g. "5.6").</param>
/// <param name="ShutterSpeed">Shutter speed (e.g. "1/125").</param>
/// <param name="WhiteBalance">White balance (e.g. "Auto", "Daylight").</param>
public sealed record CameraSettingsRequest(string? Iso = null, string? Aperture = null, string? ShutterSpeed = null, string? WhiteBalance = null)
{
public Dictionary<CameraProperty, string> ToDictionary()
{
var settings = new Dictionary<CameraProperty, string>();
if (Iso != null) settings[CameraProperty.ISOSpeed] = Iso;
if (Aperture != null) settings[CameraProperty.Aperture] = Aperture;
if (ShutterSpeed != null) settings[CameraProperty.ShutterSpeed] = ShutterSpeed;
if (WhiteBalance != null) settings[CameraProperty.WhiteBalance] = WhiteBalance;
return settings;
}
}
Loading
Loading