diff --git a/CHANGELOG.md b/CHANGELOG.md
index 7d2c86f..1121866 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -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
@@ -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
diff --git a/CLAUDE.md b/CLAUDE.md
index 30e2018..f186b28 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -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
@@ -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
@@ -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
diff --git a/Canon.API/Canon.API.csproj b/Canon.API/Canon.API.csproj
index 88b7dfc..b2dbbce 100644
--- a/Canon.API/Canon.API.csproj
+++ b/Canon.API/Canon.API.csproj
@@ -7,9 +7,9 @@
x64
- 1.2.0.0
- 1.2.0.0
- 1.2.0.0
+ 1.3.0.0
+ 1.3.0.0
+ 1.3.0.0
true
diff --git a/Canon.API/Controllers/CanonController.cs b/Canon.API/Controllers/CanonController.cs
index cbdadc6..7c70086 100644
--- a/Canon.API/Controllers/CanonController.cs
+++ b/Canon.API/Controllers/CanonController.cs
@@ -10,7 +10,8 @@ namespace Canon.API.Controllers;
///
/// Camera control endpoints. Errors are returned as problem details (see ):
-/// 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.
///
[ApiController]
[ProducesResponseType(StatusCodes.Status503ServiceUnavailable, "application/problem+json")]
@@ -98,6 +99,37 @@ private async Task 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(StatusCodes.Status200OK, "application/json")]
+ public async Task 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(StatusCodes.Status200OK, "application/json")]
+ [ProducesResponseType(StatusCodes.Status400BadRequest, "application/problem+json")]
+ [ProducesResponseType(StatusCodes.Status409Conflict, "application/problem+json")]
+ public async Task 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(StatusCodes.Status200OK, "application/json")]
@@ -105,8 +137,10 @@ private async Task SetValue(CameraProperty property, string value
[HttpPost("iso")]
[EndpointSummary("Set the ISO speed (e.g. \"Auto\", \"400\")")]
+ [EndpointDescription(SetterErrors)]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest, "application/problem+json")]
+ [ProducesResponseType(StatusCodes.Status409Conflict, "application/problem+json")]
public Task SetIso([Required][FromBody] string value) => SetValue(CameraProperty.ISOSpeed, value);
[HttpGet("aperture")]
@@ -116,8 +150,10 @@ private async Task SetValue(CameraProperty property, string value
[HttpPost("aperture")]
[EndpointSummary("Set the aperture (e.g. \"5.6\")")]
+ [EndpointDescription(SetterErrors)]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest, "application/problem+json")]
+ [ProducesResponseType(StatusCodes.Status409Conflict, "application/problem+json")]
public Task SetAperture([Required][FromBody] string value) => SetValue(CameraProperty.Aperture, value);
[HttpGet("shutterspeed")]
@@ -127,8 +163,10 @@ private async Task SetValue(CameraProperty property, string value
[HttpPost("shutterspeed")]
[EndpointSummary("Set the shutter speed (e.g. \"1/125\", \"2\\\"\")")]
+ [EndpointDescription(SetterErrors)]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest, "application/problem+json")]
+ [ProducesResponseType(StatusCodes.Status409Conflict, "application/problem+json")]
public Task SetShutterSpeed([Required][FromBody] string value) => SetValue(CameraProperty.ShutterSpeed, value);
[HttpGet("exposure")]
@@ -138,9 +176,10 @@ private async Task 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(StatusCodes.Status400BadRequest, "application/problem+json")]
+ [ProducesResponseType(StatusCodes.Status409Conflict, "application/problem+json")]
public Task SetExposureCompensation([Required][FromBody] string value) => SetValue(CameraProperty.ExposureCompensation, value);
[HttpGet("whitebalance")]
@@ -150,8 +189,10 @@ private async Task SetValue(CameraProperty property, string value
[HttpPost("whitebalance")]
[EndpointSummary("Set the white balance (e.g. \"Auto\", \"Daylight\")")]
+ [EndpointDescription(SetterErrors)]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest, "application/problem+json")]
+ [ProducesResponseType(StatusCodes.Status409Conflict, "application/problem+json")]
public Task SetWhiteBalance([Required][FromBody] string value) => SetValue(CameraProperty.WhiteBalance, value);
/// Focus before shooting (default true).
diff --git a/Canon.API/Infrastructure/CameraExceptionHandler.cs b/Canon.API/Infrastructure/CameraExceptionHandler.cs
index 6aff0e9..50cb1b1 100644
--- a/Canon.API/Infrastructure/CameraExceptionHandler.cs
+++ b/Canon.API/Infrastructure/CameraExceptionHandler.cs
@@ -43,9 +43,24 @@ public async ValueTask 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;
@@ -58,11 +73,38 @@ public async ValueTask TryHandleAsync(HttpContext httpContext, Exception e
});
}
+ ///
+ /// 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".
+ ///
+ 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();
+ }
+
///
/// HTTP status code and title for an exception.
///
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"),
diff --git a/Canon.API/Infrastructure/ServiceCollectionExtensions.cs b/Canon.API/Infrastructure/ServiceCollectionExtensions.cs
index 23bdbfc..9b9903b 100644
--- a/Canon.API/Infrastructure/ServiceCollectionExtensions.cs
+++ b/Canon.API/Infrastructure/ServiceCollectionExtensions.cs
@@ -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;
});
diff --git a/Canon.API/Models/ApiModels.cs b/Canon.API/Models/ApiModels.cs
index 04543b2..c06dfdf 100644
--- a/Canon.API/Models/ApiModels.cs
+++ b/Canon.API/Models/ApiModels.cs
@@ -27,3 +27,23 @@ public sealed record CameraStatusResponse(
TemperatureStatus? Temperature,
FlashStatus? Flash,
string? Error);
+
+///
+/// Settings to apply with POST /settings. Omitted (or null) settings are left unchanged.
+///
+/// ISO speed (e.g. "Auto", "400").
+/// Aperture (e.g. "5.6").
+/// Shutter speed (e.g. "1/125").
+/// White balance (e.g. "Auto", "Daylight").
+public sealed record CameraSettingsRequest(string? Iso = null, string? Aperture = null, string? ShutterSpeed = null, string? WhiteBalance = null)
+{
+ public Dictionary ToDictionary()
+ {
+ var settings = new Dictionary();
+ 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;
+ }
+}
diff --git a/Canon.Core.Tests/CameraSettingRulesTests.cs b/Canon.Core.Tests/CameraSettingRulesTests.cs
new file mode 100644
index 0000000..ac6fed6
--- /dev/null
+++ b/Canon.Core.Tests/CameraSettingRulesTests.cs
@@ -0,0 +1,180 @@
+using Canon.Core;
+
+namespace Canon.Core.Tests;
+
+public class CameraSettingRulesTests
+{
+ private const uint ProgramAE = 0x00;
+ private const uint Tv = 0x01;
+ private const uint Av = 0x02;
+ private const uint Manual = 0x03;
+ private const uint SceneIntelligentAuto = 0x16;
+ private const uint Portrait = 0x0C;
+
+ // Values an EOS camera could list with EdsGetPropertyDesc.
+ private static readonly uint[] ShutterSpeeds = [0x60, 0x68, 0x70, 0x78]; // 1/30, 1/60, 1/125, 1/250
+ private static readonly uint[] Apertures = [0x28, 0x30, 0x38]; // 4, 5.6, 8
+
+ private static SettingError? Validate(CameraProperty property, string? requested, uint aeMode, IReadOnlyCollection? settable = null) =>
+ CameraSettingRules.Validate(property, requested, aeMode, settable, out _);
+
+ [Theory]
+ [InlineData(CameraProperty.ShutterSpeed, "1/7")]
+ [InlineData(CameraProperty.ShutterSpeed, "")]
+ [InlineData(CameraProperty.ShutterSpeed, null)]
+ [InlineData(CameraProperty.Aperture, "f/5.6")]
+ [InlineData(CameraProperty.ISOSpeed, "0xZZ")]
+ [InlineData(CameraProperty.WhiteBalance, "Sunny")]
+ public void Unreadable_label_is_an_invalid_value(CameraProperty property, string? requested)
+ {
+ var error = Validate(property, requested, Manual, ShutterSpeeds);
+
+ Assert.NotNull(error);
+ Assert.Equal(SettingErrorReason.InvalidValue, error.Reason);
+ Assert.Equal("invalid-value", error.Reason.ToCode());
+ Assert.Equal(property, error.Property);
+ }
+
+ [Fact]
+ public void Invalid_label_is_reported_even_when_the_mode_locks_the_setting()
+ {
+ var error = Validate(CameraProperty.ShutterSpeed, "fast", Av);
+
+ Assert.Equal(SettingErrorReason.InvalidValue, error?.Reason);
+ }
+
+ [Fact]
+ public void Value_not_in_the_settable_list_is_not_accepted_and_lists_the_accepted_values()
+ {
+ var error = Validate(CameraProperty.ShutterSpeed, "1/4000", Manual, ShutterSpeeds);
+
+ Assert.NotNull(error);
+ Assert.Equal(SettingErrorReason.ValueNotAccepted, error.Reason);
+ Assert.Equal("value-not-accepted", error.Reason.ToCode());
+ Assert.Equal(["1/30", "1/60", "1/125", "1/250"], error.AcceptedValues);
+ }
+
+ [Fact]
+ public void Value_in_the_settable_list_is_accepted()
+ {
+ var error = CameraSettingRules.Validate(CameraProperty.ShutterSpeed, "1/125", Manual, ShutterSpeeds, out var value);
+
+ Assert.Null(error);
+ Assert.Equal(0x70u, value);
+ }
+
+ [Fact]
+ public void Raw_value_is_checked_like_a_label()
+ {
+ Assert.Null(Validate(CameraProperty.Aperture, "0x30", Manual, Apertures));
+ Assert.Equal(SettingErrorReason.ValueNotAccepted, Validate(CameraProperty.Aperture, "0x48", Manual, Apertures)?.Reason);
+ }
+
+ [Fact]
+ public void Without_a_settable_list_only_the_label_and_the_mode_are_checked()
+ {
+ Assert.Null(Validate(CameraProperty.WhiteBalance, "Daylight", Manual, null));
+ Assert.True(CameraSettingRules.IsSettable(CameraProperty.WhiteBalance, Manual, null));
+ }
+
+ [Fact]
+ public void Empty_settable_list_means_the_setting_cannot_be_changed_now()
+ {
+ // The Canon samples disable a setting whose list is empty (e.g. aperture without a lens, movie mode).
+ var error = Validate(CameraProperty.Aperture, "5.6", Manual, []);
+
+ Assert.Equal(SettingErrorReason.NotSettableInMode, error?.Reason);
+ Assert.Empty(error!.AcceptedValues);
+ Assert.False(CameraSettingRules.IsSettable(CameraProperty.Aperture, Manual, []));
+ Assert.True(CameraSettingRules.IsSettable(CameraProperty.Aperture, Manual, Apertures));
+ Assert.False(CameraSettingRules.IsSettable(CameraProperty.Aperture, Tv, Apertures));
+ }
+
+ [Fact]
+ public void Not_valid_value_has_a_label_but_cannot_be_sent_back()
+ {
+ // Exposure compensation read in manual exposure mode (EDSDK API reference 5.2.28).
+ var propId = (uint)CameraProperty.ExposureCompensation;
+
+ Assert.Equal("Not valid", propId.DescribeValue(0xFFFFFFFF));
+ Assert.Equal("0xFFFFFFFF", ((uint)CameraProperty.WhiteBalance).DescribeValue(0xFFFFFFFF));
+ Assert.Equal(SettingErrorReason.InvalidValue, Validate(CameraProperty.ExposureCompensation, "Not valid", Av)?.Reason);
+ }
+
+ [Fact]
+ public void Bulb_shutter_speed_is_never_accepted()
+ {
+ Assert.Equal(SettingErrorReason.ValueNotAccepted, Validate(CameraProperty.ShutterSpeed, "BULB", Manual)?.Reason);
+ }
+
+ [Fact]
+ public void Shutter_speed_cannot_be_changed_in_Av()
+ {
+ // Even when the camera still lists values: the mode decides.
+ var error = Validate(CameraProperty.ShutterSpeed, "1/125", Av, ShutterSpeeds);
+
+ Assert.NotNull(error);
+ Assert.Equal(SettingErrorReason.NotSettableInMode, error.Reason);
+ Assert.Equal("not-settable-in-mode", error.Reason.ToCode());
+ Assert.Empty(error.AcceptedValues);
+ Assert.Contains("Aperture Priority AE", error.Message);
+ }
+
+ [Fact]
+ public void Aperture_cannot_be_changed_in_Tv()
+ {
+ Assert.Equal(SettingErrorReason.NotSettableInMode, Validate(CameraProperty.Aperture, "5.6", Tv, Apertures)?.Reason);
+ }
+
+ [Theory]
+ [InlineData(SceneIntelligentAuto)]
+ [InlineData(Portrait)]
+ public void Nothing_can_be_changed_in_a_scene_mode(uint aeMode)
+ {
+ Assert.Equal(SettingErrorReason.NotSettableInMode, Validate(CameraProperty.ISOSpeed, "400", aeMode)?.Reason);
+ Assert.Equal(SettingErrorReason.NotSettableInMode, Validate(CameraProperty.Aperture, "5.6", aeMode)?.Reason);
+ Assert.Equal(SettingErrorReason.NotSettableInMode, Validate(CameraProperty.ShutterSpeed, "1/125", aeMode)?.Reason);
+ Assert.Equal(SettingErrorReason.NotSettableInMode, Validate(CameraProperty.WhiteBalance, "Auto", aeMode)?.Reason);
+ Assert.Equal(SettingErrorReason.NotSettableInMode, Validate(CameraProperty.ExposureCompensation, "0", aeMode)?.Reason);
+ }
+
+ [Theory]
+ [InlineData(CameraProperty.ISOSpeed, "400")]
+ [InlineData(CameraProperty.Aperture, "5.6")]
+ [InlineData(CameraProperty.ShutterSpeed, "1/125")]
+ [InlineData(CameraProperty.WhiteBalance, "Daylight")]
+ public void Everything_but_exposure_compensation_can_be_changed_in_M(CameraProperty property, string requested)
+ {
+ Assert.True(CameraSettingRules.IsSettableInMode(property, Manual));
+ Assert.Null(Validate(property, requested, Manual));
+ }
+
+ [Fact]
+ public void Exposure_compensation_cannot_be_changed_in_M()
+ {
+ Assert.Equal(SettingErrorReason.NotSettableInMode, Validate(CameraProperty.ExposureCompensation, "+1/3", Manual)?.Reason);
+ Assert.Null(Validate(CameraProperty.ExposureCompensation, "+1/3", Av));
+ Assert.Null(Validate(CameraProperty.ExposureCompensation, "+1/3", ProgramAE));
+ }
+
+ [Fact]
+ public void Primary_error_is_the_mode_error()
+ {
+ var mode = new CameraMode(Av, "Aperture Priority AE", true, false);
+ var invalid = Validate(CameraProperty.ISOSpeed, "abc", Av)!;
+ var locked = Validate(CameraProperty.ShutterSpeed, "1/125", Av)!;
+
+ var exception = new CameraSettingsException(mode, [invalid, locked]);
+
+ Assert.Same(locked, exception.PrimaryError);
+ Assert.Same(invalid, new CameraSettingsException(mode, [invalid]).PrimaryError);
+ }
+
+ [Fact]
+ public void Write_order_is_fixed()
+ {
+ Assert.Equal(
+ [CameraProperty.ISOSpeed, CameraProperty.Aperture, CameraProperty.ShutterSpeed, CameraProperty.ExposureCompensation, CameraProperty.WhiteBalance],
+ CameraSettingRules.WriteOrder);
+ }
+}
diff --git a/Canon.Core/CameraSettings.cs b/Canon.Core/CameraSettings.cs
new file mode 100644
index 0000000..c666bde
--- /dev/null
+++ b/Canon.Core/CameraSettings.cs
@@ -0,0 +1,225 @@
+namespace Canon.Core;
+
+///
+/// Why a requested setting value is refused.
+///
+public enum SettingErrorReason
+{
+ /// The label cannot be read (unknown label or malformed raw value).
+ InvalidValue,
+
+ /// The value is not one of the values the camera accepts now.
+ ValueNotAccepted,
+
+ /// The setting cannot be changed in the current shooting mode (e.g. shutter speed in Av).
+ NotSettableInMode
+}
+
+///
+/// A requested setting value that was refused before anything was written to the camera.
+///
+/// The setting.
+/// The value as requested by the client.
+/// Why it is refused.
+/// Labels of the values the camera accepts now (empty when unknown or not settable).
+/// Human readable explanation.
+public sealed record SettingError(
+ CameraProperty Property,
+ string? RequestedValue,
+ SettingErrorReason Reason,
+ IReadOnlyList AcceptedValues,
+ string Message);
+
+///
+/// Current value of a camera setting, the values the camera accepts now and whether it can be changed in the current mode.
+///
+/// Current value label (e.g. "1/125"), or a raw value ("0x93") when unknown.
+/// Labels of the values the camera accepts now (empty when the camera does not list them).
+/// True when the setting can be changed in the current shooting mode.
+public sealed record CameraSettingState(string Value, IReadOnlyList SupportedValues, bool Settable);
+
+///
+/// The shooting mode and the exposure settings of the camera, read in one call.
+///
+public sealed record CameraSettings(
+ CameraMode Mode,
+ CameraSettingState Iso,
+ CameraSettingState Aperture,
+ CameraSettingState ShutterSpeed,
+ CameraSettingState WhiteBalance);
+
+///
+/// Validation rules of the camera settings. Pure functions: no camera needed.
+///
+public static class CameraSettingRules
+{
+ ///
+ /// Order in which several settings are written.
+ ///
+ public static IReadOnlyList WriteOrder { get; } =
+ [
+ CameraProperty.ISOSpeed,
+ CameraProperty.Aperture,
+ CameraProperty.ShutterSpeed,
+ CameraProperty.ExposureCompensation,
+ CameraProperty.WhiteBalance
+ ];
+
+ private const uint ProgramAE = 0x00;
+ private const uint ShutterPriority = 0x01;
+ private const uint AperturePriority = 0x02;
+ private const uint Manual = 0x03;
+ private const uint Bulb = 0x04;
+ private const uint AutoDepthOfField = 0x05;
+ private const uint DepthOfField = 0x06;
+ private const uint FlexiblePriority = 0x37;
+
+ // kEdsPropID_Tv value of Bulb: it can never be set from a computer (EDSDK API reference, kEdsPropID_Tv).
+ private const uint TvBulb = 0x0C;
+
+ ///
+ /// 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).
+ ///
+ private static readonly Dictionary SettableByMode = new()
+ {
+ { ProgramAE, [CameraProperty.ISOSpeed, CameraProperty.ExposureCompensation, CameraProperty.WhiteBalance] },
+ { ShutterPriority, [CameraProperty.ISOSpeed, CameraProperty.ShutterSpeed, CameraProperty.ExposureCompensation, CameraProperty.WhiteBalance] },
+ { AperturePriority, [CameraProperty.ISOSpeed, CameraProperty.Aperture, CameraProperty.ExposureCompensation, CameraProperty.WhiteBalance] },
+ { Manual, [CameraProperty.ISOSpeed, CameraProperty.Aperture, CameraProperty.ShutterSpeed, CameraProperty.WhiteBalance] },
+ { Bulb, [CameraProperty.ISOSpeed, CameraProperty.Aperture, CameraProperty.WhiteBalance] },
+ { AutoDepthOfField, [CameraProperty.ISOSpeed, CameraProperty.ExposureCompensation, CameraProperty.WhiteBalance] },
+ { DepthOfField, [CameraProperty.ISOSpeed, CameraProperty.ExposureCompensation, CameraProperty.WhiteBalance] },
+ { FlexiblePriority, [CameraProperty.ISOSpeed, CameraProperty.Aperture, CameraProperty.ShutterSpeed, CameraProperty.ExposureCompensation, CameraProperty.WhiteBalance] },
+ };
+
+ ///
+ /// 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);
+
+ ///
+ /// True when can be changed now: allowed by the AE mode, and the camera lists at least
+ /// one settable value. An empty list (EdsGetPropertyDesc succeeded with no element) means the setting cannot be
+ /// 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 };
+
+ ///
+ /// Checks a requested value before it is written to the camera.
+ ///
+ /// The setting.
+ /// The label (e.g. "1/125", "5.6", "Auto") or raw value ("0x93").
+ /// Current kEdsPropID_AEMode value.
+ ///
+ /// Values the camera accepts now (EdsGetPropertyDesc). Null when the camera does not list them: only the label and
+ /// the mode are checked. Empty: the setting cannot be changed now (see ).
+ ///
+ /// The raw value to write, when valid.
+ /// 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)
+ {
+ var propId = (uint)property;
+ var accepted = (IReadOnlyList)(settableValues ?? []).Select(v => propId.DescribeValue(v)).ToList();
+
+ if (string.IsNullOrWhiteSpace(requested) || !propId.TryParseValue(requested, out value))
+ {
+ value = 0;
+ return new SettingError(property, requested, SettingErrorReason.InvalidValue, accepted,
+ $"Invalid value '{requested}' for {property.ToSettingName()}.");
+ }
+
+ if (!IsSettableInMode(property, aeMode))
+ {
+ return new SettingError(property, requested, SettingErrorReason.NotSettableInMode, [],
+ $"{property.ToSettingName()} cannot be changed in the current shooting mode ({DescribeAEMode(aeMode)}).");
+ }
+
+ if (settableValues is { Count: 0 })
+ {
+ return new SettingError(property, requested, SettingErrorReason.NotSettableInMode, [],
+ $"{property.ToSettingName()} cannot be changed now: the camera lists no settable value in its current state " +
+ $"({DescribeAEMode(aeMode)}; movie mode, lens...).");
+ }
+
+ if ((settableValues != null && !settableValues.Contains(value)) || (property == CameraProperty.ShutterSpeed && value == TvBulb))
+ {
+ return new SettingError(property, requested, SettingErrorReason.ValueNotAccepted, accepted,
+ $"The camera does not accept {property.ToSettingName()} '{propId.DescribeValue(value)}' now."
+ + (accepted.Count > 0 ? $" Accepted values: {string.Join(", ", accepted)}" : string.Empty));
+ }
+
+ return null;
+ }
+
+ ///
+ /// Label of an AE mode (e.g. "Manual Exposure"), or its raw value when unknown.
+ ///
+ public static string DescribeAEMode(uint aeMode) =>
+ EdsdkHelper.AEModeValues.TryGetValue(aeMode, out var label) ? label : EdsdkHelper.FormatRawValue(aeMode);
+
+ ///
+ /// Name of the setting in the API (e.g. "shutterSpeed").
+ ///
+ public static string ToSettingName(this CameraProperty property) => property switch
+ {
+ CameraProperty.ISOSpeed => "iso",
+ CameraProperty.Aperture => "aperture",
+ CameraProperty.ShutterSpeed => "shutterSpeed",
+ CameraProperty.ExposureCompensation => "exposureCompensation",
+ CameraProperty.WhiteBalance => "whiteBalance",
+ _ => property.ToString()
+ };
+
+ ///
+ /// Machine readable code of a reason (e.g. "not-settable-in-mode").
+ ///
+ public static string ToCode(this SettingErrorReason reason) => reason switch
+ {
+ SettingErrorReason.InvalidValue => "invalid-value",
+ SettingErrorReason.ValueNotAccepted => "value-not-accepted",
+ SettingErrorReason.NotSettableInMode => "not-settable-in-mode",
+ _ => reason.ToString()
+ };
+}
+
+///
+/// One or more requested settings were refused. Nothing was written to the camera.
+///
+public class CameraSettingsException(CameraMode mode, IReadOnlyList errors)
+ : Exception(string.Join(" ", errors.Select(e => e.Message)))
+{
+ /// The shooting mode when the settings were checked.
+ public CameraMode Mode { get; } = mode;
+
+ /// Every refused setting, in write order.
+ public IReadOnlyList Errors { get; } = errors;
+
+ ///
+ /// The error that decides the response: a setting locked by the shooting mode comes first, since the mode must be
+ /// changed on the camera before the values can be checked meaningfully.
+ ///
+ public SettingError PrimaryError => Errors.FirstOrDefault(e => e.Reason == SettingErrorReason.NotSettableInMode) ?? Errors[0];
+}
+
+///
+/// A setting could not be written after the validation passed. The settings in were written;
+/// and the ones after it were not.
+///
+public class CameraSettingsApplyException(IReadOnlyList applied, CameraProperty failed, string failedValue, Exception innerException)
+ : Exception(
+ $"Could not set {failed.ToSettingName()} to '{failedValue}'"
+ + (applied.Count > 0 ? $" (already set: {string.Join(", ", applied.Select(p => p.ToSettingName()))})" : " (nothing was set)")
+ + $": {innerException.Message}",
+ innerException)
+{
+ public IReadOnlyList Applied { get; } = applied;
+ public CameraProperty Failed { get; } = failed;
+ public string FailedValue { get; } = failedValue;
+}
diff --git a/Canon.Core/CanonCamera.cs b/Canon.Core/CanonCamera.cs
index d527d70..7ca9a78 100644
--- a/Canon.Core/CanonCamera.cs
+++ b/Canon.Core/CanonCamera.cs
@@ -567,58 +567,145 @@ public Task GetValue(CameraProperty property) => RunAsync(camera =>
});
///
- /// Gets the values that can currently be set for a specific camera property.
+ /// Gets the values that can currently be set for a specific camera property (empty when the camera does not list them).
///
public Task> GetSupportedValues(CameraProperty property) => RunAsync(camera =>
- GetSettableValues(camera, property)
+ (GetSettableValues(camera, property) ?? [])
.Select(value => ((uint)property).DescribeValue(value))
.ToList());
- private static uint[] GetSettableValues(nint camera, CameraProperty property)
+ ///
+ /// Values that can currently be set (EdsGetPropertyDesc), or null when the camera does not list them for this property.
+ ///
+ private static uint[]? GetSettableValues(nint camera, CameraProperty property)
{
- EDSDK.EdsGetPropertyDesc(camera, (uint)property, out var desc).ThrowIfEdSdkError($"Could not get the supported values of {property}");
+ var err = EDSDK.EdsGetPropertyDesc(camera, (uint)property, out var desc);
+
+ // EdsGetPropertyDesc is only documented for some properties (EDSDK API reference 3.1.20).
+ if (err is EDSDK.EDS_ERR_INVALID_PARAMETER or EDSDK.EDS_ERR_NOT_SUPPORTED or EDSDK.EDS_ERR_PROPERTIES_UNAVAILABLE or EDSDK.EDS_ERR_DEVICEPROP_NOT_SUPPORTED)
+ return null;
- return desc.PropDesc
- .Take(Math.Clamp(desc.NumElements, 0, desc.PropDesc.Length))
+ err.ThrowIfEdSdkError($"Could not get the supported values of {property}");
+
+ return (desc.PropDesc ?? [])
+ .Take(Math.Clamp(desc.NumElements, 0, desc.PropDesc?.Length ?? 0))
.Select(value => (uint)value)
.ToArray();
}
///
- /// Sets the raw value of a specific camera property.
- /// The value is checked against the values the camera currently accepts, as the SDK documentation requires.
+ /// Sets the raw value of a specific camera property, with the same checks as .
+ ///
+ public Task SetValue(CameraProperty property, uint value) => SetValue(property, EdsdkHelper.FormatRawValue(value));
+
+ ///
+ /// Sets a camera property from its label (e.g. "1/125", "5.6", "Auto") or raw value ("0x93").
+ /// The value is checked first: readable label, setting changeable in the current mode, value accepted by the camera now.
+ ///
+ /// The value is refused; nothing was written.
+ /// The camera refused the value when it was written.
+ public async Task SetValue(CameraProperty property, string description)
+ {
+ try
+ {
+ await ApplySettingsAsync(new Dictionary { [property] = description });
+ }
+ catch (CameraSettingsApplyException e) when (e.InnerException != null)
+ {
+ // A single setting: report the camera error itself, as before.
+ System.Runtime.ExceptionServices.ExceptionDispatchInfo.Capture(e.InnerException).Throw();
+ }
+ }
+
+ ///
+ /// Reads the shooting mode and the ISO, aperture, shutter speed and white balance settings in one call.
///
- /// The camera does not accept this value now.
- public Task SetValue(CameraProperty property, uint value) => RunWithBusyRetryAsync(camera =>
+ public Task GetSettingsAsync() => RunWithBusyRetryAsync(camera =>
{
- var settable = GetSettableValues(camera, property);
- if (settable.Length > 0 && !settable.Contains(value))
+ var mode = ReadMode(camera);
+
+ CameraSettingState Read(CameraProperty property)
{
- var allowed = string.Join(", ", settable.Select(v => ((uint)property).DescribeValue(v)));
- throw new ArgumentOutOfRangeException(nameof(value), ((uint)property).DescribeValue(value),
- $"The camera does not accept this {property} value now. Allowed values: {allowed}");
+ 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));
}
- EDSDK.EdsSetPropertyData(camera, (uint)property, 0, sizeof(uint), value)
- .ThrowIfEdSdkError($"Could not set {property} to {((uint)property).DescribeValue(value)}");
+ return new CameraSettings(mode, Read(CameraProperty.ISOSpeed), Read(CameraProperty.Aperture), Read(CameraProperty.ShutterSpeed), Read(CameraProperty.WhiteBalance));
});
///
- /// Sets a camera property from its label (e.g. "1/125", "5.6", "Auto") or raw value ("0x93").
+ /// Sets several camera properties. Every value is checked before anything is written (readable label, setting
+ /// changeable in the current mode, value accepted by the camera now); the values are then written in
+ /// , never during a capture or an autofocus.
///
- /// The value is unknown or not accepted by the camera now.
- public Task SetValue(CameraProperty property, string description)
+ /// At least one value is refused; nothing was written.
+ /// A value could not be written; the previous ones were.
+ public async Task ApplySettingsAsync(IReadOnlyDictionary settings, CancellationToken cancellationToken = default)
{
- if (!((uint)property).TryParseValue(description, out var value))
- throw new ArgumentOutOfRangeException(nameof(description), description, $"Invalid value '{description}' for property {property}");
+ if (settings.Count == 0)
+ return;
+
+ var ordered = CameraSettingRules.WriteOrder.Where(settings.ContainsKey)
+ .Concat(settings.Keys.Where(p => !CameraSettingRules.WriteOrder.Contains(p)))
+ .ToList();
+
+ await _shutterLock.WaitAsync(cancellationToken);
+
+ try
+ {
+ var values = await RunWithBusyRetryAsync(camera =>
+ {
+ var mode = ReadMode(camera);
+ 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);
+ if (error != null)
+ errors.Add(error);
+ else
+ valid.Add((property, value));
+ }
+
+ if (errors.Count > 0)
+ throw new CameraSettingsException(mode, errors);
+
+ return valid;
+ }, cancellationToken);
+
+ var applied = new List();
+
+ foreach (var (property, value) in values)
+ {
+ try
+ {
+ await RunWithBusyRetryAsync(camera =>
+ EDSDK.EdsSetPropertyData(camera, (uint)property, 0, sizeof(uint), value)
+ .ThrowIfEdSdkError($"Could not set {property} to {((uint)property).DescribeValue(value)}"), cancellationToken);
+ }
+ catch (Exception e) when (e is not OperationCanceledException)
+ {
+ throw new CameraSettingsApplyException(applied, property, settings[property], e);
+ }
- return SetValue(property, value);
+ applied.Add(property);
+ }
+ }
+ finally
+ {
+ _shutterLock.Release();
+ }
}
///
/// Gets the shooting mode of the camera (mode dial position and still/movie mode).
///
- public Task GetMode() => RunAsync(camera =>
+ public Task GetMode() => RunAsync(ReadMode);
+
+ private static CameraMode ReadMode(nint camera)
{
EDSDK.EdsGetPropertyData(camera, EDSDK.PropID_AEMode, 0, out uint aeMode).ThrowIfEdSdkError("Could not get the AE mode");
@@ -626,9 +713,8 @@ public Task GetMode() => RunAsync(camera =>
? fixedMovie != 0
: null;
- var label = EdsdkHelper.AEModeValues.TryGetValue(aeMode, out var description) ? description : EdsdkHelper.FormatRawValue(aeMode);
- return new CameraMode(aeMode, label, EdsdkHelper.CreativeZoneAEModes.Contains(aeMode), isMovieMode);
- });
+ return new CameraMode(aeMode, CameraSettingRules.DescribeAEMode(aeMode), EdsdkHelper.CreativeZoneAEModes.Contains(aeMode), isMovieMode);
+ }
///
/// Gets the restrictions applied by the camera because of its internal temperature.
diff --git a/Canon.Core/EdsdkHelper.cs b/Canon.Core/EdsdkHelper.cs
index a4bd5c6..168cc57 100644
--- a/Canon.Core/EdsdkHelper.cs
+++ b/Canon.Core/EdsdkHelper.cs
@@ -542,11 +542,27 @@ public static Dictionary GetPropertyDescriptions(this uint propId)
? descriptions
: throw new ArgumentOutOfRangeException(nameof(propId), $"Unsupported property 0x{propId:X}");
+ ///
+ /// Value returned for ISO, Av, Tv and exposure compensation when the setting is not valid in the current state
+ /// (e.g. exposure compensation in manual exposure mode): "Not valid/no settings changes" (EDSDK API reference 5.2.22 to 5.2.28).
+ /// It is only a label for reading: it cannot be sent back.
+ ///
+ public const uint NotValidValue = 0xFFFFFFFF;
+
+ public const string NotValidLabel = "Not valid";
+
///
/// Returns the human readable label of a property value, or the raw value (e.g. "0x93") when the value is unknown.
///
- public static string DescribeValue(this uint propId, uint value) =>
- propId.GetPropertyValues().TryGetValue(value, out var description) ? description : FormatRawValue(value);
+ public static string DescribeValue(this uint propId, uint value)
+ {
+ if (propId.GetPropertyValues().TryGetValue(value, out var description))
+ return description;
+
+ return value == NotValidValue && propId is EDSDK.PropID_ISOSpeed or EDSDK.PropID_Av or EDSDK.PropID_Tv or EDSDK.PropID_ExposureCompensation
+ ? NotValidLabel
+ : FormatRawValue(value);
+ }
public static string FormatRawValue(uint value) => $"0x{value:X}";
diff --git a/README.md b/README.md
index 81817ea..0f5e05e 100644
--- a/README.md
+++ b/README.md
@@ -4,10 +4,12 @@
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.2.0.0** (.NET 10, Canon EDSDK 13.20.21). See the [changelog](CHANGELOG.md) for the changes of each version.
+**Current version: 1.3.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).
+
## Features
* Get camera information (e.g., camera name, shooting mode, temperature restrictions).
@@ -68,6 +70,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 | `/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 |
@@ -75,7 +79,7 @@ Setting values are sent as a JSON string, e.g. `"100"` with `Content-Type: appli
| GET | `/shutterspeed` | Gets the current shutter speed and a list of supported values. | N/A |
| POST | `/shutterspeed` | Sets the shutter speed. | `"1/125"` |
| GET | `/exposure` | Gets the current exposure compensation and supported values. | N/A |
-| POST | `/exposure` | Sets the exposure compensation (not available in manual mode). | `"+1/3"` |
+| POST | `/exposure` | Sets the exposure compensation (not available in M, nor in the basic zone modes). | `"+1/3"` |
| GET | `/whitebalance` | Gets the current white balance and supported values. | N/A |
| POST | `/whitebalance` | Sets the white balance. | `"Auto"` |
| POST | `/takepicture` | Takes a picture and returns the file (JPEG by default). Query parameters: `useAutoFocus` (default `true`), `fileTypes` (e.g. `cr3`, `jpg,cr3`, `*`). The file name is in the `X-File-Name` header. | N/A |
@@ -85,6 +89,7 @@ Setting values are sent as a JSON string, e.g. `"100"` with `Content-Type: appli
| POST | `/autofocus` | Triggers the camera's autofocus mechanism. | N/A |
Values the camera does not know a label for are returned as raw hexadecimal values (e.g. `"0x99"`), which can also be sent back.
+A setting that is not valid in the current state (e.g. exposure compensation in M) reads `"Not valid"`.
### Errors
@@ -92,12 +97,44 @@ Errors are returned as [problem details](https://www.rfc-editor.org/rfc/rfc9457)
| Status | Meaning |
|--------|---------|
-| 400 | Invalid value, or value not accepted by the camera now (the message lists the accepted values). |
+| 400 | Invalid value, or value not accepted by the camera now (`acceptedValues` lists the accepted values). |
| 404 | No picture / live view image available. |
-| 409 | The camera refused to take the picture (focus failure, no lens, movie mode...). |
+| 409 | The camera refused to take the picture (focus failure, no lens, movie mode...), or the setting cannot be changed in the current shooting mode. |
| 503 | Camera not connected, or busy (`Retry-After` header). |
| 504 | The camera did not deliver the picture in time. |
+#### Setting errors
+
+`POST /settings` and the setters (`/iso`, `/aperture`, `/shutterspeed`, `/exposure`, `/whitebalance`) check the value before writing it
+and answer with the same machine readable problem details:
+
+| Status | `reason` | Meaning |
+|--------|----------|---------|
+| 400 | `invalid-value` | The label cannot be read (e.g. `"1/7"`, `"f/5.6"`). |
+| 400 | `value-not-accepted` | The camera does not accept this value now (lens, exposure step...). |
+| 409 | `not-settable-in-mode` | The setting cannot be changed in the current shooting mode, or the camera accepts no value for it now (empty list: movie mode, no lens...). |
+
+Extensions: `reason`, `property` (`iso`, `aperture`, `shutterSpeed`, `exposureCompensation`, `whiteBalance`), `acceptedValues`,
+`aeMode`, `aeModeCode`, and `errors`: every refused setting (`property`, `value`, `reason`, `acceptedValues`, `detail`).
+With `POST /settings`, nothing is written when a value is refused; when several are, the status is 409 if one of them
+cannot be changed in the current mode (the top level fields then describe that one), 400 otherwise.
+
+Settings that can be changed in each mode (other modes: none, the camera chooses by itself). The EDSDK does not report
+it (the `form`/`access` fields of `EdsGetPropertyDesc` are reserved), hence this table:
+
+| Mode | ISO | Aperture | Shutter speed | Exposure compensation | White balance |
+|------|-----|----------|---------------|-----------------------|---------------|
+| P, A-DEP | ✓ | | | ✓ | ✓ |
+| Tv | ✓ | | ✓ | ✓ | ✓ |
+| Av | ✓ | ✓ | | ✓ | ✓ |
+| M | ✓ | ✓ | ✓ | | ✓ |
+| Bulb | ✓ | ✓ | | | ✓ |
+| Fv | ✓ | ✓ | ✓ | ✓ | ✓ |
+
+If the camera refuses a value while `POST /settings` writes it, the problem details list the settings already written in
+`applied` and the one that failed in `failed` (`property`, `value`, `detail`, `errorCode`); the status follows the camera error
+(e.g. 503 when busy or disconnected).
+
## Configuration
Settings are read from `appsettings.json` (next to the executable) or environment variables (e.g. `Canon__CaptureFileTypes__0=jpg`).
@@ -150,7 +187,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.2.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.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
5. **Automatic packaging**: Creates release packages and updates the AutoUpdater XML
diff --git a/docs/openapi.json b/docs/openapi.json
index 2590331..7d92c91 100644
--- a/docs/openapi.json
+++ b/docs/openapi.json
@@ -2,8 +2,8 @@
"openapi": "3.1.1",
"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, 503 camera not connected or busy, 504 capture timeout.",
- "version": "1.2.0"
+ "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"
},
"paths": {
"/cameraname": {
@@ -279,6 +279,126 @@
}
}
},
+ "/settings": {
+ "get": {
+ "tags": [
+ "Canon"
+ ],
+ "summary": "Shooting mode, ISO, aperture, shutter speed 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": {
+ "description": "Service Unavailable",
+ "content": {
+ "application/problem+json": {
+ "schema": {
+ "$ref": "#/components/schemas/ProblemDetails"
+ }
+ }
+ }
+ },
+ "500": {
+ "description": "Internal Server Error",
+ "content": {
+ "application/problem+json": {
+ "schema": {
+ "$ref": "#/components/schemas/ProblemDetails"
+ }
+ }
+ }
+ },
+ "200": {
+ "description": "OK",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/CameraSettings"
+ }
+ }
+ }
+ }
+ }
+ },
+ "post": {
+ "tags": [
+ "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.",
+ "requestBody": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/CameraSettingsRequest"
+ }
+ },
+ "text/json": {
+ "schema": {
+ "$ref": "#/components/schemas/CameraSettingsRequest"
+ }
+ },
+ "application/*+json": {
+ "schema": {
+ "$ref": "#/components/schemas/CameraSettingsRequest"
+ }
+ }
+ },
+ "required": true
+ },
+ "responses": {
+ "503": {
+ "description": "Service Unavailable",
+ "content": {
+ "application/problem+json": {
+ "schema": {
+ "$ref": "#/components/schemas/ProblemDetails"
+ }
+ }
+ }
+ },
+ "500": {
+ "description": "Internal Server Error",
+ "content": {
+ "application/problem+json": {
+ "schema": {
+ "$ref": "#/components/schemas/ProblemDetails"
+ }
+ }
+ }
+ },
+ "200": {
+ "description": "OK",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/CameraSettings"
+ }
+ }
+ }
+ },
+ "400": {
+ "description": "Bad Request",
+ "content": {
+ "application/problem+json": {
+ "schema": {
+ "$ref": "#/components/schemas/ProblemDetails"
+ }
+ }
+ }
+ },
+ "409": {
+ "description": "Conflict",
+ "content": {
+ "application/problem+json": {
+ "schema": {
+ "$ref": "#/components/schemas/ProblemDetails"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
"/iso": {
"get": {
"tags": [
@@ -323,6 +443,7 @@
"Canon"
],
"summary": "Set the ISO speed (e.g. \"Auto\", \"400\")",
+ "description": "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": {
@@ -376,6 +497,16 @@
}
}
}
+ },
+ "409": {
+ "description": "Conflict",
+ "content": {
+ "application/problem+json": {
+ "schema": {
+ "$ref": "#/components/schemas/ProblemDetails"
+ }
+ }
+ }
}
}
}
@@ -424,6 +555,7 @@
"Canon"
],
"summary": "Set the aperture (e.g. \"5.6\")",
+ "description": "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": {
@@ -477,6 +609,16 @@
}
}
}
+ },
+ "409": {
+ "description": "Conflict",
+ "content": {
+ "application/problem+json": {
+ "schema": {
+ "$ref": "#/components/schemas/ProblemDetails"
+ }
+ }
+ }
}
}
}
@@ -525,6 +667,7 @@
"Canon"
],
"summary": "Set the shutter speed (e.g. \"1/125\", \"2\\\"\")",
+ "description": "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": {
@@ -578,6 +721,16 @@
}
}
}
+ },
+ "409": {
+ "description": "Conflict",
+ "content": {
+ "application/problem+json": {
+ "schema": {
+ "$ref": "#/components/schemas/ProblemDetails"
+ }
+ }
+ }
}
}
}
@@ -626,7 +779,7 @@
"Canon"
],
"summary": "Set the exposure compensation (e.g. \"0\", \"+1/3\", \"-1 2/3\")",
- "description": "Not available in manual exposure mode.",
+ "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.",
"requestBody": {
"content": {
"application/json": {
@@ -680,6 +833,16 @@
}
}
}
+ },
+ "409": {
+ "description": "Conflict",
+ "content": {
+ "application/problem+json": {
+ "schema": {
+ "$ref": "#/components/schemas/ProblemDetails"
+ }
+ }
+ }
}
}
}
@@ -728,6 +891,7 @@
"Canon"
],
"summary": "Set the white balance (e.g. \"Auto\", \"Daylight\")",
+ "description": "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": {
@@ -781,6 +945,16 @@
}
}
}
+ },
+ "409": {
+ "description": "Conflict",
+ "content": {
+ "application/problem+json": {
+ "schema": {
+ "$ref": "#/components/schemas/ProblemDetails"
+ }
+ }
+ }
}
}
}
@@ -1083,6 +1257,88 @@
}
}
},
+ "CameraSettingState": {
+ "required": [
+ "value",
+ "supportedValues",
+ "settable"
+ ],
+ "type": "object",
+ "properties": {
+ "value": {
+ "type": "string"
+ },
+ "supportedValues": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ }
+ },
+ "settable": {
+ "type": "boolean"
+ }
+ }
+ },
+ "CameraSettings": {
+ "required": [
+ "mode",
+ "iso",
+ "aperture",
+ "shutterSpeed",
+ "whiteBalance"
+ ],
+ "type": "object",
+ "properties": {
+ "mode": {
+ "$ref": "#/components/schemas/CameraMode"
+ },
+ "iso": {
+ "$ref": "#/components/schemas/CameraSettingState"
+ },
+ "aperture": {
+ "$ref": "#/components/schemas/CameraSettingState"
+ },
+ "shutterSpeed": {
+ "$ref": "#/components/schemas/CameraSettingState"
+ },
+ "whiteBalance": {
+ "$ref": "#/components/schemas/CameraSettingState"
+ }
+ }
+ },
+ "CameraSettingsRequest": {
+ "type": "object",
+ "properties": {
+ "iso": {
+ "type": [
+ "null",
+ "string"
+ ],
+ "default": null
+ },
+ "aperture": {
+ "type": [
+ "null",
+ "string"
+ ],
+ "default": null
+ },
+ "shutterSpeed": {
+ "type": [
+ "null",
+ "string"
+ ],
+ "default": null
+ },
+ "whiteBalance": {
+ "type": [
+ "null",
+ "string"
+ ],
+ "default": null
+ }
+ }
+ },
"CameraStatusResponse": {
"required": [
"connected",